1use 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
34const 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 Plaintext,
45 LocalKey([u8; KEY_LEN]),
47 HostedPending,
50}
51
52#[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 pub fn plaintext() -> Self {
75 Self {
76 inner: CryptoInner::Plaintext,
77 }
78 }
79
80 pub fn local_key(key: [u8; KEY_LEN]) -> Self {
82 Self {
83 inner: CryptoInner::LocalKey(key),
84 }
85 }
86
87 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 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 MemoryCryptoMode::HostedKms { .. } => Self {
114 inner: CryptoInner::HostedPending,
115 },
116 }
117 }
118
119 pub fn is_plaintext(&self) -> bool {
121 matches!(self.inner, CryptoInner::Plaintext)
122 }
123
124 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 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 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 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
236fn 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 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 assert!(stored.starts_with(CIPHERTEXT_PREFIX));
340 assert!(!stored.contains("confidential"));
341 assert!(!stored.contains("launch date"));
342
343 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 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 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()); }
426}