Skip to main content

fraiseql_secrets/encryption/
mod.rs

1//! Encryption for sensitive database fields using AES-256-GCM
2//!
3//! Provides transparent encryption/decryption for:
4//! - User emails
5//! - Phone numbers
6//! - SSN/tax IDs
7//! - Credit card data
8//! - API keys
9//! - OAuth tokens
10
11use 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; // 96 bits for GCM
37const KEY_SIZE: usize = 32; // 256 bits for AES-256
38
39/// Cipher for field-level encryption using AES-256-GCM
40///
41/// Encrypts sensitive database fields with authenticated encryption.
42/// Each encryption uses a random nonce, preventing identical plaintexts
43/// from producing identical ciphertexts.
44///
45/// `FieldEncryption` does not implement `Clone`. Shared access should use
46/// `Arc<FieldEncryption>` so the key schedule is held in exactly one heap
47/// allocation and zeroed on drop (requires `aes-gcm` `zeroize` feature,
48/// which is enabled in this crate's `Cargo.toml`).
49///
50/// # Example
51/// ```rust
52/// use fraiseql_secrets::FieldEncryption;
53/// // Key must be exactly 32 bytes for AES-256-GCM.
54/// let key = b"12345678901234567890123456789012"; // 32 bytes
55/// let cipher = FieldEncryption::new(key).unwrap();
56/// let encrypted = cipher.encrypt("user@example.com").unwrap();
57/// let decrypted = cipher.decrypt(&encrypted).unwrap();
58/// assert_eq!(decrypted, "user@example.com");
59/// ```
60pub struct FieldEncryption {
61    cipher: Aes256Gcm,
62}
63
64impl std::fmt::Debug for FieldEncryption {
65    #[cfg_attr(test, mutants::skip)]
66    // Reason: security-diagnostic Debug impl — outputs "Aes256Gcm(redacted)" to avoid
67    // leaking key material; no test asserts on this string so mutations cannot be killed.
68    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    /// Create new field encryption cipher.
77    ///
78    /// # Arguments
79    /// * `key` - Encryption key bytes (must be exactly 32 bytes for AES-256)
80    ///
81    /// # Errors
82    /// Returns `SecretsError::ValidationError` if `key` is not exactly 32 bytes.
83    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    /// Generate random nonce for encryption
99    ///
100    /// Uses cryptographically secure random number generation to ensure
101    /// each encryption produces a unique nonce, preventing pattern analysis.
102    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    /// Validate and extract nonce from encrypted data
109    ///
110    /// # Arguments
111    /// * `encrypted` - Encrypted data with nonce prefix
112    ///
113    /// # Returns
114    /// Tuple of (nonce, ciphertext) or error if too short
115    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    /// Convert bytes to UTF-8 string with context
134    ///
135    /// Provides clear error messages on encoding failures for debugging
136    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    /// Encrypt plaintext field using AES-256-GCM
143    ///
144    /// Generates random 96-bit nonce, encrypts with authenticated encryption,
145    /// and returns [nonce || ciphertext] format for decryption.
146    ///
147    /// # Arguments
148    /// * `plaintext` - Data to encrypt
149    ///
150    /// # Returns
151    /// Encrypted data in format: [12-byte nonce][ciphertext + 16-byte tag]
152    ///
153    /// # Errors
154    /// Returns `EncryptionError` if encryption fails
155    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        // Return [nonce || ciphertext]
165        let mut result = nonce_bytes.to_vec();
166        result.extend_from_slice(&ciphertext);
167        Ok(result)
168    }
169
170    /// Decrypt encrypted field using AES-256-GCM
171    ///
172    /// Expects data in format: [12-byte nonce][ciphertext + 16-byte tag]
173    /// Extracts nonce, decrypts, and verifies authentication tag.
174    ///
175    /// # Arguments
176    /// * `encrypted` - Encrypted data from `encrypt()`
177    ///
178    /// # Returns
179    /// Decrypted plaintext as String
180    ///
181    /// # Errors
182    /// Returns `EncryptionError` if:
183    /// - Data too short for nonce
184    /// - Decryption fails (wrong key or corrupted data)
185    /// - Plaintext is not valid UTF-8
186    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    /// Encrypt field with additional context for audit/security
203    ///
204    /// Includes context (e.g., `user_id`, `field_name`) in authenticated data
205    /// but not in ciphertext, providing audit trail without bloating storage.
206    ///
207    /// # Arguments
208    /// * `plaintext` - Data to encrypt
209    /// * `context` - Additional authenticated data (e.g., "user:123:email")
210    ///
211    /// # Errors
212    ///
213    /// Returns `SecretsError::EncryptionError` if AES-GCM encryption fails.
214    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    /// Decrypt field with additional context verification
237    ///
238    /// Context must match the value used during encryption for verification to succeed.
239    ///
240    /// # Arguments
241    /// * `encrypted` - Encrypted data from `encrypt_with_context()`
242    /// * `context` - Context that was used during encryption
243    ///
244    /// # Returns
245    /// Decrypted plaintext as String
246    ///
247    /// # Errors
248    /// Returns `EncryptionError` if context doesn't match or decryption fails
249    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
276/// Versioned ciphertext layout: `[version: 2 bytes LE][nonce: 12 bytes][ciphertext + 16-byte tag]`.
277const VERSION_PREFIX_SIZE: usize = 2;
278
279/// Multi-key cipher that supports decrypting data from rotated-out keys.
280///
281/// Ciphertexts are prefixed with a 2-byte key version number (little-endian)
282/// so the correct key can be selected during decryption. New data is always
283/// encrypted with the primary key; old ciphertexts encrypted with a
284/// secondary/fallback key remain readable until data is migrated.
285///
286/// # Key rotation workflow
287///
288/// 1. Promote the new key: `VersionedFieldEncryption::new(new_version, new_key_bytes)`
289/// 2. Register the old key as a fallback: `.with_fallback(old_version, old_key_bytes)`
290/// 3. All new records are encrypted with the primary (new) key.
291/// 4. Existing records encrypted with the old key are decrypted successfully via the fallback.
292/// 5. Migrate old records by reading → decrypting → re-encrypting (see `reencrypt_from_fallback`).
293/// 6. Once migration is complete, remove the fallback.
294pub struct VersionedFieldEncryption {
295    primary_version: KeyVersion,
296    primary:         FieldEncryption,
297    fallbacks:       Vec<(KeyVersion, FieldEncryption)>,
298}
299
300impl VersionedFieldEncryption {
301    /// Create with a single primary key.
302    ///
303    /// # Errors
304    ///
305    /// Returns `SecretsError::ValidationError` if `key` is not 32 bytes.
306    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    /// Register an additional key that can be used for decryption only.
315    ///
316    /// # Errors
317    ///
318    /// Returns `SecretsError::ValidationError` if `key` is not 32 bytes.
319    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    /// Encrypt plaintext, embedding the primary key version as a 2-byte LE prefix.
325    ///
326    /// # Errors
327    ///
328    /// Returns `SecretsError::EncryptionError` on failure.
329    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    /// Extract the key version from an encrypted blob.
338    ///
339    /// # Errors
340    ///
341    /// Returns error if `encrypted` is too short to contain the version prefix.
342    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    /// Decrypt an encrypted blob by selecting the key matching the embedded version.
353    ///
354    /// # Errors
355    ///
356    /// Returns `SecretsError::EncryptionError` if:
357    /// - The blob is too short to contain the version prefix.
358    /// - The version is unknown (not primary and not a registered fallback).
359    /// - Decryption fails (wrong key, corrupted data).
360    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    /// Re-encrypt a ciphertext from a fallback key to the current primary key.
382    ///
383    /// Use this during key rotation to migrate old records without exposing the
384    /// plaintext outside this function.
385    ///
386    /// # Errors
387    ///
388    /// Returns error if decryption or re-encryption fails.
389    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)] // Reason: test code, panics are acceptable
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    /// Test `FieldEncryption` creation
401    #[test]
402    fn test_field_encryption_creation() {
403        let key = [0u8; KEY_SIZE];
404        let _cipher = FieldEncryption::new(&key).unwrap();
405    }
406
407    /// Test basic encryption/decryption roundtrip
408    #[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 that same plaintext produces different ciphertexts
422    #[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        // Different random nonces produce different ciphertexts
432        assert_ne!(encrypted1, encrypted2);
433
434        // But both decrypt to same plaintext
435        assert_eq!(cipher.decrypt(&encrypted1).unwrap(), plaintext);
436        assert_eq!(cipher.decrypt(&encrypted2).unwrap(), plaintext);
437    }
438
439    /// Test encryption with context
440    #[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 context verification fails with wrong context
455    #[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        // Decryption with wrong context should fail
467        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 various data types
475    #[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            "", // Empty string
488            "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 invalid key size returns Err
500    #[test]
501    fn test_field_encryption_invalid_key_size_returns_err() {
502        let invalid_key = [0u8; 16]; // Too short
503        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 corrupted ciphertext fails to decrypt
511    #[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        // Corrupt a byte in the ciphertext (not the nonce)
520        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 short ciphertext fails gracefully
532    #[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]; // Too short for nonce
538        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    // =========================================================================
546    // Key management / VersionedFieldEncryption tests
547    // =========================================================================
548
549    /// Versioned encryption: same inputs produce different ciphertexts due to random nonce
550    #[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    /// Versioned encryption roundtrip with primary key
561    #[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    /// Different key versions produce blobs with different version prefix
573    #[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    /// Fallback key allows decrypting data encrypted with old key version
592    #[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        // Encrypt with v1
598        let ve_old = VersionedFieldEncryption::new(1, &key_v1).unwrap();
599        let old_ct = ve_old.encrypt("legacy data").unwrap();
600
601        // Now switch primary to v2, keep v1 as fallback
602        let ve_new = VersionedFieldEncryption::new(2, &key_v2)
603            .unwrap()
604            .with_fallback(1, &key_v1)
605            .unwrap();
606
607        // Can decrypt old ciphertext via fallback
608        let decrypted = ve_new.decrypt(&old_ct).unwrap();
609        assert_eq!(decrypted, "legacy data", "Fallback key must decrypt old ciphertexts");
610    }
611
612    /// Empty key material returns an error
613    #[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    /// Key length too short (16 bytes instead of 32) must fail
620    #[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    /// Derived key is not an identity function (output != input key)
628    #[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        // The ciphertext must not equal the plaintext
635        assert_ne!(ct, b"hello", "Encrypted output must differ from plaintext");
636    }
637
638    /// Reencrypt migrates ciphertext from fallback key to primary key
639    #[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        // New ciphertext uses version 2
655        let ver = VersionedFieldEncryption::extract_version(&new_ct).unwrap();
656        assert_eq!(ver, 2u16, "Re-encrypted blob must use the primary key version");
657
658        // Plaintext is preserved
659        let decrypted = ve_new.decrypt(&new_ct).unwrap();
660        assert_eq!(decrypted, "migrate me", "Plaintext must be preserved after re-encryption");
661    }
662}