Skip to main content

lsm_tree/
encryption.rs

1// Copyright (c) 2025-present, fjall-rs
2// This source code is licensed under both the Apache 2.0 and MIT License
3// (found in the LICENSE-* files in the repository)
4
5//! Block-level encryption at rest.
6//!
7//! This module defines the [`EncryptionProvider`] trait for pluggable
8//! block-level encryption and, behind the `encryption` feature, ships a
9//! ready-to-use [`Aes256GcmProvider`] implementation.
10//!
11//! ## Pipeline order
12//!
13//! - **Write:** raw data → compress → **encrypt** → checksum → disk
14//! - **Read:** disk → verify checksum → **decrypt** → decompress → raw data
15//!
16//! Checksums protect the encrypted (on-disk) bytes so that corruption is
17//! detected cheaply before any decryption attempt.
18
19/// Block encryption provider.
20///
21/// Implementors handle key management, nonce generation, and algorithm
22/// selection. The trait is object-safe so it can be stored as
23/// `Arc<dyn EncryptionProvider>`.
24///
25/// # Contract
26///
27/// - [`encrypt`](EncryptionProvider::encrypt) must be deterministic in output
28///   *format* (but not value — nonces should be random or unique).
29/// - [`decrypt`](EncryptionProvider::decrypt) must accept the exact byte
30///   sequence returned by `encrypt` and recover the original plaintext.
31/// - Both methods must be safe to call concurrently from multiple threads.
32pub trait EncryptionProvider:
33    Send + Sync + std::panic::UnwindSafe + std::panic::RefUnwindSafe
34{
35    /// Encrypt `plaintext`, returning an opaque ciphertext blob.
36    ///
37    /// The returned bytes may include a nonce/IV prefix and an
38    /// authentication tag — the layout is provider-defined.
39    ///
40    /// # Errors
41    ///
42    /// Returns [`crate::Error::Encrypt`] if the encryption operation fails.
43    fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>>;
44
45    /// Maximum number of bytes that encryption adds to a plaintext payload.
46    ///
47    /// Used by block I/O to account for encryption overhead in size
48    /// validation. For AES-256-GCM this is 28 (12-byte nonce + 16-byte tag).
49    ///
50    /// Returns `u32` because block sizes are `u32`-bounded on disk.
51    fn max_overhead(&self) -> u32;
52
53    /// Decrypt `ciphertext` previously produced by [`encrypt`](EncryptionProvider::encrypt).
54    ///
55    /// # Errors
56    ///
57    /// Returns [`crate::Error::Decrypt`] if the ciphertext is invalid,
58    /// tampered, or encrypted with a different key.
59    fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>>;
60
61    /// Encrypt an owned plaintext buffer, reusing its allocation when possible.
62    ///
63    /// The default implementation delegates to [`encrypt`](EncryptionProvider::encrypt).
64    /// Providers may override this to avoid an extra allocation by prepending
65    /// the nonce and appending the tag in-place.
66    ///
67    /// # Errors
68    ///
69    /// Returns [`crate::Error::Encrypt`] if the encryption operation fails.
70    fn encrypt_vec(&self, plaintext: Vec<u8>) -> crate::Result<Vec<u8>> {
71        self.encrypt(&plaintext)
72    }
73
74    /// Decrypt an owned ciphertext buffer, reusing its allocation when possible.
75    ///
76    /// The default implementation delegates to [`decrypt`](EncryptionProvider::decrypt).
77    /// Providers may override this to decrypt in-place, stripping the nonce
78    /// prefix and tag suffix without a second allocation.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`crate::Error::Decrypt`] if the ciphertext is invalid,
83    /// tampered, or encrypted with a different key.
84    fn decrypt_vec(&self, ciphertext: Vec<u8>) -> crate::Result<Vec<u8>> {
85        self.decrypt(&ciphertext)
86    }
87}
88
89// ---------------------------------------------------------------------------
90// AES-256-GCM implementation (feature-gated)
91// ---------------------------------------------------------------------------
92
93/// AES-256-GCM encryption provider.
94///
95/// Each [`encrypt`](EncryptionProvider::encrypt) call generates a random
96/// 12-byte nonce and prepends it to the ciphertext:
97///
98/// ```text
99/// [nonce; 12 bytes][ciphertext + GCM tag; N + 16 bytes]
100/// ```
101///
102/// Overhead per block: **28 bytes** (12 nonce + 16 auth tag).
103///
104/// # Key management
105///
106/// The caller is responsible for providing and rotating the 256-bit key.
107/// This provider does not persist or derive keys.
108#[cfg(feature = "encryption")]
109pub struct Aes256GcmProvider {
110    cipher: aes_gcm::Aes256Gcm,
111}
112
113#[cfg(feature = "encryption")]
114impl Aes256GcmProvider {
115    /// Nonce size for AES-256-GCM (96 bits).
116    const NONCE_LEN: usize = 12;
117
118    /// GCM authentication tag size (128 bits).
119    const TAG_LEN: usize = 16;
120
121    /// Total per-block overhead: nonce + tag.
122    pub const OVERHEAD: usize = Self::NONCE_LEN + Self::TAG_LEN;
123
124    /// Create a new provider from a 256-bit (32-byte) key.
125    ///
126    /// The key length is enforced at compile time by the `[u8; 32]` type.
127    /// For runtime-checked construction from a slice, use [`from_slice`](Self::from_slice).
128    #[must_use]
129    pub fn new(key: &[u8; 32]) -> Self {
130        use aes_gcm::KeyInit;
131
132        Self {
133            cipher: aes_gcm::Aes256Gcm::new(key.into()),
134        }
135    }
136
137    /// Create a provider from a key slice, returning an error if the
138    /// length is not 32 bytes.
139    ///
140    /// # Errors
141    ///
142    /// Returns [`crate::Error::Encrypt`] if `key` is not exactly 32 bytes.
143    pub fn from_slice(key: &[u8]) -> crate::Result<Self> {
144        let key: &[u8; 32] = key
145            .try_into()
146            .map_err(|_| crate::Error::Encrypt("AES-256-GCM key must be exactly 32 bytes"))?;
147        Ok(Self::new(key))
148    }
149}
150
151/// Create a new [`ChaCha20Rng`](rand_chacha::ChaCha20Rng) seeded from the OS RNG
152/// (via the getrandom-backed `SysRng` exposed by aes-gcm's `Generate` trait).
153///
154/// Returns the RNG directly (not `Result`) because callers are
155/// `thread_local!` init and fork-reseed, neither of which can propagate
156/// errors. This function will panic if OS entropy is unavailable.
157#[cfg(feature = "encryption")]
158fn new_chacha_rng() -> rand_chacha::ChaCha20Rng {
159    use aes_gcm::aead::Generate;
160    use aes_gcm::aead::rand_core::SeedableRng;
161
162    // `<[u8; 32]>::generate()` pulls 32 bytes from the getrandom-backed
163    // `SysRng` and panics on OS entropy failure (same semantics as the
164    // previous `ChaCha20Rng::from_rng(OsRng).expect(...)`).
165    let seed: [u8; 32] = <[u8; 32]>::generate();
166    rand_chacha::ChaCha20Rng::from_seed(seed)
167}
168
169/// Thread-local CSPRNG wrapper with fork-aware PID tracking.
170///
171/// On each access, compares the stored PID with `std::process::id()`.
172/// If they differ (i.e. the process was forked), the RNG is reseeded
173/// from the OS RNG to avoid nonce reuse across processes.
174#[cfg(feature = "encryption")]
175struct ForkAwareRng {
176    pid: std::cell::Cell<u32>,
177    rng: std::cell::RefCell<rand_chacha::ChaCha20Rng>,
178}
179
180#[cfg(feature = "encryption")]
181impl ForkAwareRng {
182    fn new() -> Self {
183        Self {
184            pid: std::cell::Cell::new(std::process::id()),
185            rng: std::cell::RefCell::new(new_chacha_rng()),
186        }
187    }
188
189    fn with_rng<R>(&self, f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
190        let mut rng_ref = self.rng.borrow_mut();
191        let current_pid = std::process::id();
192        if self.pid.get() != current_pid {
193            // Process was forked; reseed RNG to avoid nonce reuse across PIDs.
194            self.pid.set(current_pid);
195            *rng_ref = new_chacha_rng();
196        }
197
198        // The RefMut guard is held while f() runs. This is safe because
199        // f() only generates a 12-byte nonce (no reentrant RNG access).
200        // Deref-coercion: &mut RefMut<ChaCha20Rng> → &mut ChaCha20Rng
201        // (explicit &mut *rng_ref is denied by clippy::explicit_auto_deref).
202        f(&mut rng_ref)
203    }
204}
205
206#[cfg(feature = "encryption")]
207thread_local! {
208    // Module-scope so all monomorphizations of `thread_local_rng`
209    // share a single thread-local instance.
210    static THREAD_RNG: ForkAwareRng = ForkAwareRng::new();
211}
212
213/// Access a thread-local CSPRNG seeded from the OS RNG in a fork-aware way.
214///
215/// Using a thread-local [`ChaCha20Rng`](rand_chacha::ChaCha20Rng) avoids a
216/// `getrandom` syscall on every nonce generation, which saves 1-10 µs per
217/// block under contention. The RNG is cryptographically secure and seeded
218/// from the OS RNG on first access per thread, and is lazily reseeded on the
219/// next use if the process ID changes (e.g., after a `fork()`) to reduce
220/// the risk of nonce reuse across processes.
221#[cfg(feature = "encryption")]
222fn thread_local_rng<R>(f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
223    THREAD_RNG.with(|state| state.with_rng(f))
224}
225
226#[cfg(feature = "encryption")]
227impl EncryptionProvider for Aes256GcmProvider {
228    fn max_overhead(&self) -> u32 {
229        // OVERHEAD = NONCE_LEN + TAG_LEN = 28, always fits u32.
230        #[expect(clippy::cast_possible_truncation, reason = "OVERHEAD is 28")]
231        {
232            Self::OVERHEAD as u32
233        }
234    }
235
236    fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
237        // aes-gcm 0.11.0-rc.3 prerelease surface (pinned in Cargo.toml).
238        // Migration trigger: bump when aes-gcm 0.11.0 stable ships.
239        use aes_gcm::aead::{AeadInOut, Generate, Nonce};
240
241        let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
242
243        let mut buf = Vec::with_capacity(Self::NONCE_LEN + plaintext.len() + Self::TAG_LEN);
244        buf.extend_from_slice(&nonce);
245        buf.extend_from_slice(plaintext);
246
247        // encrypt_inout_detached operates on buf[NONCE_LEN..] (the plaintext portion).
248        // Indexing is safe: buf was allocated as nonce + plaintext.
249        //
250        // AAD wiring (block context: table_id, offset, block_type, dict_id, window_log)
251        // tracked separately — see lsm-tree #250/#251/#252.
252        #[expect(
253            clippy::indexing_slicing,
254            reason = "buf length = NONCE_LEN + plaintext.len()"
255        )]
256        let tag = self
257            .cipher
258            .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
259            .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
260
261        buf.extend_from_slice(&tag);
262
263        Ok(buf)
264    }
265
266    fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>> {
267        use aes_gcm::aead::{AeadInOut, Nonce, Tag};
268
269        let min_len = Self::NONCE_LEN + Self::TAG_LEN;
270        if ciphertext.len() < min_len {
271            return Err(crate::Error::Decrypt(
272                "ciphertext too short for AES-256-GCM (need nonce + tag)",
273            ));
274        }
275
276        #[expect(clippy::indexing_slicing, reason = "length checked above")]
277        let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[..Self::NONCE_LEN])
278            .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
279
280        // Safe: ciphertext.len() >= NONCE_LEN + TAG_LEN checked above
281        let tag_start = ciphertext.len() - Self::TAG_LEN;
282
283        #[expect(clippy::indexing_slicing, reason = "length checked above")]
284        let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[tag_start..])
285            .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
286
287        #[expect(clippy::indexing_slicing, reason = "length checked above")]
288        let mut buf = ciphertext[Self::NONCE_LEN..tag_start].to_vec();
289
290        self.cipher
291            .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
292            .map_err(|_| {
293                crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
294            })?;
295
296        Ok(buf)
297    }
298
299    fn encrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
300        use aes_gcm::aead::{AeadInOut, Generate, Nonce};
301
302        let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
303
304        // Reserve space for nonce prefix + tag suffix in one allocation,
305        // then shift plaintext right and write the nonce into the gap.
306        let plaintext_len = buf.len();
307        buf.reserve(Self::NONCE_LEN + Self::TAG_LEN);
308        buf.resize(plaintext_len + Self::NONCE_LEN, 0);
309        buf.copy_within(..plaintext_len, Self::NONCE_LEN);
310        #[expect(
311            clippy::indexing_slicing,
312            reason = "buf was just resized to include NONCE_LEN"
313        )]
314        buf[..Self::NONCE_LEN].copy_from_slice(&nonce);
315
316        #[expect(
317            clippy::indexing_slicing,
318            reason = "buf length ≥ NONCE_LEN after resize + copy_within"
319        )]
320        let tag = self
321            .cipher
322            .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
323            .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
324
325        buf.extend_from_slice(&tag);
326
327        Ok(buf)
328    }
329
330    fn decrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
331        use aes_gcm::aead::{AeadInOut, Nonce, Tag};
332
333        // Error::Decrypt takes &'static str — can't include runtime lengths
334        // without changing the upstream error type to accept String/Cow.
335        let min_len = Self::NONCE_LEN + Self::TAG_LEN;
336        if buf.len() < min_len {
337            return Err(crate::Error::Decrypt(
338                "ciphertext too short for AES-256-GCM (need nonce + tag)",
339            ));
340        }
341
342        // Copy nonce and tag to the stack before mutating the buffer.
343        #[expect(clippy::indexing_slicing, reason = "length checked above")]
344        let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&buf[..Self::NONCE_LEN])
345            .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
346
347        let tag_start = buf.len() - Self::TAG_LEN;
348        #[expect(clippy::indexing_slicing, reason = "length checked above")]
349        let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&buf[tag_start..])
350            .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
351
352        // Strip nonce prefix and tag suffix via copy_within + truncate
353        // (single memmove, avoids Drain iterator adapter overhead).
354        buf.copy_within(Self::NONCE_LEN..tag_start, 0);
355        buf.truncate(tag_start - Self::NONCE_LEN);
356
357        self.cipher
358            .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
359            .map_err(|_| {
360                crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
361            })?;
362
363        Ok(buf)
364    }
365}
366
367#[cfg(test)]
368#[allow(
369    clippy::doc_markdown,
370    clippy::redundant_clone,
371    clippy::unnecessary_wraps,
372    clippy::redundant_closure_for_method_calls
373)]
374mod tests {
375    use super::*;
376
377    #[test]
378    fn encryption_provider_trait_is_object_safe() {
379        // Compile-time check: the trait can be used as a trait object.
380        fn _assert_object_safe(_: &dyn EncryptionProvider) {}
381    }
382
383    /// Minimal provider that only implements required methods,
384    /// exercising the default `encrypt_vec/decrypt_vec` implementations.
385    struct XorProvider;
386
387    impl std::panic::UnwindSafe for XorProvider {}
388    impl std::panic::RefUnwindSafe for XorProvider {}
389
390    impl EncryptionProvider for XorProvider {
391        fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
392            Ok(plaintext.iter().map(|b| b ^ 0xAA).collect())
393        }
394
395        fn max_overhead(&self) -> u32 {
396            0
397        }
398
399        fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>> {
400            Ok(ciphertext.iter().map(|b| b ^ 0xAA).collect())
401        }
402    }
403
404    #[test]
405    fn default_encrypt_vec_delegates_to_encrypt() -> crate::Result<()> {
406        let provider = XorProvider;
407        let plaintext = b"test default encrypt_vec";
408
409        let via_encrypt = provider.encrypt(plaintext)?;
410        let via_encrypt_vec = provider.encrypt_vec(plaintext.to_vec())?;
411        assert_eq!(via_encrypt, via_encrypt_vec);
412
413        let decrypted = provider.decrypt(&via_encrypt_vec)?;
414        assert_eq!(decrypted, plaintext);
415        Ok(())
416    }
417
418    #[test]
419    fn default_decrypt_vec_delegates_to_decrypt() -> crate::Result<()> {
420        let provider = XorProvider;
421        let plaintext = b"test default decrypt_vec";
422
423        let ciphertext = provider.encrypt(plaintext)?;
424
425        let via_decrypt = provider.decrypt(&ciphertext)?;
426        let via_decrypt_vec = provider.decrypt_vec(ciphertext)?;
427        assert_eq!(via_decrypt, via_decrypt_vec);
428        assert_eq!(via_decrypt_vec, plaintext);
429        Ok(())
430    }
431
432    #[cfg(feature = "encryption")]
433    mod aes256gcm {
434        use super::*;
435
436        fn test_key() -> [u8; 32] {
437            [0x42; 32]
438        }
439
440        #[test]
441        fn roundtrip_basic() -> crate::Result<()> {
442            let provider = Aes256GcmProvider::new(&test_key());
443            let plaintext = b"hello world, this is a block of data!";
444
445            let ciphertext = provider.encrypt(plaintext)?;
446            assert_ne!(&ciphertext[..], plaintext.as_slice());
447            assert_eq!(
448                ciphertext.len(),
449                Aes256GcmProvider::NONCE_LEN + plaintext.len() + Aes256GcmProvider::TAG_LEN,
450            );
451
452            let decrypted = provider.decrypt(&ciphertext)?;
453            assert_eq!(decrypted, plaintext);
454            Ok(())
455        }
456
457        #[test]
458        fn roundtrip_empty() -> crate::Result<()> {
459            let provider = Aes256GcmProvider::new(&test_key());
460            let plaintext = b"";
461
462            let ciphertext = provider.encrypt(plaintext)?;
463            let decrypted = provider.decrypt(&ciphertext)?;
464            assert_eq!(decrypted, plaintext);
465            Ok(())
466        }
467
468        #[test]
469        fn different_nonces_produce_different_ciphertexts() -> crate::Result<()> {
470            let provider = Aes256GcmProvider::new(&test_key());
471            let plaintext = b"deterministic input";
472
473            let ct1 = provider.encrypt(plaintext)?;
474            let ct2 = provider.encrypt(plaintext)?;
475            assert_ne!(
476                ct1, ct2,
477                "random nonces should produce different ciphertexts"
478            );
479
480            // Both decrypt to the same plaintext
481            assert_eq!(provider.decrypt(&ct1)?, provider.decrypt(&ct2)?,);
482            Ok(())
483        }
484
485        #[test]
486        fn wrong_key_fails_decrypt() -> crate::Result<()> {
487            let provider1 = Aes256GcmProvider::new(&[0x01; 32]);
488            let provider2 = Aes256GcmProvider::new(&[0x02; 32]);
489
490            let ciphertext = provider1.encrypt(b"secret")?;
491            let result = provider2.decrypt(&ciphertext);
492            assert!(result.is_err());
493            Ok(())
494        }
495
496        #[test]
497        fn tampered_ciphertext_fails_decrypt() -> crate::Result<()> {
498            let provider = Aes256GcmProvider::new(&test_key());
499            let mut ciphertext = provider.encrypt(b"data")?;
500
501            // Flip a byte in the ciphertext body
502            let mid = Aes256GcmProvider::NONCE_LEN + 1;
503            if mid < ciphertext.len() {
504                #[expect(clippy::indexing_slicing)]
505                {
506                    ciphertext[mid] ^= 0xFF;
507                }
508            }
509
510            let result = provider.decrypt(&ciphertext);
511            assert!(result.is_err());
512            Ok(())
513        }
514
515        #[test]
516        fn truncated_ciphertext_fails_decrypt() -> crate::Result<()> {
517            let provider = Aes256GcmProvider::new(&test_key());
518            let result = provider.decrypt(&[0u8; 10]); // less than nonce + tag
519            assert!(result.is_err());
520            Ok(())
521        }
522
523        #[test]
524        fn from_slice_rejects_wrong_length() {
525            assert!(Aes256GcmProvider::from_slice(&[0u8; 16]).is_err());
526            assert!(Aes256GcmProvider::from_slice(&[0u8; 31]).is_err());
527            assert!(Aes256GcmProvider::from_slice(&[0u8; 33]).is_err());
528            assert!(Aes256GcmProvider::from_slice(&[0u8; 32]).is_ok());
529        }
530
531        #[test]
532        fn roundtrip_large_payload() -> crate::Result<()> {
533            let provider = Aes256GcmProvider::new(&test_key());
534            let plaintext = vec![0xAB_u8; 64 * 1024]; // 64 KiB
535
536            let ciphertext = provider.encrypt(&plaintext)?;
537            let decrypted = provider.decrypt(&ciphertext)?;
538            assert_eq!(decrypted, plaintext);
539            Ok(())
540        }
541
542        /// Verify the thread-local CSPRNG produces unique nonces across many
543        /// encrypt calls — no nonce reuse even under rapid sequential use.
544        #[test]
545        fn thread_local_rng_produces_unique_nonces() -> crate::Result<()> {
546            let provider = Aes256GcmProvider::new(&test_key());
547            let plaintext = b"nonce uniqueness test";
548
549            let mut nonces = std::collections::HashSet::new();
550            for _ in 0..1000 {
551                let ct = provider.encrypt(plaintext)?;
552
553                #[expect(clippy::indexing_slicing, reason = "ct always >= NONCE_LEN")]
554                #[expect(clippy::expect_used, reason = "test assertion")]
555                let nonce: [u8; Aes256GcmProvider::NONCE_LEN] = ct[..Aes256GcmProvider::NONCE_LEN]
556                    .try_into()
557                    .expect("nonce has expected length");
558
559                assert!(
560                    nonces.insert(nonce),
561                    "nonce collision detected — CSPRNG produced duplicate nonce"
562                );
563            }
564            Ok(())
565        }
566
567        /// Verify `ForkAwareRng` actually REPLACES the inner RNG (not just
568        /// restores PID bookkeeping) when it detects a PID change.
569        ///
570        /// Stamps a deterministic large `word_pos` into the inner ChaCha20Rng,
571        /// triggers fake-PID reseed, and asserts `word_pos` was reset to a
572        /// fresh-RNG value. If the `*rng_ref = new_chacha_rng()` line were
573        /// removed from `with_rng`, the stamped offset would survive and this
574        /// test would fail.
575        /// Distinctive word_pos that cannot occur naturally on a freshly-seeded
576        /// RNG after a single u64 draw (which advances word_pos by 2).
577        const SENTINEL_WORD_POS: u128 = 0xDEAD_BEEF_u128;
578
579        #[test]
580        fn fork_aware_rng_reseeds_on_pid_change() {
581            let rng = ForkAwareRng::new();
582
583            // Initialize the lazy RNG.
584            let _ = rng.with_rng(aes_gcm::aead::rand_core::Rng::next_u64);
585            rng.rng.borrow_mut().set_word_pos(SENTINEL_WORD_POS);
586            assert_eq!(rng.rng.borrow().get_word_pos(), SENTINEL_WORD_POS);
587
588            // Simulate fork: stamp a fake PID different from the real one.
589            let real_pid = std::process::id();
590            rng.pid.set(real_pid ^ 1);
591
592            // Next access detects PID mismatch → replaces the inner RNG with
593            // a fresh seed from the OS RNG. After one u64 draw on the fresh
594            // RNG, word_pos must be
595            // a small fresh-RNG value, NOT SENTINEL_WORD_POS + 2.
596            let _ = rng.with_rng(aes_gcm::aead::rand_core::Rng::next_u64);
597
598            assert_eq!(
599                rng.pid.get(),
600                real_pid,
601                "PID should be restored to real process ID after reseed"
602            );
603
604            let post_word_pos = rng.rng.borrow().get_word_pos();
605            assert!(
606                post_word_pos < SENTINEL_WORD_POS,
607                "inner RNG was not replaced on reseed: post word_pos {post_word_pos:#x} \
608                 should be a fresh-RNG value, not {SENTINEL_WORD_POS:#x}+ \
609                 (would indicate fork-safety reseed is broken)"
610            );
611        }
612
613        #[test]
614        fn encrypt_vec_roundtrip() -> crate::Result<()> {
615            let provider = Aes256GcmProvider::new(&test_key());
616            let plaintext = b"block data for encrypt_vec test";
617
618            let ciphertext = provider.encrypt_vec(plaintext.to_vec())?;
619            assert_eq!(
620                ciphertext.len(),
621                Aes256GcmProvider::NONCE_LEN + plaintext.len() + Aes256GcmProvider::TAG_LEN,
622            );
623
624            // encrypt_vec output must be decryptable by decrypt
625            let decrypted = provider.decrypt(&ciphertext)?;
626            assert_eq!(decrypted, plaintext);
627            Ok(())
628        }
629
630        #[test]
631        fn decrypt_vec_roundtrip() -> crate::Result<()> {
632            let provider = Aes256GcmProvider::new(&test_key());
633            let plaintext = b"block data for decrypt_vec test";
634
635            // encrypt output must be decryptable by decrypt_vec
636            let ciphertext = provider.encrypt(plaintext)?;
637            let decrypted = provider.decrypt_vec(ciphertext)?;
638            assert_eq!(decrypted, plaintext);
639            Ok(())
640        }
641
642        #[test]
643        fn encrypt_vec_decrypt_vec_roundtrip() -> crate::Result<()> {
644            let provider = Aes256GcmProvider::new(&test_key());
645            let plaintext = vec![0xCD_u8; 16 * 1024]; // 16 KiB
646
647            let ciphertext = provider.encrypt_vec(plaintext.clone())?;
648            let decrypted = provider.decrypt_vec(ciphertext)?;
649            assert_eq!(decrypted, plaintext);
650            Ok(())
651        }
652
653        #[test]
654        fn encrypt_vec_empty() -> crate::Result<()> {
655            let provider = Aes256GcmProvider::new(&test_key());
656
657            let ciphertext = provider.encrypt_vec(vec![])?;
658            let decrypted = provider.decrypt_vec(ciphertext)?;
659            assert!(decrypted.is_empty());
660            Ok(())
661        }
662
663        #[test]
664        fn decrypt_vec_truncated_fails() -> crate::Result<()> {
665            let provider = Aes256GcmProvider::new(&test_key());
666            let result = provider.decrypt_vec(vec![0u8; 10]);
667            assert!(result.is_err());
668            Ok(())
669        }
670
671        #[test]
672        fn decrypt_vec_tampered_fails() -> crate::Result<()> {
673            let provider = Aes256GcmProvider::new(&test_key());
674            let mut ciphertext = provider.encrypt_vec(b"data".to_vec())?;
675
676            let mid = Aes256GcmProvider::NONCE_LEN + 1;
677            if mid < ciphertext.len() {
678                #[expect(clippy::indexing_slicing)]
679                {
680                    ciphertext[mid] ^= 0xFF;
681                }
682            }
683
684            let result = provider.decrypt_vec(ciphertext);
685            assert!(result.is_err());
686            Ok(())
687        }
688    }
689}