Skip to main content

a3s_code_core/store/
encryption.rs

1//! At-rest encryption for file session-store documents (KRN-6 / STORE-ENCRYPT1).
2//!
3//! Sealed files use a binary envelope so on-disk bytes are not JSON plaintext.
4//! The digest-only WAL remains unencrypted by design (it never stores session
5//! payloads). Hosts supply a 32-byte key; Code does not invent key management.
6
7use aes_gcm::aead::{Aead, KeyInit, OsRng};
8use aes_gcm::{AeadCore, Aes256Gcm, Nonce};
9use anyhow::{bail, Context, Result};
10
11const ENVELOPE_MAGIC: &[u8; 4] = b"A3SE";
12const ENVELOPE_VERSION: u8 = 1;
13const NONCE_LEN: usize = 12;
14const HEADER_LEN: usize = 4 + 1 + NONCE_LEN;
15
16/// AES-256-GCM cipher used by [`super::FileSessionStore`] when constructed
17/// with an encryption key.
18#[derive(Clone)]
19pub struct SessionStoreAtRestCipher {
20    cipher: Aes256Gcm,
21}
22
23impl SessionStoreAtRestCipher {
24    pub fn new(key: &[u8; 32]) -> Result<Self> {
25        let cipher = Aes256Gcm::new_from_slice(key)
26            .context("session store at-rest cipher key must be 32 bytes")?;
27        Ok(Self { cipher })
28    }
29
30    pub fn is_sealed(bytes: &[u8]) -> bool {
31        bytes.len() > HEADER_LEN
32            && bytes.starts_with(ENVELOPE_MAGIC)
33            && bytes[4] == ENVELOPE_VERSION
34    }
35
36    pub fn seal(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
37        let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
38        let ciphertext = self
39            .cipher
40            .encrypt(&nonce, plaintext)
41            .map_err(|_| anyhow::anyhow!("session store at-rest encryption failed"))?;
42        let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len());
43        out.extend_from_slice(ENVELOPE_MAGIC);
44        out.push(ENVELOPE_VERSION);
45        out.extend_from_slice(nonce.as_slice());
46        out.extend_from_slice(&ciphertext);
47        Ok(out)
48    }
49
50    pub fn open(&self, sealed: &[u8]) -> Result<Vec<u8>> {
51        if !Self::is_sealed(sealed) {
52            bail!("session store document is not an at-rest sealed envelope");
53        }
54        let nonce = Nonce::from_slice(&sealed[5..5 + NONCE_LEN]);
55        self.cipher
56            .decrypt(nonce, &sealed[HEADER_LEN..])
57            .map_err(|_| anyhow::anyhow!("session store at-rest decryption failed"))
58    }
59
60    /// Decrypt a sealed envelope, or return plaintext bytes unchanged for
61    /// migration of pre-encryption documents.
62    pub fn open_or_plaintext(&self, bytes: &[u8]) -> Result<Vec<u8>> {
63        if Self::is_sealed(bytes) {
64            self.open(bytes)
65        } else {
66            Ok(bytes.to_vec())
67        }
68    }
69}
70
71#[cfg(test)]
72mod tests {
73    use super::*;
74
75    #[test]
76    fn seal_round_trip_and_wrong_key_fail_closed() {
77        let a = SessionStoreAtRestCipher::new(&[7u8; 32]).unwrap();
78        let b = SessionStoreAtRestCipher::new(&[8u8; 32]).unwrap();
79        let sealed = a.seal(b"{\"ok\":true}").unwrap();
80        assert!(SessionStoreAtRestCipher::is_sealed(&sealed));
81        assert_ne!(&sealed[..], b"{\"ok\":true}");
82        assert_eq!(a.open(&sealed).unwrap(), b"{\"ok\":true}");
83        assert!(b.open(&sealed).is_err());
84        assert_eq!(a.open_or_plaintext(b"plain").unwrap(), b"plain");
85    }
86}