Skip to main content

tandem_memory/
crypto.rs

1//! Memory payload encryption (ciphertext-at-rest).
2//!
3//! Semantic memory text columns are encrypted before they are written to the
4//! database and decrypted on read, so a raw database dump does not reveal tenant
5//! memory plaintext. This is driven by the crypto mode resolved in
6//! [`crate::decrypt_broker`]:
7//!
8//! - **Local plaintext** (default single-user): a no-op — existing/local data is
9//!   stored and read as plaintext, relying on host/file security.
10//! - **Local encrypted**: AES-256-GCM with a host key file (0600) under the
11//!   tandem home directory, generated on first use.
12//! - **Hosted KMS**: requires a KMS-backed DEK via the decrypt broker. Until a
13//!   KMS provider is provisioned, hosted mode **fails closed** on write rather
14//!   than silently storing plaintext.
15//!
16//! Stored ciphertext is self-describing (`tce1:<hex(nonce||ciphertext+tag)>`).
17//! In local plaintext and local-encrypted modes, legacy plaintext rows are read
18//! as plain text for compatibility, but hosted modes reject plaintext rows to
19//! enforce fail-closed behavior at rest.
20//!
21//! Embeddings (sqlite-vec KNN) and the FTS-indexed `memory_records.content`
22//! column cannot be encrypted without breaking similarity/full-text search; they
23//! are classified as search-required plaintext and governed by authority-scoped
24//! reads instead. See `docs/internal` / the BR-14 notes.
25
26use std::path::{Path, PathBuf};
27
28use aes_gcm::aead::{Aead, KeyInit};
29use aes_gcm::{Aes256Gcm, Key, Nonce};
30
31use crate::decrypt_broker::{MemoryCryptoMode, MemoryDecryptBrokerConfig};
32use crate::types::{MemoryError, MemoryResult};
33
34/// Self-describing prefix for an encrypted memory field (tandem crypto
35/// envelope, version 1).
36const CIPHERTEXT_PREFIX: &str = "tce1:";
37const LOCAL_KEY_FILE_ENV: &str = "TANDEM_MEMORY_LOCAL_KEY_FILE";
38const NONCE_LEN: usize = 12;
39const KEY_LEN: usize = 32;
40
41#[derive(Clone)]
42enum CryptoInner {
43    /// No encryption (local plaintext / backward compatibility).
44    Plaintext,
45    /// Local AES-256-GCM with a host-held key.
46    LocalKey([u8; KEY_LEN]),
47    /// Hosted mode whose KMS-backed DEK provider is not yet available; writes
48    /// fail closed so plaintext is never persisted under a hosted requirement.
49    HostedPending,
50}
51
52/// Encrypts/decrypts individual memory text fields according to the active
53/// crypto mode. Cheap to clone.
54#[derive(Clone)]
55pub struct MemoryCryptoProvider {
56    inner: CryptoInner,
57}
58
59impl std::fmt::Debug for MemoryCryptoProvider {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        let label = match self.inner {
62            CryptoInner::Plaintext => "plaintext",
63            CryptoInner::LocalKey(_) => "local_key",
64            CryptoInner::HostedPending => "hosted_pending",
65        };
66        f.debug_struct("MemoryCryptoProvider")
67            .field("mode", &label)
68            .finish()
69    }
70}
71
72impl MemoryCryptoProvider {
73    /// A no-op provider: fields are stored and read as plaintext.
74    pub fn plaintext() -> Self {
75        Self {
76            inner: CryptoInner::Plaintext,
77        }
78    }
79
80    /// A local AES-256-GCM provider backed by the given 256-bit key.
81    pub fn local_key(key: [u8; KEY_LEN]) -> Self {
82        Self {
83            inner: CryptoInner::LocalKey(key),
84        }
85    }
86
87    /// Resolve the provider from the environment-selected crypto mode.
88    pub fn from_env() -> Self {
89        let config = MemoryDecryptBrokerConfig::from_env()
90            .unwrap_or_else(|_| MemoryDecryptBrokerConfig::local_disabled());
91        Self::from_mode(config.crypto_mode())
92    }
93
94    /// Build a provider for an explicit crypto mode.
95    pub fn from_mode(mode: MemoryCryptoMode) -> Self {
96        match mode {
97            MemoryCryptoMode::LocalPlaintext => Self::plaintext(),
98            MemoryCryptoMode::LocalEncrypted { .. } => {
99                match load_or_create_local_key(&local_key_path()) {
100                    Ok(key) => Self::local_key(key),
101                    Err(err) => {
102                        tracing::error!(
103                        "local memory encryption is configured but the key could not be loaded ({err}); failing closed"
104                    );
105                        Self {
106                            inner: CryptoInner::HostedPending,
107                        }
108                    }
109                }
110            }
111            // Hosted KMS-backed encryption requires a provisioned DEK provider
112            // (BR-12). Until then, fail closed rather than store plaintext.
113            MemoryCryptoMode::HostedKms { .. } => Self {
114                inner: CryptoInner::HostedPending,
115            },
116        }
117    }
118
119    /// True when fields are stored as plaintext (no encryption applied).
120    pub fn is_plaintext(&self) -> bool {
121        matches!(self.inner, CryptoInner::Plaintext)
122    }
123
124    /// Encrypt a memory text field for storage. Plaintext mode returns the input
125    /// unchanged; hosted-pending mode fails closed.
126    pub fn encrypt_field(&self, plaintext: &str) -> MemoryResult<String> {
127        match &self.inner {
128            CryptoInner::Plaintext => Ok(plaintext.to_string()),
129            CryptoInner::LocalKey(key) => encrypt_with_key(key, plaintext),
130            CryptoInner::HostedPending => Err(MemoryError::InvalidConfig(
131                "hosted memory encryption requires a provisioned KMS provider; refusing to store plaintext (fail-closed)"
132                    .to_string(),
133            )),
134        }
135    }
136
137    /// Decrypt a stored memory text field.
138    ///
139    /// - In plaintext and local-encrypted modes, values without the encryption
140    ///   prefix are treated as legacy plaintext for compatibility.
141    /// - In hosted mode, plaintext rows are rejected to avoid leaving memory
142    ///   readable at rest under encryption-required semantics.
143    pub fn decrypt_field(&self, stored: &str) -> MemoryResult<String> {
144        let Some(hex_blob) = stored.strip_prefix(CIPHERTEXT_PREFIX) else {
145            return match &self.inner {
146                CryptoInner::Plaintext | CryptoInner::LocalKey(_) => Ok(stored.to_string()),
147                CryptoInner::HostedPending => Err(MemoryError::InvalidConfig(
148                    "hosted memory mode requires encrypted rows (missing tce1 payload marker)"
149                        .to_string(),
150                )),
151            };
152        };
153
154        match &self.inner {
155            CryptoInner::LocalKey(key) => decrypt_with_key(key, hex_blob),
156            CryptoInner::Plaintext => Ok(stored.to_string()),
157            CryptoInner::HostedPending => Err(MemoryError::InvalidConfig(
158                "encrypted memory field cannot be read without the configured decryption key"
159                    .to_string(),
160            )),
161        }
162    }
163
164    /// Encrypt an optional JSON-ish metadata string if present.
165    pub fn encrypt_optional(&self, value: Option<&str>) -> MemoryResult<Option<String>> {
166        match value {
167            Some(text) => Ok(Some(self.encrypt_field(text)?)),
168            None => Ok(None),
169        }
170    }
171
172    /// Decrypt an optional stored field if present.
173    pub fn decrypt_optional(&self, value: Option<&str>) -> MemoryResult<Option<String>> {
174        match value {
175            Some(text) => Ok(Some(self.decrypt_field(text)?)),
176            None => Ok(None),
177        }
178    }
179}
180
181impl Default for MemoryCryptoProvider {
182    fn default() -> Self {
183        Self::plaintext()
184    }
185}
186
187fn encrypt_with_key(key: &[u8; KEY_LEN], plaintext: &str) -> MemoryResult<String> {
188    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
189    let nonce_bytes = random_bytes::<NONCE_LEN>()?;
190    let ciphertext = cipher
191        .encrypt(Nonce::from_slice(&nonce_bytes), plaintext.as_bytes())
192        .map_err(|_| MemoryError::InvalidConfig("memory field encryption failed".to_string()))?;
193    let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len());
194    blob.extend_from_slice(&nonce_bytes);
195    blob.extend_from_slice(&ciphertext);
196    Ok(format!("{CIPHERTEXT_PREFIX}{}", to_hex(&blob)))
197}
198
199fn decrypt_with_key(key: &[u8; KEY_LEN], hex_blob: &str) -> MemoryResult<String> {
200    let blob = from_hex(hex_blob).ok_or_else(|| {
201        MemoryError::InvalidConfig("memory field ciphertext is malformed".to_string())
202    })?;
203    if blob.len() < NONCE_LEN {
204        return Err(MemoryError::InvalidConfig(
205            "memory field ciphertext is too short".to_string(),
206        ));
207    }
208    let (nonce_bytes, ciphertext) = blob.split_at(NONCE_LEN);
209    let cipher = Aes256Gcm::new(Key::<Aes256Gcm>::from_slice(key));
210    let plaintext = cipher
211        .decrypt(Nonce::from_slice(nonce_bytes), ciphertext)
212        .map_err(|_| MemoryError::InvalidConfig("memory field decryption failed".to_string()))?;
213    String::from_utf8(plaintext).map_err(|_| {
214        MemoryError::InvalidConfig("decrypted memory field is not valid UTF-8".to_string())
215    })
216}
217
218fn random_bytes<const N: usize>() -> MemoryResult<[u8; N]> {
219    let mut buf = [0u8; N];
220    getrandom::getrandom(&mut buf)
221        .map_err(|err| MemoryError::InvalidConfig(format!("secure RNG unavailable: {err}")))?;
222    Ok(buf)
223}
224
225fn local_key_path() -> PathBuf {
226    if let Ok(explicit) = std::env::var(LOCAL_KEY_FILE_ENV) {
227        let trimmed = explicit.trim();
228        if !trimmed.is_empty() {
229            return PathBuf::from(trimmed);
230        }
231    }
232    let base = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
233    base.join(".tandem").join("memory").join("local_memory.key")
234}
235
236/// Load a 256-bit local key from `path`, generating and persisting one (0600) on
237/// first use.
238fn load_or_create_local_key(path: &Path) -> MemoryResult<[u8; KEY_LEN]> {
239    if let Ok(bytes) = std::fs::read(path) {
240        if bytes.len() == KEY_LEN {
241            let mut key = [0u8; KEY_LEN];
242            key.copy_from_slice(&bytes);
243            return Ok(key);
244        }
245        // Tolerate a hex-encoded key file.
246        if let Some(decoded) = std::str::from_utf8(&bytes)
247            .ok()
248            .and_then(|text| from_hex(text.trim()))
249        {
250            if decoded.len() == KEY_LEN {
251                let mut key = [0u8; KEY_LEN];
252                key.copy_from_slice(&decoded);
253                return Ok(key);
254            }
255        }
256        return Err(MemoryError::InvalidConfig(format!(
257            "local memory key file `{}` is not a valid 256-bit key",
258            path.display()
259        )));
260    }
261
262    let key = random_bytes::<KEY_LEN>()?;
263    if let Some(parent) = path.parent() {
264        std::fs::create_dir_all(parent).map_err(|err| {
265            MemoryError::InvalidConfig(format!("failed to create local key directory: {err}"))
266        })?;
267    }
268    std::fs::write(path, key).map_err(|err| {
269        MemoryError::InvalidConfig(format!("failed to write local memory key file: {err}"))
270    })?;
271    set_key_file_permissions(path);
272    Ok(key)
273}
274
275#[cfg(unix)]
276fn set_key_file_permissions(path: &Path) {
277    use std::os::unix::fs::PermissionsExt;
278    if let Ok(metadata) = std::fs::metadata(path) {
279        let mut perms = metadata.permissions();
280        perms.set_mode(0o600);
281        let _ = std::fs::set_permissions(path, perms);
282    }
283}
284
285#[cfg(not(unix))]
286fn set_key_file_permissions(_path: &Path) {}
287
288fn to_hex(bytes: &[u8]) -> String {
289    let mut out = String::with_capacity(bytes.len() * 2);
290    for byte in bytes {
291        out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
292        out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
293    }
294    out
295}
296
297fn from_hex(text: &str) -> Option<Vec<u8>> {
298    let text = text.trim();
299    if text.is_empty() || !text.len().is_multiple_of(2) {
300        return None;
301    }
302    let mut out = Vec::with_capacity(text.len() / 2);
303    let bytes = text.as_bytes();
304    let mut i = 0;
305    while i < bytes.len() {
306        let hi = (bytes[i] as char).to_digit(16)?;
307        let lo = (bytes[i + 1] as char).to_digit(16)?;
308        out.push(((hi << 4) | lo) as u8);
309        i += 2;
310    }
311    Some(out)
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317
318    #[test]
319    fn plaintext_provider_is_noop_and_passes_through_legacy() {
320        let provider = MemoryCryptoProvider::plaintext();
321        assert!(provider.is_plaintext());
322        assert_eq!(
323            provider.encrypt_field("secret memory").unwrap(),
324            "secret memory"
325        );
326        assert_eq!(
327            provider.decrypt_field("secret memory").unwrap(),
328            "secret memory"
329        );
330    }
331
332    #[test]
333    fn local_key_round_trips_and_is_ciphertext_at_rest() {
334        let provider = MemoryCryptoProvider::local_key([7u8; KEY_LEN]);
335        let plaintext = "tenant A confidential note: launch date is 2026-09-01";
336        let stored = provider.encrypt_field(plaintext).unwrap();
337
338        // Stored form is opaque ciphertext, not the plaintext.
339        assert!(stored.starts_with(CIPHERTEXT_PREFIX));
340        assert!(!stored.contains("confidential"));
341        assert!(!stored.contains("launch date"));
342
343        // Round-trips back to plaintext.
344        assert_eq!(provider.decrypt_field(&stored).unwrap(), plaintext);
345    }
346
347    #[test]
348    fn encryption_uses_a_fresh_nonce_each_time() {
349        let provider = MemoryCryptoProvider::local_key([3u8; KEY_LEN]);
350        let a = provider.encrypt_field("same plaintext").unwrap();
351        let b = provider.encrypt_field("same plaintext").unwrap();
352        assert_ne!(
353            a, b,
354            "nonce reuse would make identical plaintext produce identical ciphertext"
355        );
356        assert_eq!(provider.decrypt_field(&a).unwrap(), "same plaintext");
357        assert_eq!(provider.decrypt_field(&b).unwrap(), "same plaintext");
358    }
359
360    #[test]
361    fn local_key_reads_legacy_plaintext_rows() {
362        // Existing plaintext data (no prefix) remains readable after enabling
363        // local encryption — no migration required.
364        let provider = MemoryCryptoProvider::local_key([9u8; KEY_LEN]);
365        assert_eq!(
366            provider.decrypt_field("legacy plaintext").unwrap(),
367            "legacy plaintext"
368        );
369    }
370
371    #[test]
372    fn wrong_key_cannot_decrypt() {
373        let writer = MemoryCryptoProvider::local_key([1u8; KEY_LEN]);
374        let reader = MemoryCryptoProvider::local_key([2u8; KEY_LEN]);
375        let stored = writer.encrypt_field("cross-tenant secret").unwrap();
376        assert!(reader.decrypt_field(&stored).is_err());
377    }
378
379    #[test]
380    fn hosted_pending_fails_closed_on_write() {
381        let provider = MemoryCryptoProvider::from_mode(MemoryCryptoMode::HostedKms {
382            provider: "google_cloud_kms".to_string(),
383        });
384        assert!(
385            provider
386                .encrypt_field("must not be stored as plaintext")
387                .is_err(),
388            "hosted mode without a KMS provider must fail closed"
389        );
390        // Plaintext mode reading an encrypted value also fails closed.
391        assert!(provider
392            .decrypt_field(&format!("{CIPHERTEXT_PREFIX}deadbeef"))
393            .is_err());
394
395        assert!(
396            provider.decrypt_field("legacy memory row").is_err(),
397            "hosted mode should reject plaintext rows to avoid compatibility leakage"
398        );
399    }
400
401    #[test]
402    fn local_encrypted_mode_generates_and_reuses_a_key_file() {
403        let dir = std::env::temp_dir().join(format!("tandem-mem-key-{}", uuid::Uuid::new_v4()));
404        let key_path = dir.join("local_memory.key");
405        let key1 = load_or_create_local_key(&key_path).expect("create key");
406        assert!(key_path.exists());
407        let key2 = load_or_create_local_key(&key_path).expect("reload key");
408        assert_eq!(key1, key2, "key file must be stable across loads");
409        #[cfg(unix)]
410        {
411            use std::os::unix::fs::PermissionsExt;
412            let mode = std::fs::metadata(&key_path).unwrap().permissions().mode();
413            assert_eq!(mode & 0o777, 0o600, "key file must be 0600");
414        }
415        let _ = std::fs::remove_dir_all(&dir);
416    }
417
418    #[test]
419    fn hex_round_trips() {
420        let bytes = [0u8, 1, 15, 16, 255, 128, 64];
421        let hex = to_hex(&bytes);
422        assert_eq!(from_hex(&hex).unwrap(), bytes);
423        assert!(from_hex("xyz").is_none());
424        assert!(from_hex("abc").is_none()); // odd length
425    }
426}