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;