fraiseql_secrets/encryption/
mod.rs1use aes_gcm::{
12 Aes256Gcm, Nonce,
13 aead::{Aead, KeyInit, Payload},
14};
15use rand::RngCore;
16
17use crate::secrets_manager::SecretsError;
18
19pub mod audit_logging;
20pub mod compliance;
21pub mod credential_rotation;
22pub mod dashboard;
23pub mod database_adapter;
24pub mod error_recovery;
25pub mod mapper;
26pub mod middleware;
27pub mod performance;
28pub mod query_builder;
29pub mod refresh_trigger;
30pub mod rotation_api;
31pub mod schema;
32pub mod transaction;
33
34pub use credential_rotation::KeyVersion;
35
36const NONCE_SIZE: usize = 12; const KEY_SIZE: usize = 32; pub struct FieldEncryption {
61 cipher: Aes256Gcm,
62}
63
64impl std::fmt::Debug for FieldEncryption {
65 #[cfg_attr(test, mutants::skip)]
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("FieldEncryption")
70 .field("cipher", &"Aes256Gcm(redacted)")
71 .finish()
72 }
73}
74
75impl FieldEncryption {
76 pub fn new(key: &[u8]) -> Result<Self, SecretsError> {
84 if key.len() != KEY_SIZE {
85 return Err(SecretsError::ValidationError(format!(
86 "Encryption key must be exactly {} bytes, got {}",
87 KEY_SIZE,
88 key.len()
89 )));
90 }
91
92 let cipher = Aes256Gcm::new_from_slice(key)
93 .map_err(|e| SecretsError::EncryptionError(format!("Failed to create cipher: {e}")))?;
94
95 Ok(FieldEncryption { cipher })
96 }
97
98 fn generate_nonce() -> [u8; NONCE_SIZE] {
103 let mut nonce_bytes = [0u8; NONCE_SIZE];
104 rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
105 nonce_bytes
106 }
107
108 fn extract_nonce_and_ciphertext(
116 encrypted: &[u8],
117 ) -> Result<([u8; NONCE_SIZE], &[u8]), SecretsError> {
118 if encrypted.len() < NONCE_SIZE {
119 return Err(SecretsError::EncryptionError(format!(
120 "Encrypted data too short (need ≥{} bytes, got {})",
121 NONCE_SIZE,
122 encrypted.len()
123 )));
124 }
125
126 let mut nonce = [0u8; NONCE_SIZE];
127 nonce.copy_from_slice(&encrypted[0..NONCE_SIZE]);
128 let ciphertext = &encrypted[NONCE_SIZE..];
129
130 Ok((nonce, ciphertext))
131 }
132
133 fn bytes_to_utf8(bytes: Vec<u8>, context: &str) -> Result<String, SecretsError> {
137 String::from_utf8(bytes).map_err(|e| {
138 SecretsError::EncryptionError(format!("Invalid UTF-8 in {}: {}", context, e))
139 })
140 }
141
142 pub fn encrypt(&self, plaintext: &str) -> Result<Vec<u8>, SecretsError> {
156 let nonce_bytes = Self::generate_nonce();
157 let nonce = Nonce::from_slice(&nonce_bytes);
158
159 let ciphertext = self
160 .cipher
161 .encrypt(nonce, plaintext.as_bytes())
162 .map_err(|e| SecretsError::EncryptionError(format!("Encryption failed: {}", e)))?;
163
164 let mut result = nonce_bytes.to_vec();
166 result.extend_from_slice(&ciphertext);
167 Ok(result)
168 }
169
170 pub fn decrypt(&self, encrypted: &[u8]) -> Result<String, SecretsError> {
187 let (nonce_bytes, ciphertext) = Self::extract_nonce_and_ciphertext(encrypted)?;
188
189 let nonce = Nonce::from_slice(&nonce_bytes);
190 let plaintext_bytes = self.cipher.decrypt(nonce, ciphertext).map_err(|_| {
191 SecretsError::EncryptionError(
192 "Decryption failed: authentication tag mismatch. \
193 Possible causes: wrong key, corrupted data, or data was encrypted \
194 with context (use decrypt_with_context instead)."
195 .to_string(),
196 )
197 })?;
198
199 Self::bytes_to_utf8(plaintext_bytes, "decrypted data")
200 }
201
202 pub fn encrypt_with_context(
215 &self,
216 plaintext: &str,
217 context: &str,
218 ) -> Result<Vec<u8>, SecretsError> {
219 let nonce_bytes = Self::generate_nonce();
220 let nonce = Nonce::from_slice(&nonce_bytes);
221
222 let payload = Payload {
223 msg: plaintext.as_bytes(),
224 aad: context.as_bytes(),
225 };
226
227 let ciphertext = self.cipher.encrypt(nonce, payload).map_err(|e| {
228 SecretsError::EncryptionError(format!("Encryption with context failed: {}", e))
229 })?;
230
231 let mut result = nonce_bytes.to_vec();
232 result.extend_from_slice(&ciphertext);
233 Ok(result)
234 }
235
236 pub fn decrypt_with_context(
250 &self,
251 encrypted: &[u8],
252 context: &str,
253 ) -> Result<String, SecretsError> {
254 let (nonce_bytes, ciphertext) = Self::extract_nonce_and_ciphertext(encrypted)?;
255
256 let nonce = Nonce::from_slice(&nonce_bytes);
257 let payload = Payload {
258 msg: ciphertext,
259 aad: context.as_bytes(),
260 };
261
262 let plaintext_bytes = self.cipher.decrypt(nonce, payload).map_err(|_| {
263 SecretsError::EncryptionError(
264 "Decryption with context failed: authentication tag mismatch. \
265 Ensure the context string supplied here exactly matches the one \
266 used during encryption. This can also indicate key mismatch \
267 or data corruption."
268 .to_string(),
269 )
270 })?;
271
272 Self::bytes_to_utf8(plaintext_bytes, "decrypted data with context")
273 }
274}
275
276const VERSION_PREFIX_SIZE: usize = 2;
278
279pub struct VersionedFieldEncryption {
295 primary_version: KeyVersion,
296 primary: FieldEncryption,
297 fallbacks: Vec<(KeyVersion, FieldEncryption)>,
298}
299
300impl VersionedFieldEncryption {
301 pub fn new(primary_version: KeyVersion, primary_key: &[u8]) -> Result<Self, SecretsError> {
307 Ok(Self {
308 primary_version,
309 primary: FieldEncryption::new(primary_key)?,
310 fallbacks: Vec::new(),
311 })
312 }
313
314 pub fn with_fallback(mut self, version: KeyVersion, key: &[u8]) -> Result<Self, SecretsError> {
320 self.fallbacks.push((version, FieldEncryption::new(key)?));
321 Ok(self)
322 }
323
324 pub fn encrypt(&self, plaintext: &str) -> Result<Vec<u8>, SecretsError> {
330 let inner = self.primary.encrypt(plaintext)?;
331 let mut out = Vec::with_capacity(VERSION_PREFIX_SIZE + inner.len());
332 out.extend_from_slice(&self.primary_version.to_le_bytes());
333 out.extend_from_slice(&inner);
334 Ok(out)
335 }
336
337 pub fn extract_version(encrypted: &[u8]) -> Result<KeyVersion, SecretsError> {
343 if encrypted.len() < VERSION_PREFIX_SIZE {
344 return Err(SecretsError::EncryptionError(format!(
345 "Versioned ciphertext too short (need ≥{VERSION_PREFIX_SIZE} bytes, got {})",
346 encrypted.len()
347 )));
348 }
349 Ok(u16::from_le_bytes([encrypted[0], encrypted[1]]))
350 }
351
352 pub fn decrypt(&self, encrypted: &[u8]) -> Result<String, SecretsError> {
361 let version = Self::extract_version(encrypted)?;
362 let inner = &encrypted[VERSION_PREFIX_SIZE..];
363
364 if version == self.primary_version {
365 return self.primary.decrypt(inner);
366 }
367
368 for (fb_version, fb_cipher) in &self.fallbacks {
369 if *fb_version == version {
370 return fb_cipher.decrypt(inner);
371 }
372 }
373
374 Err(SecretsError::EncryptionError(format!(
375 "Unknown key version {version}; known versions: primary={}, fallbacks=[{}]",
376 self.primary_version,
377 self.fallbacks.iter().map(|(v, _)| v.to_string()).collect::<Vec<_>>().join(", ")
378 )))
379 }
380
381 pub fn reencrypt_from_fallback(&self, old_ciphertext: &[u8]) -> Result<Vec<u8>, SecretsError> {
390 let plaintext = self.decrypt(old_ciphertext)?;
391 self.encrypt(&plaintext)
392 }
393}
394
395#[allow(clippy::unwrap_used)] #[cfg(test)]
397mod tests {
398 use super::*;
399
400 #[test]
402 fn test_field_encryption_creation() {
403 let key = [0u8; KEY_SIZE];
404 let _cipher = FieldEncryption::new(&key).unwrap();
405 }
406
407 #[test]
409 fn test_field_encrypt_decrypt_roundtrip() {
410 let key = [0u8; KEY_SIZE];
411 let cipher = FieldEncryption::new(&key).unwrap();
412
413 let plaintext = "user@example.com";
414 let encrypted = cipher.encrypt(plaintext).unwrap();
415 let decrypted = cipher.decrypt(&encrypted).unwrap();
416
417 assert_eq!(plaintext, decrypted);
418 assert_ne!(plaintext.as_bytes(), &encrypted[NONCE_SIZE..]);
419 }
420
421 #[test]
423 fn test_field_encrypt_random_nonce() {
424 let key = [0u8; KEY_SIZE];
425 let cipher = FieldEncryption::new(&key).unwrap();
426
427 let plaintext = "sensitive@data.com";
428 let encrypted1 = cipher.encrypt(plaintext).unwrap();
429 let encrypted2 = cipher.encrypt(plaintext).unwrap();
430
431 assert_ne!(encrypted1, encrypted2);
433
434 assert_eq!(cipher.decrypt(&encrypted1).unwrap(), plaintext);
436 assert_eq!(cipher.decrypt(&encrypted2).unwrap(), plaintext);
437 }
438
439 #[test]
441 fn test_field_encrypt_decrypt_with_context() {
442 let key = [0u8; KEY_SIZE];
443 let cipher = FieldEncryption::new(&key).unwrap();
444
445 let plaintext = "secret123";
446 let context = "user:456:password";
447
448 let encrypted = cipher.encrypt_with_context(plaintext, context).unwrap();
449 let decrypted = cipher.decrypt_with_context(&encrypted, context).unwrap();
450
451 assert_eq!(plaintext, decrypted);
452 }
453
454 #[test]
456 fn test_field_decrypt_with_wrong_context_fails() {
457 let key = [0u8; KEY_SIZE];
458 let cipher = FieldEncryption::new(&key).unwrap();
459
460 let plaintext = "secret123";
461 let correct_context = "user:456:password";
462 let wrong_context = "user:789:password";
463
464 let encrypted = cipher.encrypt_with_context(plaintext, correct_context).unwrap();
465
466 let result = cipher.decrypt_with_context(&encrypted, wrong_context);
468 assert!(
469 matches!(result, Err(SecretsError::EncryptionError(_))),
470 "expected EncryptionError for wrong context, got: {result:?}"
471 );
472 }
473
474 #[test]
476 fn test_field_encrypt_various_types() {
477 let key = [0u8; KEY_SIZE];
478 let cipher = FieldEncryption::new(&key).unwrap();
479
480 let test_cases = vec![
481 "email@example.com",
482 "+1-555-123-4567",
483 "123-45-6789",
484 "4532015112830366",
485 "sk_live_abc123def456",
486 "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9",
487 "", "with\nspecial\nchars\t!@#$%",
489 "unicode: 你好世界 🔐",
490 ];
491
492 for plaintext in test_cases {
493 let encrypted = cipher.encrypt(plaintext).unwrap();
494 let decrypted = cipher.decrypt(&encrypted).unwrap();
495 assert_eq!(plaintext, decrypted);
496 }
497 }
498
499 #[test]
501 fn test_field_encryption_invalid_key_size_returns_err() {
502 let invalid_key = [0u8; 16]; let result = FieldEncryption::new(&invalid_key);
504 assert!(
505 matches!(result, Err(SecretsError::ValidationError(_))),
506 "expected ValidationError for invalid key size, got: {result:?}"
507 );
508 }
509
510 #[test]
512 fn test_field_decrypt_corrupted_data_fails() {
513 let key = [0u8; KEY_SIZE];
514 let cipher = FieldEncryption::new(&key).unwrap();
515
516 let plaintext = "data";
517 let mut encrypted = cipher.encrypt(plaintext).unwrap();
518
519 if encrypted.len() > NONCE_SIZE {
521 encrypted[NONCE_SIZE] ^= 0xFF;
522 }
523
524 let result = cipher.decrypt(&encrypted);
525 assert!(
526 matches!(result, Err(SecretsError::EncryptionError(_))),
527 "expected EncryptionError for corrupted data, got: {result:?}"
528 );
529 }
530
531 #[test]
533 fn test_field_decrypt_short_data_fails() {
534 let key = [0u8; KEY_SIZE];
535 let cipher = FieldEncryption::new(&key).unwrap();
536
537 let short_data = vec![0u8; 5]; let result = cipher.decrypt(&short_data);
539 assert!(
540 matches!(result, Err(SecretsError::EncryptionError(_))),
541 "expected EncryptionError for short data, got: {result:?}"
542 );
543 }
544
545 #[test]
551 fn test_versioned_encrypt_not_deterministic() {
552 let key = [1u8; KEY_SIZE];
553 let ve = VersionedFieldEncryption::new(1, &key).unwrap();
554
555 let ct1 = ve.encrypt("secret").unwrap();
556 let ct2 = ve.encrypt("secret").unwrap();
557 assert_ne!(ct1, ct2, "Versioned encryption must produce non-deterministic output");
558 }
559
560 #[test]
562 fn test_versioned_encrypt_decrypt_roundtrip() {
563 let key = [2u8; KEY_SIZE];
564 let ve = VersionedFieldEncryption::new(1, &key).unwrap();
565
566 let plaintext = "sensitive@example.com";
567 let ct = ve.encrypt(plaintext).unwrap();
568 let decrypted = ve.decrypt(&ct).unwrap();
569 assert_eq!(decrypted, plaintext, "Versioned roundtrip must restore original plaintext");
570 }
571
572 #[test]
574 fn test_versioned_different_versions_different_prefixes() {
575 let key_v1 = [1u8; KEY_SIZE];
576 let key_v2 = [2u8; KEY_SIZE];
577 let ve1 = VersionedFieldEncryption::new(1, &key_v1).unwrap();
578 let ve2 = VersionedFieldEncryption::new(2, &key_v2).unwrap();
579
580 let ct1 = ve1.encrypt("data").unwrap();
581 let ct2 = ve2.encrypt("data").unwrap();
582
583 let ver1 = VersionedFieldEncryption::extract_version(&ct1).unwrap();
584 let ver2 = VersionedFieldEncryption::extract_version(&ct2).unwrap();
585
586 assert_ne!(ver1, ver2, "Different key versions must produce different version prefixes");
587 assert_eq!(ver1, 1u16);
588 assert_eq!(ver2, 2u16);
589 }
590
591 #[test]
593 fn test_versioned_fallback_key_decrypts_old_data() {
594 let key_v1 = [1u8; KEY_SIZE];
595 let key_v2 = [2u8; KEY_SIZE];
596
597 let ve_old = VersionedFieldEncryption::new(1, &key_v1).unwrap();
599 let old_ct = ve_old.encrypt("legacy data").unwrap();
600
601 let ve_new = VersionedFieldEncryption::new(2, &key_v2)
603 .unwrap()
604 .with_fallback(1, &key_v1)
605 .unwrap();
606
607 let decrypted = ve_new.decrypt(&old_ct).unwrap();
609 assert_eq!(decrypted, "legacy data", "Fallback key must decrypt old ciphertexts");
610 }
611
612 #[test]
614 fn test_versioned_empty_key_returns_error() {
615 let result = VersionedFieldEncryption::new(1, &[]);
616 assert!(result.is_err(), "Empty key must return an error");
617 }
618
619 #[test]
621 fn test_versioned_short_key_returns_error() {
622 let short_key = [0u8; 16];
623 let result = VersionedFieldEncryption::new(1, &short_key);
624 assert!(result.is_err(), "Short key must return an error");
625 }
626
627 #[test]
629 fn test_versioned_encrypt_is_not_identity() {
630 let key = [5u8; KEY_SIZE];
631 let ve = VersionedFieldEncryption::new(1, &key).unwrap();
632 let ct = ve.encrypt("hello").unwrap();
633
634 assert_ne!(ct, b"hello", "Encrypted output must differ from plaintext");
636 }
637
638 #[test]
640 fn test_versioned_reencrypt_from_fallback() {
641 let key_v1 = [10u8; KEY_SIZE];
642 let key_v2 = [20u8; KEY_SIZE];
643
644 let ve_old = VersionedFieldEncryption::new(1, &key_v1).unwrap();
645 let old_ct = ve_old.encrypt("migrate me").unwrap();
646
647 let ve_new = VersionedFieldEncryption::new(2, &key_v2)
648 .unwrap()
649 .with_fallback(1, &key_v1)
650 .unwrap();
651
652 let new_ct = ve_new.reencrypt_from_fallback(&old_ct).unwrap();
653
654 let ver = VersionedFieldEncryption::extract_version(&new_ct).unwrap();
656 assert_eq!(ver, 2u16, "Re-encrypted blob must use the primary key version");
657
658 let decrypted = ve_new.decrypt(&new_ct).unwrap();
660 assert_eq!(decrypted, "migrate me", "Plaintext must be preserved after re-encryption");
661 }
662}