Skip to main content

lsm_tree/encryption/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2025-present, fjall-rs
3// Copyright (c) 2026-present, Dmitry Prudnikov
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//! ## AAD-bound block format
20//!
21//! The AAD-bound on-disk block format from
22//! `docs/aad-block-format.md` ships in the submodules:
23//!
24//! - `aad`: pure-byte AAD construction, `aad::SuiteId` /
25//!   `aad::EncryptionContext`, plus re-exports of the existing
26//!   `crate::table::block::BlockType` / `crate::table::block::BlockIdentity`
27//!   so the AAD path and the Block I/O path share one identity type.
28//! - `error`: `DecryptError` enum with one variant per decode-time
29//!   failure mode (re-exported at the crate's encryption surface).
30//! - `aead` (feature `encryption`): per-suite AEAD dispatch
31//!   (AES-256-GCM, ChaCha20-Poly1305) that takes the 39-byte AAD
32//!   from `aad::build` and produces / verifies the
33//!   `(nonce, ciphertext, tag)` triple.
34//! - `key_chain`: the `KeyChain` trait + in-memory
35//!   `StaticKeyChain` reference impl, decoupling KeyEpoch → key
36//!   lookup from the LSM crate.
37//! - `block` (features `encryption` + `zstd_any`): top-level
38//!   `encrypt_block` / `decrypt_block` entry points wrapping
39//!   the `MetadataFrame ‖ BodyFrame` skippable-frame wire format
40//!   and returning `DecryptedBlock` (plaintext + parsed codec
41//!   context for the caller to thread through
42//!   `structured_zstd::FrameDecoder` setters).
43//!
44//! (Module and item names above are inline rather than intra-doc links
45//! because several — `aead`, `block`, `encrypt_block`, `decrypt_block`,
46//! `DecryptedBlock` — are feature-gated and absent from doc builds that
47//! omit those features, which would break the links.)
48//!
49//! Still pending in a follow-up: replacing the legacy
50//! `Aes256GcmProvider` Block I/O code path below with the
51//! `encrypt_block` / `decrypt_block` entry points at every
52//! `Block::write_into` / `Block::from_reader` site.
53
54pub mod aad;
55#[cfg(feature = "encryption")]
56pub mod aead;
57// `block` depends on `structured_zstd::skippable::SkippableFrame`,
58// which is only in the dep graph when the `zstd` cargo feature is
59// enabled (the dep is `optional`, pulled in by `feature = "zstd"`).
60// `encryption` alone doesn't bring zstd, so gate the wire-format
61// module on both features. Without `zstd`, the AAD types + AEAD
62// primitives still compile; only the SkippableFrame-wrapped
63// encrypt_block / decrypt_block entry points are absent.
64#[cfg(all(feature = "encryption", zstd_any))]
65pub mod block;
66pub mod error;
67pub mod key_chain;
68
69#[cfg(not(feature = "std"))]
70use alloc::vec::Vec;
71
72// Top-level re-exports so callers can spell `crate::encryption::
73// encrypt_block` / `decrypt_block` / `DecryptedBlock` /
74// `KeyChain` / `DecryptError` directly, instead of paging
75// through the submodule path each time. Matches the surface
76// shape #251's design discussion settled on.
77#[cfg(all(feature = "encryption", zstd_any))]
78pub use block::{
79    DecryptedBlock, EncryptedBlockMetadata, decrypt_block, encrypt_block,
80    parse_encrypted_block_metadata, reconstruct_block_aad,
81};
82pub use error::DecryptError;
83pub use key_chain::KeyChain;
84#[cfg(feature = "std")]
85pub use key_chain::StaticKeyChain;
86
87/// Block encryption provider.
88///
89/// Implementors handle key management, nonce generation, and algorithm
90/// selection. The trait is object-safe so it can be stored as
91/// `Arc<dyn EncryptionProvider>`.
92///
93/// # Contract
94///
95/// - [`encrypt`](EncryptionProvider::encrypt) must be deterministic in output
96///   *format* (but not value — nonces should be random or unique).
97/// - [`decrypt`](EncryptionProvider::decrypt) must accept the exact byte
98///   sequence returned by `encrypt` and recover the original plaintext.
99/// - Both methods must be safe to call concurrently from multiple threads.
100pub trait EncryptionProvider:
101    Send + Sync + core::panic::UnwindSafe + core::panic::RefUnwindSafe
102{
103    /// Encrypt `plaintext`, returning an opaque ciphertext blob.
104    ///
105    /// The returned bytes may include a nonce/IV prefix and an
106    /// authentication tag — the layout is provider-defined.
107    ///
108    /// # Errors
109    ///
110    /// Returns [`crate::Error::Encrypt`] if the encryption operation fails.
111    fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>>;
112
113    /// Maximum number of bytes that encryption adds to a plaintext payload.
114    ///
115    /// Used by block I/O to account for encryption overhead in size
116    /// validation. For AES-256-GCM this is 28 (12-byte nonce + 16-byte tag).
117    ///
118    /// Returns `u32` because block sizes are `u32`-bounded on disk.
119    fn max_overhead(&self) -> u32;
120
121    /// Whether this provider implements the AAD-bound block path
122    /// ([`encrypt_block_aad`](Self::encrypt_block_aad) /
123    /// [`decrypt_block_aad`](Self::decrypt_block_aad)).
124    ///
125    /// On a `zstd` build the live block I/O path routes encrypted blocks
126    /// through the AAD-bound envelope, so a provider that only implements the
127    /// opaque [`encrypt`](Self::encrypt) / [`decrypt`](Self::decrypt) surface
128    /// would fail on the first encrypted read/write. The store validates this
129    /// capability when an encryption provider is configured (see
130    /// `Config::open`) and rejects an unsupported provider up front rather than
131    /// failing mid-I/O. Defaults to `false`; AAD-capable providers (e.g.
132    /// `Aes256GcmProvider`) override it to `true`.
133    #[must_use]
134    fn supports_aad_block_path(&self) -> bool {
135        false
136    }
137
138    /// Decrypt `ciphertext` previously produced by [`encrypt`](EncryptionProvider::encrypt).
139    ///
140    /// # Errors
141    ///
142    /// Returns [`crate::Error::Decrypt`] if the ciphertext is invalid,
143    /// tampered, or encrypted with a different key.
144    fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>>;
145
146    /// Encrypt an owned plaintext buffer, reusing its allocation when possible.
147    ///
148    /// The default implementation delegates to [`encrypt`](EncryptionProvider::encrypt).
149    /// Providers may override this to avoid an extra allocation by prepending
150    /// the nonce and appending the tag in-place.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`crate::Error::Encrypt`] if the encryption operation fails.
155    fn encrypt_vec(&self, plaintext: Vec<u8>) -> crate::Result<Vec<u8>> {
156        self.encrypt(&plaintext)
157    }
158
159    /// Decrypt an owned ciphertext buffer, reusing its allocation when possible.
160    ///
161    /// The default implementation delegates to [`decrypt`](EncryptionProvider::decrypt).
162    /// Providers may override this to decrypt in-place, stripping the nonce
163    /// prefix and tag suffix without a second allocation.
164    ///
165    /// # Errors
166    ///
167    /// Returns [`crate::Error::Decrypt`] if the ciphertext is invalid,
168    /// tampered, or encrypted with a different key.
169    fn decrypt_vec(&self, ciphertext: Vec<u8>) -> crate::Result<Vec<u8>> {
170        self.decrypt(&ciphertext)
171    }
172
173    /// AAD-bound block encryption: seal `plaintext` into a `MetadataFrame ‖
174    /// BodyFrame` blob whose AEAD tag binds the block's identity and transform
175    /// stack (anti-swap / anti-relabel), per `docs/aad-block-format.md`. The
176    /// per-block AAD inputs (`identity`, `compression_type`, `block_flags`) come
177    /// from the block layer; the provider supplies the key material (key chain +
178    /// active epoch + suite). This is the live block-I/O encryption path
179    /// (`BlockTransform`), distinct from the opaque [`encrypt`](Self::encrypt)
180    /// the manifest subsystem still uses.
181    ///
182    /// # Errors
183    ///
184    /// The default returns [`crate::Error::Encrypt`]: a provider that cannot bind
185    /// AAD must not serve the block path. AAD-capable providers (e.g.
186    /// `Aes256GcmProvider`) override this.
187    fn encrypt_block_aad(
188        &self,
189        _plaintext: &[u8],
190        _identity: &crate::table::block::BlockIdentity,
191        _compression_type: u8,
192        _block_flags: u8,
193    ) -> crate::Result<Vec<u8>> {
194        Err(crate::Error::Encrypt(
195            "provider does not support AAD-bound block encryption",
196        ))
197    }
198
199    /// Inverse of [`encrypt_block_aad`](Self::encrypt_block_aad): verify the AAD
200    /// binding and recover the plaintext. The transform fields the writer bound
201    /// (`compression_type`, `block_flags`, `key_epoch`, `suite_id`) are read back
202    /// from the frame's `MetadataFrame`; the reader supplies only `identity` (the
203    /// out-of-band tree/table + dict/window context, from the per-SST
204    /// descriptor + block handle). Any mismatch fails AEAD verification.
205    ///
206    /// # Errors
207    ///
208    /// [`crate::Error::Decrypt`] on tag-verify failure / malformed frame, or for
209    /// providers that do not bind AAD (the default).
210    fn decrypt_block_aad(
211        &self,
212        _ciphertext: &[u8],
213        _identity: &crate::table::block::BlockIdentity,
214    ) -> crate::Result<Vec<u8>> {
215        Err(crate::Error::Decrypt(
216            "provider does not support AAD-bound block decryption",
217        ))
218    }
219}
220
221// ---------------------------------------------------------------------------
222// AES-256-GCM implementation (feature-gated)
223// ---------------------------------------------------------------------------
224
225/// AES-256-GCM encryption provider.
226///
227/// Each [`encrypt`](EncryptionProvider::encrypt) call generates a random
228/// 12-byte nonce and prepends it to the ciphertext:
229///
230/// ```text
231/// [nonce; 12 bytes][ciphertext + GCM tag; N + 16 bytes]
232/// ```
233///
234/// Overhead per block: **28 bytes** (12 nonce + 16 auth tag).
235///
236/// # Key management
237///
238/// The caller is responsible for providing and rotating the 256-bit key.
239/// This provider does not persist or derive keys.
240#[cfg(feature = "encryption")]
241pub struct Aes256GcmProvider {
242    cipher: aes_gcm::Aes256Gcm,
243    /// Key material for the AAD-bound block path
244    /// ([`encrypt_block_aad`](EncryptionProvider::encrypt_block_aad)). The
245    /// opaque [`encrypt`](EncryptionProvider::encrypt) (manifest subsystem) uses
246    /// `cipher` directly; the block path looks the key up by epoch here.
247    ///
248    /// This and the two fields below form the AAD-bound block state, read only by
249    /// `encrypt_block_aad` / `decrypt_block_aad`. Those entry points are
250    /// `zstd_any`-gated (they wrap the zstd `SkippableFrame`), so without zstd the
251    /// provider cannot do block AAD and these fields are unused — gate them with it.
252    #[cfg(zstd_any)]
253    key_chain: crate::encryption::key_chain::StaticKeyChain,
254    /// Active key epoch sealed into every block's `MetadataFrame`.
255    #[cfg(zstd_any)]
256    key_epoch: u8,
257    /// AEAD suite tag (AES-256-GCM) mirrored into the AAD.
258    #[cfg(zstd_any)]
259    suite_id: crate::encryption::aad::SuiteId,
260}
261
262#[cfg(feature = "encryption")]
263impl Aes256GcmProvider {
264    /// Nonce size for AES-256-GCM (96 bits).
265    const NONCE_LEN: usize = 12;
266
267    /// GCM authentication tag size (128 bits).
268    const TAG_LEN: usize = 16;
269
270    /// Total per-block overhead: nonce + tag.
271    pub const OVERHEAD: usize = Self::NONCE_LEN + Self::TAG_LEN;
272
273    /// Create a new provider from a 256-bit (32-byte) key.
274    ///
275    /// The key length is enforced at compile time by the `[u8; 32]` type.
276    /// For runtime-checked construction from a slice, use [`from_slice`](Self::from_slice).
277    #[must_use]
278    pub fn new(key: &[u8; 32]) -> Self {
279        use aes_gcm::KeyInit;
280
281        Self {
282            cipher: aes_gcm::Aes256Gcm::new(key.into()),
283            // Single-epoch shortcut: the one supplied key is epoch 0. Deployments
284            // that rotate keys construct a provider over a multi-epoch chain.
285            #[cfg(zstd_any)]
286            key_chain: crate::encryption::key_chain::StaticKeyChain::new().with_key(0, *key),
287            #[cfg(zstd_any)]
288            key_epoch: 0,
289            #[cfg(zstd_any)]
290            suite_id: crate::encryption::aad::SuiteId::Aes256Gcm,
291        }
292    }
293
294    /// Create a provider from a key slice, returning an error if the
295    /// length is not 32 bytes.
296    ///
297    /// # Errors
298    ///
299    /// Returns [`crate::Error::Encrypt`] if `key` is not exactly 32 bytes.
300    pub fn from_slice(key: &[u8]) -> crate::Result<Self> {
301        let key: &[u8; 32] = key
302            .try_into()
303            .map_err(|_| crate::Error::Encrypt("AES-256-GCM key must be exactly 32 bytes"))?;
304        Ok(Self::new(key))
305    }
306}
307
308/// Create a new [`ChaCha20Rng`](rand_chacha::ChaCha20Rng) seeded from the OS RNG
309/// (via the getrandom-backed `SysRng` exposed by aes-gcm's `Generate` trait).
310///
311/// Returns the RNG directly (not `Result`) because callers are
312/// `thread_local!` init and fork-reseed, neither of which can propagate
313/// errors. This function will panic if OS entropy is unavailable.
314#[cfg(feature = "encryption")]
315fn new_chacha_rng() -> rand_chacha::ChaCha20Rng {
316    use aes_gcm::aead::Generate;
317    use aes_gcm::aead::rand_core::SeedableRng;
318
319    // `<[u8; 32]>::generate()` pulls 32 bytes from the getrandom-backed
320    // `SysRng` and panics on OS entropy failure (same semantics as the
321    // previous `ChaCha20Rng::from_rng(OsRng).expect(...)`).
322    let seed: [u8; 32] = <[u8; 32]>::generate();
323    rand_chacha::ChaCha20Rng::from_seed(seed)
324}
325
326/// Current process id for fork detection. Under `std` this is the real PID;
327/// under `no_std` there is no process/fork concept, so it is a constant — the
328/// PID never "changes", the reseed branch never fires, and the RNG behaves as
329/// a plain thread-local CSPRNG.
330#[cfg(feature = "encryption")]
331fn process_pid() -> u32 {
332    #[cfg(feature = "std")]
333    {
334        std::process::id()
335    }
336    #[cfg(not(feature = "std"))]
337    {
338        0
339    }
340}
341
342/// Thread-local CSPRNG wrapper with fork-aware PID tracking.
343///
344/// On each access, compares the stored PID with [`process_pid`]. If they
345/// differ (i.e. the process was forked), the RNG is reseeded from the OS RNG
346/// to avoid nonce reuse across processes.
347#[cfg(feature = "encryption")]
348struct ForkAwareRng {
349    pid: core::cell::Cell<u32>,
350    rng: core::cell::RefCell<rand_chacha::ChaCha20Rng>,
351}
352
353#[cfg(feature = "encryption")]
354impl ForkAwareRng {
355    fn new() -> Self {
356        Self {
357            pid: core::cell::Cell::new(process_pid()),
358            rng: core::cell::RefCell::new(new_chacha_rng()),
359        }
360    }
361
362    fn with_rng<R>(&self, f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
363        let mut rng_ref = self.rng.borrow_mut();
364        let current_pid = process_pid();
365        if self.pid.get() != current_pid {
366            // Process was forked; reseed RNG to avoid nonce reuse across PIDs.
367            self.pid.set(current_pid);
368            *rng_ref = new_chacha_rng();
369        }
370
371        // The RefMut guard is held while f() runs. This is safe because
372        // f() only generates a 12-byte nonce (no reentrant RNG access).
373        // Deref-coercion: &mut RefMut<ChaCha20Rng> → &mut ChaCha20Rng
374        // (explicit &mut *rng_ref is denied by clippy::explicit_auto_deref).
375        f(&mut rng_ref)
376    }
377}
378
379#[cfg(feature = "encryption")]
380thread_local! {
381    // Module-scope so all monomorphizations of `thread_local_rng`
382    // share a single thread-local instance.
383    static THREAD_RNG: ForkAwareRng = ForkAwareRng::new();
384}
385
386/// Access a thread-local CSPRNG seeded from the OS RNG in a fork-aware way.
387///
388/// Using a thread-local [`ChaCha20Rng`](rand_chacha::ChaCha20Rng) avoids a
389/// `getrandom` syscall on every nonce generation, which saves 1-10 µs per
390/// block under contention. The RNG is cryptographically secure and seeded
391/// from the OS RNG on first access per thread, and is lazily reseeded on the
392/// next use if the process ID changes (e.g., after a `fork()`) to reduce
393/// the risk of nonce reuse across processes.
394#[cfg(feature = "encryption")]
395fn thread_local_rng<R>(f: impl FnOnce(&mut rand_chacha::ChaCha20Rng) -> R) -> R {
396    THREAD_RNG.with(|state| state.with_rng(f))
397}
398
399#[cfg(feature = "encryption")]
400impl EncryptionProvider for Aes256GcmProvider {
401    fn max_overhead(&self) -> u32 {
402        // The block path under zstd seals the AAD-bound `MetadataFrame ‖
403        // BodyFrame` envelope, whose framing overhead over the plaintext is
404        // 8 (meta SFA header) + 39 (meta payload, incl. 12-byte nonce) +
405        // 8 (body SFA header) + 16 (auth tag) = 71 bytes. Without zstd the
406        // opaque `[nonce ‖ ciphertext ‖ tag]` form is used: NONCE_LEN +
407        // TAG_LEN = 28. This bound sizes the read-path payload check.
408        #[cfg(zstd_any)]
409        {
410            8 + 39 + 8 + 16
411        }
412        #[cfg(not(zstd_any))]
413        #[expect(clippy::cast_possible_truncation, reason = "OVERHEAD is 28")]
414        {
415            Self::OVERHEAD as u32
416        }
417    }
418
419    // The AAD-bound block entry points are only compiled under `zstd_any` (the
420    // envelope wraps `SkippableFrame`); on a non-zstd build this provider falls
421    // back to the opaque block path, which needs no AAD capability, so the
422    // default `false` is correct there.
423    #[cfg(zstd_any)]
424    fn supports_aad_block_path(&self) -> bool {
425        true
426    }
427
428    fn encrypt(&self, plaintext: &[u8]) -> crate::Result<Vec<u8>> {
429        // aes-gcm 0.11.0-rc.3 prerelease surface (pinned in Cargo.toml).
430        // Migration trigger: bump when aes-gcm 0.11.0 stable ships.
431        use aes_gcm::aead::{AeadInOut, Generate, Nonce};
432
433        let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
434
435        let mut buf = Vec::with_capacity(Self::NONCE_LEN + plaintext.len() + Self::TAG_LEN);
436        buf.extend_from_slice(&nonce);
437        buf.extend_from_slice(plaintext);
438
439        // encrypt_inout_detached operates on buf[NONCE_LEN..] (the plaintext portion).
440        // Indexing is safe: buf was allocated as nonce + plaintext.
441        //
442        // AAD wiring (block context: table_id, offset, block_type, dict_id, window_log)
443        // tracked separately — see lsm-tree #250/#251/#252.
444        #[expect(
445            clippy::indexing_slicing,
446            reason = "buf length = NONCE_LEN + plaintext.len()"
447        )]
448        let tag = self
449            .cipher
450            .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
451            .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
452
453        buf.extend_from_slice(&tag);
454
455        Ok(buf)
456    }
457
458    fn decrypt(&self, ciphertext: &[u8]) -> crate::Result<Vec<u8>> {
459        use aes_gcm::aead::{AeadInOut, Nonce, Tag};
460
461        let min_len = Self::NONCE_LEN + Self::TAG_LEN;
462        if ciphertext.len() < min_len {
463            return Err(crate::Error::Decrypt(
464                "ciphertext too short for AES-256-GCM (need nonce + tag)",
465            ));
466        }
467
468        #[expect(clippy::indexing_slicing, reason = "length checked above")]
469        let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[..Self::NONCE_LEN])
470            .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
471
472        // Safe: ciphertext.len() >= NONCE_LEN + TAG_LEN checked above
473        let tag_start = ciphertext.len() - Self::TAG_LEN;
474
475        #[expect(clippy::indexing_slicing, reason = "length checked above")]
476        let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&ciphertext[tag_start..])
477            .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
478
479        #[expect(clippy::indexing_slicing, reason = "length checked above")]
480        let mut buf = ciphertext[Self::NONCE_LEN..tag_start].to_vec();
481
482        self.cipher
483            .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
484            .map_err(|_| {
485                crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
486            })?;
487
488        Ok(buf)
489    }
490
491    fn encrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
492        use aes_gcm::aead::{AeadInOut, Generate, Nonce};
493
494        let nonce = thread_local_rng(Nonce::<aes_gcm::Aes256Gcm>::generate_from_rng);
495
496        // Reserve space for nonce prefix + tag suffix in one allocation,
497        // then shift plaintext right and write the nonce into the gap.
498        let plaintext_len = buf.len();
499        buf.reserve(Self::NONCE_LEN + Self::TAG_LEN);
500        buf.resize(plaintext_len + Self::NONCE_LEN, 0);
501        buf.copy_within(..plaintext_len, Self::NONCE_LEN);
502        #[expect(
503            clippy::indexing_slicing,
504            reason = "buf was just resized to include NONCE_LEN"
505        )]
506        buf[..Self::NONCE_LEN].copy_from_slice(&nonce);
507
508        #[expect(
509            clippy::indexing_slicing,
510            reason = "buf length ≥ NONCE_LEN after resize + copy_within"
511        )]
512        let tag = self
513            .cipher
514            .encrypt_inout_detached(&nonce, b"", (&mut buf[Self::NONCE_LEN..]).into())
515            .map_err(|_| crate::Error::Encrypt("AES-256-GCM encryption failed"))?;
516
517        buf.extend_from_slice(&tag);
518
519        Ok(buf)
520    }
521
522    fn decrypt_vec(&self, mut buf: Vec<u8>) -> crate::Result<Vec<u8>> {
523        use aes_gcm::aead::{AeadInOut, Nonce, Tag};
524
525        // Error::Decrypt takes &'static str — can't include runtime lengths
526        // without changing the upstream error type to accept String/Cow.
527        let min_len = Self::NONCE_LEN + Self::TAG_LEN;
528        if buf.len() < min_len {
529            return Err(crate::Error::Decrypt(
530                "ciphertext too short for AES-256-GCM (need nonce + tag)",
531            ));
532        }
533
534        // Copy nonce and tag to the stack before mutating the buffer.
535        #[expect(clippy::indexing_slicing, reason = "length checked above")]
536        let nonce = Nonce::<aes_gcm::Aes256Gcm>::try_from(&buf[..Self::NONCE_LEN])
537            .map_err(|_| crate::Error::Decrypt("AES-256-GCM nonce length mismatch"))?;
538
539        let tag_start = buf.len() - Self::TAG_LEN;
540        #[expect(clippy::indexing_slicing, reason = "length checked above")]
541        let tag = Tag::<aes_gcm::Aes256Gcm>::try_from(&buf[tag_start..])
542            .map_err(|_| crate::Error::Decrypt("AES-256-GCM tag length mismatch"))?;
543
544        // Strip nonce prefix and tag suffix via copy_within + truncate
545        // (single memmove, avoids Drain iterator adapter overhead).
546        buf.copy_within(Self::NONCE_LEN..tag_start, 0);
547        buf.truncate(tag_start - Self::NONCE_LEN);
548
549        self.cipher
550            .decrypt_inout_detached(&nonce, b"", (&mut buf[..]).into(), &tag)
551            .map_err(|_| {
552                crate::Error::Decrypt("AES-256-GCM decryption failed (bad key or tampered data)")
553            })?;
554
555        Ok(buf)
556    }
557
558    // The AAD-bound entry points (`encrypt_block` / `decrypt_block`) live in the
559    // `block` module, gated on `zstd_any` (it wraps `SkippableFrame`). Without
560    // zstd this provider falls back to the trait default (unsupported), since the
561    // wire-format frame is unavailable.
562    #[cfg(zstd_any)]
563    fn encrypt_block_aad(
564        &self,
565        plaintext: &[u8],
566        identity: &crate::table::block::BlockIdentity,
567        compression_type: u8,
568        block_flags: u8,
569    ) -> crate::Result<Vec<u8>> {
570        let ctx = crate::encryption::aad::EncryptionContext::v1(
571            self.key_epoch,
572            self.suite_id,
573            compression_type,
574            block_flags,
575        );
576        crate::encryption::encrypt_block(plaintext, identity, &ctx, &self.key_chain)
577    }
578
579    #[cfg(zstd_any)]
580    fn decrypt_block_aad(
581        &self,
582        ciphertext: &[u8],
583        identity: &crate::table::block::BlockIdentity,
584    ) -> crate::Result<Vec<u8>> {
585        // The frame self-describes its transform fields (key_epoch / suite /
586        // compression_type / block_flags); the reader supplies only `identity`.
587        match crate::encryption::decrypt_block(ciphertext, identity, &self.key_chain) {
588            Ok(decrypted) => Ok(decrypted.plaintext),
589            Err(e) => {
590                // Map the typed AAD-decrypt failure into the crate error; the
591                // specific variant (block-swap / dict-sub / tamper) is logged for
592                // diagnostics since `Error::Decrypt` carries only a static reason.
593                log::debug!("AAD-bound block decryption failed: {e:?}");
594                Err(crate::Error::Decrypt(
595                    "AAD-bound block decryption failed (tampered, wrong key, or malformed frame)",
596                ))
597            }
598        }
599    }
600}
601
602#[cfg(test)]
603#[allow(
604    clippy::doc_markdown,
605    clippy::redundant_clone,
606    clippy::unnecessary_wraps,
607    clippy::redundant_closure_for_method_calls
608)]
609mod tests;