Skip to main content

s4_server/
sse.rs

1//! Server-side encryption (SSE-S4) — AES-256-GCM (v0.4 #21, v0.5 #29, v0.5 #27, v0.5 #28).
2//!
3//! Wraps the post-compression S3 object body with authenticated
4//! encryption. Compress-then-encrypt is the right order: encryption
5//! produces high-entropy bytes that don't compress, so encrypting last
6//! preserves the codec's ratio.
7//!
8//! ## Wire formats
9//!
10//! ### S4E1 (v0.4) — single key, no rotation
11//!
12//! ```text
13//! [magic: "S4E1" 4B]
14//! [algo:  u8]            # 1 = AES-256-GCM
15//! [reserved: 3B]         # 0x00 0x00 0x00
16//! [nonce: 12B]           # random per-object
17//! [tag:   16B]           # AES-GCM authentication tag
18//! [ciphertext: variable] # encrypted-then-authenticated body
19//! ```
20//!
21//! Total overhead: 36 bytes per object.
22//!
23//! ### S4E2 (v0.5 #29) — keyring-aware, supports rotation
24//!
25//! ```text
26//! [magic:  "S4E2" 4B]
27//! [algo:   u8]            # 1 = AES-256-GCM
28//! [key_id: u16 BE]        # which keyring slot encrypted this body
29//! [reserved: 1B]          # 0x00
30//! [nonce:  12B]           # random per-object
31//! [tag:    16B]           # AES-GCM authentication tag
32//! [ciphertext: variable]
33//! ```
34//!
35//! Same 36-byte overhead — we reused the 3-byte reserved area in S4E1
36//! to fit a 2-byte key-id + 1-byte reserved without bumping the header
37//! size. The key-id is included in the AAD so a flipped key-id byte
38//! fails the auth tag (i.e. an attacker can't trick the gateway into
39//! decrypting under a different keyring slot).
40//!
41//! ### S4E3 (v0.5 #27) — SSE-C, customer-provided key
42//!
43//! ```text
44//! [magic:   "S4E3" 4B]
45//! [algo:    u8]            # 1 = AES-256-GCM
46//! [key_md5: 16B]           # MD5 fingerprint of the customer key
47//! [nonce:   12B]           # random per-object
48//! [tag:     16B]           # AES-GCM authentication tag
49//! [ciphertext: variable]
50//! ```
51//!
52//! Overhead: 49 bytes (`4 + 1 + 16 + 12 + 16`). Unlike S4E1/S4E2 the
53//! gateway does **not** persist the key — the client supplies it on
54//! every PUT/GET via `x-amz-server-side-encryption-customer-{algorithm,
55//! key,key-MD5}` headers. We store only the 16-byte MD5 in the on-disk
56//! frame so a GET with the wrong key surfaces as
57//! [`SseError::WrongCustomerKey`] before AES-GCM is even tried (saves a
58//! useless decrypt + gives operators a distinct error from generic auth
59//! failure).
60//!
61//! The `key_md5` is included in the AAD so flipping a single byte of
62//! the stored fingerprint also breaks AES-GCM auth — i.e. an attacker
63//! who tampered with the metadata can't sneak a different key past the
64//! check.
65//!
66//! ### S4E4 (v0.5 #28) — SSE-KMS envelope, per-object DEK
67//!
68//! ```text
69//! [magic:           "S4E4" 4B]
70//! [algo:            u8]            # 1 = AES-256-GCM
71//! [key_id_len:      u8]            # 1..=255, length of UTF-8 key_id
72//! [key_id:          variable]      # UTF-8, AAD-authenticated
73//! [wrapped_dek_len: u32 BE]        # length of the wrapped DEK blob
74//! [wrapped_dek:     variable]      # opaque, AAD-authenticated
75//! [nonce:           12B]           # random per-object
76//! [tag:             16B]           # AES-GCM auth tag for body
77//! [ciphertext:      variable]      # body encrypted under the DEK
78//! ```
79//!
80//! Header overhead: `4 + 1 + 1 + key_id_len + 4 + wrapped_dek_len + 12
81//! + 16` = 38 + key_id_len + wrapped_dek_len. For a typical
82//! [`crate::kms::LocalKms`] wrap (60-byte ciphertext) and a 36-char
83//! UUID-style `key_id`, that's ~134 bytes per object.
84//!
85//! `key_id` and `wrapped_dek` are both placed in the AAD so an
86//! attacker cannot rewrite either field to point the gateway at a
87//! different KEK or wrapped DEK without invalidating the body's
88//! AES-GCM tag. The plaintext DEK is never persisted; only the
89//! wrapped form is on disk, and the gateway holds the plaintext only
90//! for the duration of one PUT or GET.
91//!
92//! S4E4 decrypt requires an `async` round-trip to the KMS backend
93//! (to unwrap the DEK), so the synchronous [`decrypt`] function
94//! refuses S4E4 with [`SseError::KmsAsyncRequired`] — callers that
95//! peek `S4E4` via [`peek_magic`] must dispatch to
96//! [`decrypt_with_kms`] instead.
97//!
98//! ## v0.5 rotation flow (SSE-S4 only)
99//!
100//! Operators wire one [`SseKeyring`] holding the **active** key plus
101//! any number of **retired** keys. PUT always encrypts under the
102//! active key (S4E2 with that key's id). GET sniffs the magic:
103//!
104//! - `S4E1`: legacy single-key path. The keyring's active key is tried
105//!   first, then every retired key — this lets a v0.4 deployment
106//!   migrate to a keyring with the original key as active and decrypt
107//!   pre-rotation objects unchanged.
108//! - `S4E2`: read the key_id, look it up in the keyring, decrypt with
109//!   that exact key. Missing key_id surfaces as `KeyNotInKeyring`.
110//! - `S4E3`: keyring is **not** consulted. Caller must supply
111//!   [`SseSource::CustomerKey`] with the matching key + md5.
112//!
113//! ## Open follow-ups
114//!
115//! - **Server-managed key only** (for SSE-S4): keys come from local
116//!   files via `--sse-s4-key` / `--sse-s4-key-rotated`. KMS / vault
117//!   integration for the SSE-S4 keyring (i.e. wrapping the keyring's
118//!   keys with KMS) is a separate issue. SSE-KMS for per-object DEKs
119//!   is implemented (see [`SseSource::Kms`] + S4E4 above).
120
121// v0.8.8: aes-gcm 0.10 + hmac 0.12 (pinned by RustCrypto) re-export the
122// `Nonce::from_slice` / `Key::<Aes256Gcm>::from_slice` helpers from
123// generic-array 0.14, whose helpers were deprecated in favour of the 1.x
124// API. Migrating requires bumping aes-gcm to a release that pins
125// generic-array 1.x (not yet stable as of this writing), so silence the
126// deprecation at module scope until the upstream RustCrypto stack lands.
127#![allow(deprecated)]
128
129use std::collections::HashMap;
130use std::path::Path;
131use std::sync::Arc;
132
133use aes_gcm::aead::{Aead, KeyInit, Payload};
134use aes_gcm::{Aes256Gcm, Key, Nonce};
135use bytes::Bytes;
136use md5::{Digest as Md5Digest, Md5};
137use rand::RngCore;
138use thiserror::Error;
139
140use crate::kms::{KmsBackend, KmsError, WrappedDek};
141
142pub const SSE_MAGIC_V1: &[u8; 4] = b"S4E1";
143pub const SSE_MAGIC_V2: &[u8; 4] = b"S4E2";
144pub const SSE_MAGIC_V3: &[u8; 4] = b"S4E3";
145pub const SSE_MAGIC_V4: &[u8; 4] = b"S4E4";
146/// v0.8 #52: chunked variant of S4E2 — same SSE-S4 keyring source,
147/// but the body is sliced into independently-sealed AES-GCM chunks
148/// so the GET path can stream-decrypt + emit chunk-by-chunk instead
149/// of buffering the entire object before tag verify. See
150/// [`encrypt_v2_chunked`] / [`decrypt_chunked_stream`] for the on-
151/// the-wire layout.
152///
153/// **Read-only as of v0.8.1 #57** — new PUTs emit [`SSE_MAGIC_V6`]
154/// (S4E6). S4E5 is kept around for back-compat decrypt of objects
155/// written by v0.8.0.
156pub const SSE_MAGIC_V5: &[u8; 4] = b"S4E5";
157/// v0.8.1 #57: identical layout to S4E5 except the per-PUT salt is
158/// widened from 4 → 8 bytes so the birthday-collision threshold on
159/// AES-GCM nonce reuse jumps from ~65k PUTs/key to ~4 billion. See
160/// [`encrypt_v2_chunked`] (now emits S4E6) / the S4E6 wire-format
161/// docs further down for the full layout.
162pub const SSE_MAGIC_V6: &[u8; 4] = b"S4E6";
163/// Back-compat alias — v0.4 callers that imported `SSE_MAGIC` mean S4E1.
164pub const SSE_MAGIC: &[u8; 4] = SSE_MAGIC_V1;
165
166/// Header layout matches between S4E1 and S4E2 (both 36 bytes total)
167/// because S4E2 reuses the 3-byte reserved slot to fit `key_id (2B) +
168/// reserved (1B)`. Keeping them the same length means the rest of the
169/// pipeline (sidecar offsets, multipart math) doesn't care which
170/// frame variant is in flight.
171pub const SSE_HEADER_BYTES: usize = 4 + 1 + 3 + 12 + 16; // = 36
172/// S4E3 (SSE-C) replaces the 3-byte reserved area with a 16-byte
173/// customer-key MD5 fingerprint, so the header is 49 bytes total.
174/// `magic 4 + algo 1 + key_md5 16 + nonce 12 + tag 16`.
175pub const SSE_HEADER_BYTES_V3: usize = 4 + 1 + KEY_MD5_LEN + 12 + 16; // = 49
176pub const ALGO_AES_256_GCM: u8 = 1;
177const NONCE_LEN: usize = 12;
178const TAG_LEN: usize = 16;
179const KEY_LEN: usize = 32;
180const KEY_MD5_LEN: usize = 16;
181/// AWS S3 SSE-C only allows AES256 in the
182/// `x-amz-server-side-encryption-customer-algorithm` header, so we
183/// match that exact spelling for parity with real S3 clients.
184pub const SSE_C_ALGORITHM: &str = "AES256";
185
186#[derive(Debug, Error)]
187pub enum SseError {
188    #[error("SSE key file {path:?}: {source}")]
189    KeyFileIo {
190        path: std::path::PathBuf,
191        source: std::io::Error,
192    },
193    #[error(
194        "SSE key file must be exactly 32 raw bytes (or 64-char hex / 44-char base64); got {got} bytes after parse"
195    )]
196    BadKeyLength { got: usize },
197    #[error("SSE-encrypted body too short ({got} bytes; need at least {SSE_HEADER_BYTES})")]
198    TooShort { got: usize },
199    #[error("SSE bad magic: expected S4E1/S4E2/S4E3/S4E4/S4E5/S4E6, got {got:?}")]
200    BadMagic { got: [u8; 4] },
201    #[error("SSE unsupported algo tag: {tag} (this build only knows AES-256-GCM = 1)")]
202    UnsupportedAlgo { tag: u8 },
203    #[error(
204        "SSE key_id {id} (S4E2 frame) not present in keyring; rotation history likely incomplete"
205    )]
206    KeyNotInKeyring { id: u16 },
207    #[error("SSE decryption / authentication failed (key mismatch or ciphertext tampered with)")]
208    DecryptFailed,
209    // --- v0.5 #27: SSE-C specific errors ---
210    /// The MD5 fingerprint stored in the S4E3 frame doesn't match the
211    /// MD5 of the customer key the client supplied. This is the
212    /// "wrong customer key on GET" signal — distinct from
213    /// `DecryptFailed` so service.rs can map it to AWS S3's
214    /// `403 AccessDenied` (S3 returns AccessDenied when the supplied
215    /// SSE-C key doesn't match the one used at PUT time).
216    #[error("SSE-C key MD5 fingerprint mismatch — client supplied a different key than PUT")]
217    WrongCustomerKey,
218    /// `parse_customer_key_headers` saw a malformed input. `reason` is
219    /// a short human string ("base64 decode of key", "key length",
220    /// "md5 length", "md5 mismatch") for operator log lines — never
221    /// echoed to the client (would leak crypto details).
222    #[error("SSE-C customer-key headers invalid: {reason}")]
223    InvalidCustomerKey { reason: &'static str },
224    /// Client asked for an SSE-C algorithm the gateway doesn't speak.
225    /// AWS S3 only ever defines `AES256` here; surfacing the offending
226    /// string lets us 400 with a useful message.
227    #[error("SSE-C algorithm {algo:?} unsupported (only {SSE_C_ALGORITHM:?} is allowed)")]
228    CustomerKeyAlgorithmUnsupported { algo: String },
229    /// S4E3 body lacks an SSE-C key — caller passed `SseSource::Keyring`
230    /// when decrypting an SSE-C-encrypted object. service.rs should
231    /// translate this into the same "missing customer key" 400 that
232    /// AWS S3 returns when SSE-C headers are absent on a GET.
233    #[error("S4E3 frame requires SseSource::CustomerKey; got Keyring")]
234    CustomerKeyRequired,
235    /// Inverse: client sent SSE-C headers on a GET for an object stored
236    /// without SSE-C. The supplied key has no role in decryption, but
237    /// AWS S3 actually 400s in this case ("expected an unencrypted
238    /// object" / "extraneous SSE-C headers"), so we mirror that.
239    #[error("S4E1/S4E2 frame stored without SSE-C; SseSource::CustomerKey is unexpected")]
240    CustomerKeyUnexpected,
241    // --- v0.5 #28: SSE-KMS specific errors ---
242    /// `decrypt` (sync) was handed an S4E4 body. SSE-KMS unwrap is
243    /// async (it round-trips to the KMS backend), so callers must
244    /// peek the magic with [`peek_magic`] and dispatch S4E4 frames to
245    /// [`decrypt_with_kms`] instead. service.rs's GET handler does
246    /// this; tests / direct callers may hit this if they forget.
247    #[error(
248        "S4E4 (SSE-KMS) body requires async decrypt — call decrypt_with_kms() instead of decrypt()"
249    )]
250    KmsAsyncRequired,
251    /// S4E4 frame is shorter than the minimum-possible header (38
252    /// bytes for an empty `key_id` + empty `wrapped_dek`, which is
253    /// itself impossible — we just sanity-check the floor).
254    #[error("S4E4 frame too short ({got} bytes; need at least {min})")]
255    KmsFrameTooShort { got: usize, min: usize },
256    /// S4E4 declared a `key_id_len` or `wrapped_dek_len` that runs
257    /// past the end of the body. Almost certainly truncation /
258    /// corruption rather than tampering (tampering would fail the
259    /// AES-GCM tag instead).
260    #[error("S4E4 frame field length out of bounds: {what}")]
261    KmsFrameFieldOob { what: &'static str },
262    /// `key_id` field of an S4E4 frame is not valid UTF-8. We require
263    /// UTF-8 because `LocalKms` uses the basename of a `.kek` file
264    /// (which is OS-string-but-typically-UTF-8) and AWS KMS uses ARNs
265    /// (which are ASCII).
266    #[error("S4E4 key_id is not valid UTF-8")]
267    KmsKeyIdNotUtf8,
268    /// service.rs handed `decrypt_with_kms` a `WrappedDek` whose
269    /// `key_id` doesn't match the one stored in the S4E4 frame. This
270    /// is an integration bug (caller is meant to pull the wrapped
271    /// DEK *from the frame*, not from somewhere else), surface as a
272    /// distinct error so it shows up in tests rather than silently
273    /// failing the AES-GCM tag.
274    #[error(
275        "S4E4 SseSource::Kms wrapped DEK key_id {supplied:?} doesn't match frame key_id {stored:?}"
276    )]
277    KmsWrappedDekMismatch { supplied: String, stored: String },
278    /// SSE-KMS path got a non-Kms `SseSource` for an S4E4 body. The
279    /// async dispatch in `decrypt_with_kms` re-derives the source
280    /// internally so this can only happen if a future caller passes
281    /// `SseSource::Keyring` / `CustomerKey` to a path that expected
282    /// `Kms` — kept around for symmetry with the other "wrong source"
283    /// errors.
284    #[error("S4E4 frame requires SseSource::Kms")]
285    KmsRequired,
286    /// Pass-through for [`crate::kms::KmsError`] surfaced from
287    /// `KmsBackend::decrypt_dek` — boxed so the variant stays small.
288    #[error("KMS unwrap: {0}")]
289    KmsBackend(#[from] KmsError),
290    // --- v0.8 #52: S4E5 (chunked SSE-S4) specific errors ---
291    /// AES-GCM auth tag verify failed on chunk `chunk_index` of an
292    /// S4E5 body. Distinct from the all-or-nothing
293    /// [`SseError::DecryptFailed`] because the streaming GET may
294    /// have already emitted earlier chunks to the client by the
295    /// time chunk N fails — operators need the chunk index in audit
296    /// logs to triangulate which byte range was tampered with (or
297    /// which disk sector flipped).
298    #[error(
299        "S4E5 chunk {chunk_index} auth tag verify failed (key mismatch or chunk tampered with)"
300    )]
301    ChunkAuthFailed { chunk_index: u32 },
302    /// Caller asked [`encrypt_v2_chunked`] to use a chunk size of 0
303    /// — nonsensical (would loop forever). Surfaced as an error
304    /// rather than panicking so service.rs can map a bad
305    /// `--sse-chunk-size 0` configuration to a clear startup error.
306    #[error("S4E5 chunk_size must be > 0 (got 0)")]
307    ChunkSizeInvalid,
308    /// S4E5 frame is shorter than the fixed header or declares a
309    /// (chunk_count × per-chunk-bytes) total that overruns the
310    /// body. Almost certainly truncation / corruption — tampering
311    /// with the per-chunk ciphertext or tag would surface as
312    /// [`SseError::ChunkAuthFailed`] instead.
313    #[error("S4E5 frame truncated: {what}")]
314    ChunkFrameTruncated { what: &'static str },
315    // --- v0.8.1 #57: S4E6 (8-byte salt, 24-bit chunk_index) ---
316    /// S4E6 chunk_index is encoded as a 24-bit big-endian field in
317    /// the per-chunk nonce, capping `chunk_count` at
318    /// `2^24 - 1 = 16_777_215`. At the default 1 MiB chunk size that
319    /// is ~16 PiB per object — well past S3's 5 GiB single-object
320    /// ceiling. Surface as a distinct error so a misconfiguration
321    /// (`--sse-chunk-size 1` on a multi-GiB object, say) shows up at
322    /// PUT time with a clear cause rather than a panic at the u32 →
323    /// u24 cast.
324    #[error("S4E6 chunk_count {got} exceeds 24-bit max ({max}) — pick a larger --sse-chunk-size")]
325    ChunkCountTooLarge { got: u32, max: u32 },
326    // --- v0.8.2 #64: pre-allocation guard for chunked SSE frames ---
327    /// `parse_chunked_header` rejected an S4E5 / S4E6 frame because
328    /// the declared `chunk_size × chunk_count` (or the on-disk total
329    /// after adding per-chunk tag overhead and the fixed header) is
330    /// either:
331    ///
332    /// 1. arithmetically nonsensical (the multiplication / addition
333    ///    overflows u64 on a 64-bit host), or
334    /// 2. larger than the gateway's configured `max_body_bytes`
335    ///    (default 5 GiB — AWS S3's single-object PUT ceiling).
336    ///
337    /// This is the **DoS guard** for the chunked path: without it, a
338    /// 24-byte malicious header that claims `chunk_size = u32::MAX`
339    /// and `chunk_count = u32::MAX` would have caused the buffered
340    /// decrypt path to attempt a multi-PB `Vec::with_capacity` (or
341    /// integer-overflow into a tiny alloc + later out-of-bounds
342    /// panic) before any cryptographic work happened. Surface as a
343    /// distinct variant — never echo the offending sizes back to the
344    /// client (kept as a `&'static str` `details` field for operator
345    /// audit logs only) so the response isn't a tampering oracle.
346    #[error("S4E5/S4E6 chunked frame declares an over-large size: {details}")]
347    ChunkFrameTooLarge { details: &'static str },
348}
349
350/// Default cap on a single chunked-SSE frame's declared plaintext
351/// size, used by [`decrypt_chunked_buffered_default`] and the
352/// streaming pre-validation path. Mirrors AWS S3's 5 GiB single-object
353/// PUT ceiling — anything larger is unreachable on the AWS-compatible
354/// API surface and is therefore safe to reject pre-alloc as a malformed
355/// / malicious header (v0.8.2 #64).
356///
357/// v0.9 #106 (32-bit target support): split on `target_pointer_width` so
358/// the `usize` const doesn't const-overflow when someone runs
359/// `cargo check --target i686-unknown-linux-gnu` (or similar 32-bit
360/// target). On 32-bit the cap collapses to `isize::MAX as usize`
361/// (≈ 2 GiB on 32-bit), which is the largest allocation any Rust
362/// `Vec` / `Bytes` buffer can hold: the language ABI caps single-
363/// allocation byte counts at `isize::MAX` (not `usize::MAX`), so
364/// `usize::MAX` would let the guard accept sizes that subsequently
365/// panic inside `Vec::with_capacity`. Codex review (v0.9 #106 P2)
366/// caught this on the initial cut. s4-server itself is still 64-bit-
367/// only at runtime (README §"Supported targets"), but a clean
368/// compile + an *allocatable* upper bound lets s4-codec downstream
369/// consumers cross-check the workspace without risking an OOM in
370/// the SSE buffered-decrypt pre-alloc path.
371#[cfg(target_pointer_width = "64")]
372pub const DEFAULT_MAX_BODY_BYTES: usize = 5 * 1024 * 1024 * 1024;
373#[cfg(target_pointer_width = "32")]
374pub const DEFAULT_MAX_BODY_BYTES: usize = isize::MAX as usize;
375
376/// 32-byte symmetric key. `bytes` is `pub` so call sites can construct
377/// keys directly from already-validated bytes (e.g. KMS-decrypted DEKs)
378/// without going through the on-disk parser. Hold inside an `Arc` when
379/// sharing across handler tasks — `SseKeyring` does this internally.
380pub struct SseKey {
381    pub bytes: [u8; 32],
382}
383
384impl SseKey {
385    /// Load a 32-byte key from disk. Accepts three on-disk encodings:
386    /// raw 32 bytes, 64-char ASCII hex, or 44-char ASCII base64 (with or
387    /// without padding). Whitespace is trimmed.
388    pub fn from_path(path: &Path) -> Result<Self, SseError> {
389        let raw = std::fs::read(path).map_err(|source| SseError::KeyFileIo {
390            path: path.to_path_buf(),
391            source,
392        })?;
393        Self::from_bytes(&raw)
394    }
395
396    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SseError> {
397        // Try raw first.
398        if bytes.len() == KEY_LEN {
399            let mut k = [0u8; KEY_LEN];
400            k.copy_from_slice(bytes);
401            return Ok(Self { bytes: k });
402        }
403        // Trim whitespace and try hex / base64.
404        let s = std::str::from_utf8(bytes).unwrap_or("").trim();
405        if s.len() == KEY_LEN * 2 && s.chars().all(|c| c.is_ascii_hexdigit()) {
406            let mut k = [0u8; KEY_LEN];
407            for (i, k_byte) in k.iter_mut().enumerate() {
408                *k_byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
409                    .map_err(|_| SseError::BadKeyLength { got: bytes.len() })?;
410            }
411            return Ok(Self { bytes: k });
412        }
413        if let Ok(decoded) =
414            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.as_bytes())
415            && decoded.len() == KEY_LEN
416        {
417            let mut k = [0u8; KEY_LEN];
418            k.copy_from_slice(&decoded);
419            return Ok(Self { bytes: k });
420        }
421        Err(SseError::BadKeyLength { got: bytes.len() })
422    }
423
424    fn as_aes_key(&self) -> &Key<Aes256Gcm> {
425        Key::<Aes256Gcm>::from_slice(&self.bytes)
426    }
427}
428
429impl std::fmt::Debug for SseKey {
430    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
431        f.debug_struct("SseKey")
432            .field("len", &KEY_LEN)
433            .field("key", &"<redacted>")
434            .finish()
435    }
436}
437
438/// v0.5 #29: a set of `SseKey`s indexed by `u16` key-id, plus a
439/// designated **active** id used for new encryptions. Rotation is just
440/// "add the new key, flip `active` to its id, leave the old keys for
441/// decryption-only". Cheap to clone (`Arc<SseKey>` per slot).
442#[derive(Clone)]
443pub struct SseKeyring {
444    active: u16,
445    keys: HashMap<u16, Arc<SseKey>>,
446}
447
448impl SseKeyring {
449    /// Create a keyring seeded with one key, immediately marked
450    /// active. Add older keys later via [`SseKeyring::add`] so the
451    /// gateway can still decrypt pre-rotation objects.
452    pub fn new(active: u16, key: Arc<SseKey>) -> Self {
453        let mut keys = HashMap::new();
454        keys.insert(active, key);
455        Self { active, keys }
456    }
457
458    /// Insert another key under id `id`. Does NOT change `active`. If
459    /// `id == active`, the slot is overwritten (useful for tests; in
460    /// production prefer minting a fresh id).
461    pub fn add(&mut self, id: u16, key: Arc<SseKey>) {
462        self.keys.insert(id, key);
463    }
464
465    /// Active (id, key) — used by [`encrypt_v2`] to pick the slot for
466    /// new objects.
467    pub fn active(&self) -> (u16, &SseKey) {
468        let id = self.active;
469        let key = self
470            .keys
471            .get(&id)
472            .expect("active key id must be present in keyring (constructor invariant)");
473        (id, key.as_ref())
474    }
475
476    /// Look up a key by id. Returns `None` for unknown ids — caller
477    /// should surface this as [`SseError::KeyNotInKeyring`].
478    pub fn get(&self, id: u16) -> Option<&SseKey> {
479        self.keys.get(&id).map(Arc::as_ref)
480    }
481}
482
483impl std::fmt::Debug for SseKeyring {
484    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
485        f.debug_struct("SseKeyring")
486            .field("active", &self.active)
487            .field("key_count", &self.keys.len())
488            .field("key_ids", &self.keys.keys().collect::<Vec<_>>())
489            .finish()
490    }
491}
492
493pub type SharedSseKeyring = Arc<SseKeyring>;
494
495/// Encrypt `plaintext` with the given key, producing the on-the-wire
496/// S4E1-framed output: `[magic 4][algo 1][reserved 3][nonce 12][tag 16][ciphertext]`.
497///
498/// Kept for back-compat: v0.4 callers that hand-built an `SseKey` (no
499/// keyring) still get the v1 frame. New code should use
500/// [`encrypt_v2`] which writes S4E2 and supports rotation on read.
501pub fn encrypt(key: &SseKey, plaintext: &[u8]) -> Bytes {
502    let cipher = Aes256Gcm::new(key.as_aes_key());
503    let mut nonce_bytes = [0u8; NONCE_LEN];
504    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
505    let nonce = Nonce::from_slice(&nonce_bytes);
506    // AAD = magic + algo. Tampering with either bumps the tag check.
507    let mut aad = [0u8; 8];
508    aad[..4].copy_from_slice(SSE_MAGIC_V1);
509    aad[4] = ALGO_AES_256_GCM;
510    let ct_with_tag = cipher
511        .encrypt(
512            nonce,
513            Payload {
514                msg: plaintext,
515                aad: &aad,
516            },
517        )
518        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
519    debug_assert!(ct_with_tag.len() >= TAG_LEN);
520    let split = ct_with_tag.len() - TAG_LEN;
521    let (ct, tag) = ct_with_tag.split_at(split);
522
523    let mut out = Vec::with_capacity(SSE_HEADER_BYTES + ct.len());
524    out.extend_from_slice(SSE_MAGIC_V1);
525    out.push(ALGO_AES_256_GCM);
526    out.extend_from_slice(&[0u8; 3]); // reserved
527    out.extend_from_slice(&nonce_bytes);
528    out.extend_from_slice(tag);
529    out.extend_from_slice(ct);
530    Bytes::from(out)
531}
532
533/// v0.5 #29: encrypt under the keyring's currently-active key, writing
534/// an S4E2-framed body (`[magic 4][algo 1][key_id 2 BE][reserved 1]
535/// [nonce 12][tag 16][ciphertext]`). The key-id is included in the
536/// AAD so flipping it fails the auth tag.
537pub fn encrypt_v2(plaintext: &[u8], keyring: &SseKeyring) -> Bytes {
538    let (key_id, key) = keyring.active();
539    let cipher = Aes256Gcm::new(key.as_aes_key());
540    let mut nonce_bytes = [0u8; NONCE_LEN];
541    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
542    let nonce = Nonce::from_slice(&nonce_bytes);
543    let aad = aad_v2(key_id);
544    let ct_with_tag = cipher
545        .encrypt(
546            nonce,
547            Payload {
548                msg: plaintext,
549                aad: &aad,
550            },
551        )
552        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
553    debug_assert!(ct_with_tag.len() >= TAG_LEN);
554    let split = ct_with_tag.len() - TAG_LEN;
555    let (ct, tag) = ct_with_tag.split_at(split);
556
557    let mut out = Vec::with_capacity(SSE_HEADER_BYTES + ct.len());
558    out.extend_from_slice(SSE_MAGIC_V2);
559    out.push(ALGO_AES_256_GCM);
560    out.extend_from_slice(&key_id.to_be_bytes()); // 2B BE key_id
561    out.push(0u8); // 1B reserved
562    out.extend_from_slice(&nonce_bytes);
563    out.extend_from_slice(tag);
564    out.extend_from_slice(ct);
565    Bytes::from(out)
566}
567
568fn aad_v1() -> [u8; 8] {
569    let mut aad = [0u8; 8];
570    aad[..4].copy_from_slice(SSE_MAGIC_V1);
571    aad[4] = ALGO_AES_256_GCM;
572    aad
573}
574
575fn aad_v2(key_id: u16) -> [u8; 8] {
576    let mut aad = [0u8; 8];
577    aad[..4].copy_from_slice(SSE_MAGIC_V2);
578    aad[4] = ALGO_AES_256_GCM;
579    aad[5..7].copy_from_slice(&key_id.to_be_bytes());
580    aad[7] = 0u8;
581    aad
582}
583
584/// AAD for S4E3 = magic (4) + algo (1) + key_md5 (16). Putting the
585/// fingerprint in the AAD means tampering with the stored MD5 (e.g. an
586/// attacker rewriting the header to match a *different* key they
587/// happen to know) breaks the AES-GCM tag — the wrong-key check isn't
588/// just a plain `==` we could be tricked past.
589fn aad_v3(key_md5: &[u8; KEY_MD5_LEN]) -> [u8; 4 + 1 + KEY_MD5_LEN] {
590    let mut aad = [0u8; 4 + 1 + KEY_MD5_LEN];
591    aad[..4].copy_from_slice(SSE_MAGIC_V3);
592    aad[4] = ALGO_AES_256_GCM;
593    aad[5..5 + KEY_MD5_LEN].copy_from_slice(key_md5);
594    aad
595}
596
597/// Parsed + verified SSE-C key material from the three customer
598/// headers. `key_md5` is the MD5 of `key` (we recompute and compare in
599/// [`parse_customer_key_headers`] — clients send their own to catch
600/// transport corruption, but we *trust* our own computation as the
601/// canonical fingerprint in the S4E3 frame).
602#[derive(Clone)]
603pub struct CustomerKeyMaterial {
604    pub key: [u8; KEY_LEN],
605    pub key_md5: [u8; KEY_MD5_LEN],
606}
607
608impl std::fmt::Debug for CustomerKeyMaterial {
609    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
610        // Don't leak the key into logs. The MD5 is a public fingerprint
611        // (S3 puts it on the wire), so that's safe to show.
612        f.debug_struct("CustomerKeyMaterial")
613            .field("key", &"<redacted>")
614            .field("key_md5_hex", &hex_lower(&self.key_md5))
615            .finish()
616    }
617}
618
619fn hex_lower(bytes: &[u8]) -> String {
620    let mut s = String::with_capacity(bytes.len() * 2);
621    for b in bytes {
622        s.push_str(&format!("{b:02x}"));
623    }
624    s
625}
626
627/// Source of the encryption key for [`encrypt_with_source`] /
628/// [`decrypt`]. SSE-S4 (server-managed, rotation-aware) goes through
629/// `Keyring`; SSE-C (customer-supplied) goes through `CustomerKey`.
630///
631/// Borrowed (not owned) so the caller can hold a long-lived
632/// `CustomerKeyMaterial` next to the request and just lend it for the
633/// duration of one PUT/GET.
634#[derive(Debug, Clone, Copy)]
635pub enum SseSource<'a> {
636    /// Server-managed keyring path → produces / consumes S4E1 (legacy)
637    /// or S4E2 (rotation-aware) frames.
638    Keyring(&'a SseKeyring),
639    /// Client-supplied AES-256 key + its MD5 fingerprint → produces /
640    /// consumes S4E3 frames. The server never persists the key; it
641    /// stores `key_md5` only.
642    CustomerKey {
643        key: &'a [u8; KEY_LEN],
644        key_md5: &'a [u8; KEY_MD5_LEN],
645    },
646    /// SSE-KMS envelope → produces / consumes S4E4 frames. The server
647    /// holds a per-object plaintext DEK (from a fresh
648    /// [`KmsBackend::generate_dek`] call) and the wrapped form to
649    /// persist alongside the body. The DEK is dropped after one
650    /// PUT/GET; only the wrapped form survives at rest.
651    Kms {
652        /// 32-byte plaintext DEK, used as the AES-GCM key.
653        dek: &'a [u8; KEY_LEN],
654        /// Wrapped form to persist in the S4E4 frame (PUT) or the one
655        /// read out of the frame (GET, after a successful unwrap).
656        wrapped: &'a WrappedDek,
657    },
658}
659
660/// Back-compat coercion: existing call sites pass `&SseKeyring`
661/// directly to [`decrypt`]. With this `From` impl the generic bound
662/// `Into<SseSource>` accepts `&SseKeyring` without the caller writing
663/// `.into()`, keeping v0.4 / v0.5 #29 service.rs callers compiling
664/// untouched while v0.5 #27 SSE-C callers pass `SseSource::CustomerKey`
665/// explicitly.
666impl<'a> From<&'a SseKeyring> for SseSource<'a> {
667    fn from(kr: &'a SseKeyring) -> Self {
668        SseSource::Keyring(kr)
669    }
670}
671
672/// service.rs holds keyring as `Option<Arc<SseKeyring>>` and unwraps to
673/// `&Arc<SseKeyring>` — let that coerce too, otherwise every existing
674/// call site needs `.as_ref()` boilerplate.
675impl<'a> From<&'a Arc<SseKeyring>> for SseSource<'a> {
676    fn from(kr: &'a Arc<SseKeyring>) -> Self {
677        SseSource::Keyring(kr.as_ref())
678    }
679}
680
681impl<'a> From<&'a CustomerKeyMaterial> for SseSource<'a> {
682    fn from(m: &'a CustomerKeyMaterial) -> Self {
683        SseSource::CustomerKey {
684            key: &m.key,
685            key_md5: &m.key_md5,
686        }
687    }
688}
689
690/// Parse the three AWS SSE-C headers and return verified key material.
691///
692/// Validates, in order:
693/// 1. `algorithm == "AES256"` (the only value AWS S3 defines).
694/// 2. `key_base64` decodes to exactly 32 bytes (AES-256 key length).
695/// 3. `key_md5_base64` decodes to exactly 16 bytes (MD5 digest length).
696/// 4. The actual MD5 of the decoded key matches the supplied MD5.
697///
698/// Step 4 catches transport corruption *and* a class of programming
699/// bugs where the client signs with one key but uploads another. AWS
700/// S3 also performs this check.
701pub fn parse_customer_key_headers(
702    algorithm: &str,
703    key_base64: &str,
704    key_md5_base64: &str,
705) -> Result<CustomerKeyMaterial, SseError> {
706    use base64::Engine as _;
707    if algorithm != SSE_C_ALGORITHM {
708        return Err(SseError::CustomerKeyAlgorithmUnsupported {
709            algo: algorithm.to_string(),
710        });
711    }
712    let key_bytes = base64::engine::general_purpose::STANDARD
713        .decode(key_base64.trim().as_bytes())
714        .map_err(|_| SseError::InvalidCustomerKey {
715            reason: "base64 decode of key",
716        })?;
717    if key_bytes.len() != KEY_LEN {
718        return Err(SseError::InvalidCustomerKey {
719            reason: "key length (must be 32 bytes after base64 decode)",
720        });
721    }
722    let supplied_md5 = base64::engine::general_purpose::STANDARD
723        .decode(key_md5_base64.trim().as_bytes())
724        .map_err(|_| SseError::InvalidCustomerKey {
725            reason: "base64 decode of key MD5",
726        })?;
727    if supplied_md5.len() != KEY_MD5_LEN {
728        return Err(SseError::InvalidCustomerKey {
729            reason: "key MD5 length (must be 16 bytes after base64 decode)",
730        });
731    }
732    let actual_md5 = compute_key_md5(&key_bytes);
733    // Constant-time compare — paranoia, MD5 is non-secret but the key
734    // it identifies is, so we don't want a timing oracle.
735    if !constant_time_eq(&actual_md5, &supplied_md5) {
736        return Err(SseError::InvalidCustomerKey {
737            reason: "supplied MD5 does not match MD5 of supplied key",
738        });
739    }
740    let mut key = [0u8; KEY_LEN];
741    key.copy_from_slice(&key_bytes);
742    let mut key_md5 = [0u8; KEY_MD5_LEN];
743    key_md5.copy_from_slice(&actual_md5);
744    Ok(CustomerKeyMaterial { key, key_md5 })
745}
746
747/// Convenience wrapper — compute the MD5 fingerprint of a 32-byte
748/// customer key. Callers that already have the bytes (e.g. derived
749/// from a KMS unwrap) can use this to construct a
750/// [`CustomerKeyMaterial`] directly.
751pub fn compute_key_md5(key: &[u8]) -> [u8; KEY_MD5_LEN] {
752    let mut h = Md5::new();
753    h.update(key);
754    let out = h.finalize();
755    let mut md5 = [0u8; KEY_MD5_LEN];
756    md5.copy_from_slice(&out);
757    md5
758}
759
760/// `subtle`-free constant-time byte slice equality. We only need this
761/// at one site (MD5 verification) so pulling `subtle` in feels excessive.
762fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
763    if a.len() != b.len() {
764        return false;
765    }
766    let mut acc: u8 = 0;
767    for (x, y) in a.iter().zip(b.iter()) {
768        acc |= x ^ y;
769    }
770    acc == 0
771}
772
773/// v0.5 #27: encrypt under whichever source the caller picked.
774///
775/// - `SseSource::Keyring` → delegates to [`encrypt_v2`] (S4E2 frame).
776/// - `SseSource::CustomerKey` → writes an S4E3 frame (no key persisted,
777///   just the MD5 fingerprint for GET-side verification).
778///
779/// service.rs picks the source per-request: SSE-C headers present →
780/// `CustomerKey`, otherwise (and only when `--sse-s4-key` is wired) →
781/// `Keyring`. Plaintext objects skip this function entirely.
782pub fn encrypt_with_source(plaintext: &[u8], source: SseSource<'_>) -> Bytes {
783    match source {
784        SseSource::Keyring(kr) => encrypt_v2(plaintext, kr),
785        SseSource::CustomerKey { key, key_md5 } => encrypt_v3(plaintext, key, key_md5),
786        SseSource::Kms { dek, wrapped } => encrypt_v4(plaintext, dek, wrapped),
787    }
788}
789
790fn encrypt_v3(plaintext: &[u8], key: &[u8; KEY_LEN], key_md5: &[u8; KEY_MD5_LEN]) -> Bytes {
791    let aes_key = Key::<Aes256Gcm>::from_slice(key);
792    let cipher = Aes256Gcm::new(aes_key);
793    let mut nonce_bytes = [0u8; NONCE_LEN];
794    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
795    let nonce = Nonce::from_slice(&nonce_bytes);
796    let aad = aad_v3(key_md5);
797    let ct_with_tag = cipher
798        .encrypt(
799            nonce,
800            Payload {
801                msg: plaintext,
802                aad: &aad,
803            },
804        )
805        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
806    debug_assert!(ct_with_tag.len() >= TAG_LEN);
807    let split = ct_with_tag.len() - TAG_LEN;
808    let (ct, tag) = ct_with_tag.split_at(split);
809
810    let mut out = Vec::with_capacity(SSE_HEADER_BYTES_V3 + ct.len());
811    out.extend_from_slice(SSE_MAGIC_V3);
812    out.push(ALGO_AES_256_GCM);
813    out.extend_from_slice(key_md5);
814    out.extend_from_slice(&nonce_bytes);
815    out.extend_from_slice(tag);
816    out.extend_from_slice(ct);
817    Bytes::from(out)
818}
819
820/// v0.5 #29 + v0.5 #27: dispatch on the body's magic and decrypt under
821/// whichever source the caller supplied.
822///
823/// - `S4E1` / `S4E2` require `SseSource::Keyring` (return
824///   [`SseError::CustomerKeyRequired`] for `CustomerKey` — service.rs
825///   should map this to "extraneous SSE-C headers" 400).
826/// - `S4E3` requires `SseSource::CustomerKey` (return
827///   [`SseError::CustomerKeyUnexpected`] for `Keyring` — service.rs
828///   should map this to "missing SSE-C headers" 400).
829///
830/// Generic over `Into<SseSource>` so existing `decrypt(body, &keyring)`
831/// call sites compile unchanged via the `From<&SseKeyring>` impl above
832/// — only the new SSE-C path needs to type out
833/// `SseSource::CustomerKey { .. }`.
834///
835/// Distinct errors (`KeyNotInKeyring`, `DecryptFailed`,
836/// `WrongCustomerKey`) let operators tell rotation gaps, ciphertext
837/// tampering, and SSE-C key mismatch apart in audit logs.
838pub fn decrypt<'a, S: Into<SseSource<'a>>>(body: &[u8], source: S) -> Result<Bytes, SseError> {
839    let source = source.into();
840    // Outer short-check uses the smaller of the two header sizes
841    // (S4E1/S4E2 = 36 bytes). Anything below this can't be any valid
842    // SSE frame regardless of magic — keeps back-compat with v0.4 /
843    // v0.5 #29 callers that expected `TooShort` for absurdly short
844    // inputs even when the magic is garbage.
845    if body.len() < SSE_HEADER_BYTES {
846        return Err(SseError::TooShort { got: body.len() });
847    }
848    let mut magic = [0u8; 4];
849    magic.copy_from_slice(&body[..4]);
850    match &magic {
851        m if m == SSE_MAGIC_V1 || m == SSE_MAGIC_V2 => {
852            let keyring = match source {
853                SseSource::Keyring(kr) => kr,
854                SseSource::CustomerKey { .. } => return Err(SseError::CustomerKeyUnexpected),
855                // S4E1/E2 stored under the keyring → SseSource::Kms
856                // is just as nonsensical as CustomerKey here. Re-use
857                // the same "wrong source" error so service.rs can
858                // map both to AWS S3's "extraneous SSE-* headers"
859                // 400.
860                SseSource::Kms { .. } => return Err(SseError::CustomerKeyUnexpected),
861            };
862            if m == SSE_MAGIC_V1 {
863                decrypt_v1_with_keyring(body, keyring)
864            } else {
865                decrypt_v2_with_keyring(body, keyring)
866            }
867        }
868        m if m == SSE_MAGIC_V3 => {
869            // S4E3 has a larger 49-byte header, so re-check.
870            if body.len() < SSE_HEADER_BYTES_V3 {
871                return Err(SseError::TooShort { got: body.len() });
872            }
873            let (key, key_md5) = match source {
874                SseSource::CustomerKey { key, key_md5 } => (key, key_md5),
875                SseSource::Keyring(_) => return Err(SseError::CustomerKeyRequired),
876                SseSource::Kms { .. } => return Err(SseError::CustomerKeyRequired),
877            };
878            decrypt_v3(body, key, key_md5)
879        }
880        m if m == SSE_MAGIC_V4 => {
881            // SSE-KMS unwrap is async (KMS round-trip required).
882            // Caller must dispatch to `decrypt_with_kms` after
883            // peeking the magic — surface this as a distinct error
884            // rather than silently failing.
885            Err(SseError::KmsAsyncRequired)
886        }
887        m if m == SSE_MAGIC_V5 || m == SSE_MAGIC_V6 => {
888            // v0.8 #52 (S4E5) / v0.8.1 #57 (S4E6): chunked SSE-S4.
889            // Sync back-compat path — verifies + decrypts every
890            // chunk into a single Bytes. Callers that want true
891            // streaming (per-chunk emit) should use
892            // `decrypt_chunked_stream` instead. SSE-C and SSE-KMS
893            // sources are nonsensical here for the same reason as
894            // S4E2 (server-managed keyring only).
895            let keyring = match source {
896                SseSource::Keyring(kr) => kr,
897                SseSource::CustomerKey { .. } => {
898                    return Err(SseError::CustomerKeyUnexpected);
899                }
900                SseSource::Kms { .. } => return Err(SseError::CustomerKeyUnexpected),
901            };
902            // v0.8.2 #64: route through the back-compat wrapper so
903            // the public `decrypt` signature stays stable while the
904            // pre-allocation guard runs against the gateway's
905            // 5 GiB single-object cap. Bespoke callers (e.g. tests
906            // or future API surfaces that want a tighter limit) can
907            // call `decrypt_chunked_buffered` directly.
908            decrypt_chunked_buffered_default(body, keyring)
909        }
910        _ => Err(SseError::BadMagic { got: magic }),
911    }
912}
913
914fn decrypt_v3(
915    body: &[u8],
916    key: &[u8; KEY_LEN],
917    supplied_md5: &[u8; KEY_MD5_LEN],
918) -> Result<Bytes, SseError> {
919    let algo = body[4];
920    if algo != ALGO_AES_256_GCM {
921        return Err(SseError::UnsupportedAlgo { tag: algo });
922    }
923    let mut stored_md5 = [0u8; KEY_MD5_LEN];
924    stored_md5.copy_from_slice(&body[5..5 + KEY_MD5_LEN]);
925    // Cheap fingerprint check first — if the supplied key has a
926    // different MD5 than what was used at PUT, fail fast with a
927    // dedicated error. AES-GCM auth would also catch this (different
928    // key → bad tag) but the bespoke error gives operators an audit
929    // signal distinct from "ciphertext was tampered with".
930    if !constant_time_eq(supplied_md5, &stored_md5) {
931        return Err(SseError::WrongCustomerKey);
932    }
933    let nonce_off = 5 + KEY_MD5_LEN;
934    let tag_off = nonce_off + NONCE_LEN;
935    let mut nonce_bytes = [0u8; NONCE_LEN];
936    nonce_bytes.copy_from_slice(&body[nonce_off..nonce_off + NONCE_LEN]);
937    let mut tag_bytes = [0u8; TAG_LEN];
938    tag_bytes.copy_from_slice(&body[tag_off..tag_off + TAG_LEN]);
939    let ct = &body[SSE_HEADER_BYTES_V3..];
940
941    let aad = aad_v3(&stored_md5);
942    let nonce = Nonce::from_slice(&nonce_bytes);
943    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
944    ct_with_tag.extend_from_slice(ct);
945    ct_with_tag.extend_from_slice(&tag_bytes);
946
947    let aes_key = Key::<Aes256Gcm>::from_slice(key);
948    let cipher = Aes256Gcm::new(aes_key);
949    let plain = cipher
950        .decrypt(
951            nonce,
952            Payload {
953                msg: &ct_with_tag,
954                aad: &aad,
955            },
956        )
957        .map_err(|_| SseError::DecryptFailed)?;
958    Ok(Bytes::from(plain))
959}
960
961/// AAD for S4E4 = magic (4) + algo (1) + key_id_len (1) + key_id +
962/// wrapped_dek_len (4 BE) + wrapped_dek. Putting the variable-length
963/// key_id and wrapped_dek into the AAD means an attacker cannot
964/// rewrite either field to redirect the gateway to a different KEK
965/// or wrapped DEK without invalidating the body's AES-GCM tag.
966///
967/// Length-prefixing key_id and wrapped_dek inside the AAD prevents a
968/// canonicalisation ambiguity: without the length prefix, an
969/// attacker could shift bytes between the two fields and produce the
970/// same AAD bytestream, defeating the per-field tampering check.
971fn aad_v4(key_id: &[u8], wrapped_dek: &[u8]) -> Vec<u8> {
972    let mut aad = Vec::with_capacity(4 + 1 + 1 + key_id.len() + 4 + wrapped_dek.len());
973    aad.extend_from_slice(SSE_MAGIC_V4);
974    aad.push(ALGO_AES_256_GCM);
975    aad.push(key_id.len() as u8);
976    aad.extend_from_slice(key_id);
977    aad.extend_from_slice(&(wrapped_dek.len() as u32).to_be_bytes());
978    aad.extend_from_slice(wrapped_dek);
979    aad
980}
981
982fn encrypt_v4(plaintext: &[u8], dek: &[u8; KEY_LEN], wrapped: &WrappedDek) -> Bytes {
983    // Pre-conditions: key_id must fit in a u8 length prefix and be
984    // non-empty (an empty id means we wouldn't be able to re-fetch
985    // the KEK on GET). wrapped_dek length fits in u32 by the same
986    // logic — at u32::MAX bytes you have bigger problems. We assert
987    // these in debug and silently truncate-or-panic in release; in
988    // practice key_id is a UUID or ARN (<256 chars) and wrapped_dek
989    // is 60 bytes (LocalKms) or ~200 bytes (AWS KMS).
990    assert!(
991        !wrapped.key_id.is_empty() && wrapped.key_id.len() <= u8::MAX as usize,
992        "S4E4 key_id must be 1..=255 bytes (got {})",
993        wrapped.key_id.len()
994    );
995    assert!(
996        wrapped.ciphertext.len() <= u32::MAX as usize,
997        "S4E4 wrapped_dek longer than u32::MAX",
998    );
999
1000    let aes_key = Key::<Aes256Gcm>::from_slice(dek);
1001    let cipher = Aes256Gcm::new(aes_key);
1002    let mut nonce_bytes = [0u8; NONCE_LEN];
1003    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
1004    let nonce = Nonce::from_slice(&nonce_bytes);
1005    let aad = aad_v4(wrapped.key_id.as_bytes(), &wrapped.ciphertext);
1006    let ct_with_tag = cipher
1007        .encrypt(
1008            nonce,
1009            Payload {
1010                msg: plaintext,
1011                aad: &aad,
1012            },
1013        )
1014        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
1015    debug_assert!(ct_with_tag.len() >= TAG_LEN);
1016    let split = ct_with_tag.len() - TAG_LEN;
1017    let (ct, tag) = ct_with_tag.split_at(split);
1018
1019    let key_id_bytes = wrapped.key_id.as_bytes();
1020    let mut out = Vec::with_capacity(
1021        4 + 1
1022            + 1
1023            + key_id_bytes.len()
1024            + 4
1025            + wrapped.ciphertext.len()
1026            + NONCE_LEN
1027            + TAG_LEN
1028            + ct.len(),
1029    );
1030    out.extend_from_slice(SSE_MAGIC_V4);
1031    out.push(ALGO_AES_256_GCM);
1032    out.push(key_id_bytes.len() as u8);
1033    out.extend_from_slice(key_id_bytes);
1034    out.extend_from_slice(&(wrapped.ciphertext.len() as u32).to_be_bytes());
1035    out.extend_from_slice(&wrapped.ciphertext);
1036    out.extend_from_slice(&nonce_bytes);
1037    out.extend_from_slice(tag);
1038    out.extend_from_slice(ct);
1039    Bytes::from(out)
1040}
1041
1042/// Parsed view of an S4E4 frame's variable-length header. Returned
1043/// by [`parse_s4e4_header`] so both the async [`decrypt_with_kms`]
1044/// path and any future inspection code (e.g. an admin tool that
1045/// needs to enumerate object → KMS-key bindings) can reuse the same
1046/// parser without re-implementing offset math.
1047#[derive(Debug)]
1048pub struct S4E4Header<'a> {
1049    pub key_id: &'a str,
1050    pub wrapped_dek: &'a [u8],
1051    pub nonce: &'a [u8],
1052    pub tag: &'a [u8],
1053    pub ciphertext: &'a [u8],
1054}
1055
1056/// Parse the (variable-length) S4E4 header. Pure byte-shuffling — no
1057/// crypto, no KMS round-trip. Returns errors on truncation /
1058/// out-of-bounds field lengths / non-UTF-8 key_id.
1059pub fn parse_s4e4_header(body: &[u8]) -> Result<S4E4Header<'_>, SseError> {
1060    // Minimum: magic(4) + algo(1) + key_id_len(1) + key_id(>=1) +
1061    // wrapped_dek_len(4) + wrapped_dek(>=1) + nonce(12) + tag(16)
1062    // = 40 bytes. We use a slightly looser floor here (bytes for
1063    // empty fields = 38) and let the per-field bounds checks below
1064    // catch the actual short reads.
1065    const S4E4_MIN: usize = 4 + 1 + 1 + 4 + NONCE_LEN + TAG_LEN; // 38
1066    if body.len() < S4E4_MIN {
1067        return Err(SseError::KmsFrameTooShort {
1068            got: body.len(),
1069            min: S4E4_MIN,
1070        });
1071    }
1072    let magic = &body[..4];
1073    if magic != SSE_MAGIC_V4 {
1074        let mut got = [0u8; 4];
1075        got.copy_from_slice(magic);
1076        return Err(SseError::BadMagic { got });
1077    }
1078    let algo = body[4];
1079    if algo != ALGO_AES_256_GCM {
1080        return Err(SseError::UnsupportedAlgo { tag: algo });
1081    }
1082    let key_id_len = body[5] as usize;
1083    let key_id_off: usize = 6;
1084    let key_id_end = key_id_off
1085        .checked_add(key_id_len)
1086        .ok_or(SseError::KmsFrameFieldOob { what: "key_id_len" })?;
1087    if key_id_end + 4 > body.len() {
1088        return Err(SseError::KmsFrameFieldOob { what: "key_id" });
1089    }
1090    let key_id = std::str::from_utf8(&body[key_id_off..key_id_end])
1091        .map_err(|_| SseError::KmsKeyIdNotUtf8)?;
1092    let wrapped_len_off = key_id_end;
1093    let wrapped_dek_len = u32::from_be_bytes([
1094        body[wrapped_len_off],
1095        body[wrapped_len_off + 1],
1096        body[wrapped_len_off + 2],
1097        body[wrapped_len_off + 3],
1098    ]) as usize;
1099    let wrapped_off = wrapped_len_off + 4;
1100    let wrapped_end =
1101        wrapped_off
1102            .checked_add(wrapped_dek_len)
1103            .ok_or(SseError::KmsFrameFieldOob {
1104                what: "wrapped_dek_len",
1105            })?;
1106    if wrapped_end + NONCE_LEN + TAG_LEN > body.len() {
1107        return Err(SseError::KmsFrameFieldOob {
1108            what: "wrapped_dek",
1109        });
1110    }
1111    let wrapped_dek = &body[wrapped_off..wrapped_end];
1112    let nonce_off = wrapped_end;
1113    let tag_off = nonce_off + NONCE_LEN;
1114    let ct_off = tag_off + TAG_LEN;
1115    let nonce = &body[nonce_off..nonce_off + NONCE_LEN];
1116    let tag = &body[tag_off..tag_off + TAG_LEN];
1117    let ciphertext = &body[ct_off..];
1118    Ok(S4E4Header {
1119        key_id,
1120        wrapped_dek,
1121        nonce,
1122        tag,
1123        ciphertext,
1124    })
1125}
1126
1127/// Async decrypt for S4E4 (SSE-KMS) bodies. Caller supplies the KMS
1128/// backend; this function parses the frame, calls
1129/// `kms.decrypt_dek(...)` to unwrap the DEK, then runs AES-256-GCM
1130/// to recover the plaintext.
1131///
1132/// service.rs's GET handler should peek the magic with [`peek_magic`]
1133/// and dispatch:
1134///
1135/// - `Some("S4E4")` → `decrypt_with_kms(blob, &*kms).await`
1136/// - everything else → existing sync `decrypt(blob, source)`
1137///
1138/// Note: we don't go through `SseSource::Kms` here because the
1139/// wrapped DEK + key_id come from the frame itself, not from the
1140/// request — the `SseSource` is built for sync paths where the
1141/// caller already knows the key.
1142pub async fn decrypt_with_kms(body: &[u8], kms: &dyn KmsBackend) -> Result<Bytes, SseError> {
1143    let hdr = parse_s4e4_header(body)?;
1144    let wrapped = WrappedDek {
1145        key_id: hdr.key_id.to_string(),
1146        ciphertext: hdr.wrapped_dek.to_vec(),
1147    };
1148    let dek_vec = kms.decrypt_dek(&wrapped).await?;
1149    if dek_vec.len() != KEY_LEN {
1150        // KMS returned a non-32-byte plaintext. AES-256 needs exactly
1151        // 32 bytes. This shouldn't happen with `KeySpec=AES_256` but
1152        // surface as a backend error so it's auditable rather than
1153        // panicking.
1154        return Err(SseError::KmsBackend(KmsError::BackendUnavailable {
1155            message: format!(
1156                "KMS returned {} byte DEK; expected {KEY_LEN}",
1157                dek_vec.len()
1158            ),
1159        }));
1160    }
1161    let mut dek = [0u8; KEY_LEN];
1162    dek.copy_from_slice(&dek_vec);
1163
1164    let aad = aad_v4(hdr.key_id.as_bytes(), hdr.wrapped_dek);
1165    let aes_key = Key::<Aes256Gcm>::from_slice(&dek);
1166    let cipher = Aes256Gcm::new(aes_key);
1167    let nonce = Nonce::from_slice(hdr.nonce);
1168    let mut ct_with_tag = Vec::with_capacity(hdr.ciphertext.len() + TAG_LEN);
1169    ct_with_tag.extend_from_slice(hdr.ciphertext);
1170    ct_with_tag.extend_from_slice(hdr.tag);
1171    let plain = cipher
1172        .decrypt(
1173            nonce,
1174            Payload {
1175                msg: &ct_with_tag,
1176                aad: &aad,
1177            },
1178        )
1179        .map_err(|_| SseError::DecryptFailed)?;
1180    Ok(Bytes::from(plain))
1181}
1182
1183fn decrypt_v1_with_keyring(body: &[u8], keyring: &SseKeyring) -> Result<Bytes, SseError> {
1184    let algo = body[4];
1185    if algo != ALGO_AES_256_GCM {
1186        return Err(SseError::UnsupportedAlgo { tag: algo });
1187    }
1188    // body[5..8] reserved (must be ignored — v0.4 wrote zeros, but we
1189    // didn't auth them so we can't insist on it).
1190    let mut nonce_bytes = [0u8; NONCE_LEN];
1191    nonce_bytes.copy_from_slice(&body[8..8 + NONCE_LEN]);
1192    let mut tag_bytes = [0u8; TAG_LEN];
1193    tag_bytes.copy_from_slice(&body[8 + NONCE_LEN..SSE_HEADER_BYTES]);
1194    let ct = &body[SSE_HEADER_BYTES..];
1195
1196    let aad = aad_v1();
1197    let nonce = Nonce::from_slice(&nonce_bytes);
1198    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
1199    ct_with_tag.extend_from_slice(ct);
1200    ct_with_tag.extend_from_slice(&tag_bytes);
1201
1202    // Active key first, then any others. v0.4 deployments that flip to
1203    // v0.5 with their original key as active hit this path on the
1204    // first try for every legacy object.
1205    let (active_id, _active_key) = keyring.active();
1206    let mut ids: Vec<u16> = keyring.keys.keys().copied().collect();
1207    ids.sort_by_key(|id| if *id == active_id { 0 } else { 1 });
1208    for id in ids {
1209        let key = keyring.get(id).expect("id came from keyring iteration");
1210        let cipher = Aes256Gcm::new(key.as_aes_key());
1211        if let Ok(plain) = cipher.decrypt(
1212            nonce,
1213            Payload {
1214                msg: &ct_with_tag,
1215                aad: &aad,
1216            },
1217        ) {
1218            return Ok(Bytes::from(plain));
1219        }
1220    }
1221    Err(SseError::DecryptFailed)
1222}
1223
1224fn decrypt_v2_with_keyring(body: &[u8], keyring: &SseKeyring) -> Result<Bytes, SseError> {
1225    let algo = body[4];
1226    if algo != ALGO_AES_256_GCM {
1227        return Err(SseError::UnsupportedAlgo { tag: algo });
1228    }
1229    let key_id = u16::from_be_bytes([body[5], body[6]]);
1230    // body[7] reserved (1B), authenticated as 0 via AAD.
1231    let key = keyring
1232        .get(key_id)
1233        .ok_or(SseError::KeyNotInKeyring { id: key_id })?;
1234    let mut nonce_bytes = [0u8; NONCE_LEN];
1235    nonce_bytes.copy_from_slice(&body[8..8 + NONCE_LEN]);
1236    let mut tag_bytes = [0u8; TAG_LEN];
1237    tag_bytes.copy_from_slice(&body[8 + NONCE_LEN..SSE_HEADER_BYTES]);
1238    let ct = &body[SSE_HEADER_BYTES..];
1239
1240    let aad = aad_v2(key_id);
1241    let nonce = Nonce::from_slice(&nonce_bytes);
1242    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
1243    ct_with_tag.extend_from_slice(ct);
1244    ct_with_tag.extend_from_slice(&tag_bytes);
1245    let cipher = Aes256Gcm::new(key.as_aes_key());
1246    let plain = cipher
1247        .decrypt(
1248            nonce,
1249            Payload {
1250                msg: &ct_with_tag,
1251                aad: &aad,
1252            },
1253        )
1254        .map_err(|_| SseError::DecryptFailed)?;
1255    Ok(Bytes::from(plain))
1256}
1257
1258/// Detect whether `body` is SSE-S4 encrypted (S4E1, S4E2, S4E3, or
1259/// S4E4) by sniffing the first 4 magic bytes. Used by the GET path
1260/// to decide whether to run decryption before frame parsing.
1261///
1262/// We require a length check that's safe for *any* of the four
1263/// frames — `SSE_HEADER_BYTES` (36) is the smallest valid header
1264/// (S4E1 / S4E2). S4E3 is 49 bytes; S4E4 is variable but always >=
1265/// 38 bytes. The per-frame decrypt path re-checks the appropriate
1266/// minimum, so this 36-byte gate is just a fast rejection of
1267/// obviously-too-short bodies.
1268pub fn looks_encrypted(body: &[u8]) -> bool {
1269    if body.len() < SSE_HEADER_BYTES {
1270        return false;
1271    }
1272    let m = &body[..4];
1273    m == SSE_MAGIC_V1
1274        || m == SSE_MAGIC_V2
1275        || m == SSE_MAGIC_V3
1276        || m == SSE_MAGIC_V4
1277        || m == SSE_MAGIC_V5
1278        || m == SSE_MAGIC_V6
1279}
1280
1281/// Peek the SSE-S4 magic at the front of `body`, returning a
1282/// stringified frame variant identifier or `None` if `body` is not
1283/// recognized as SSE-S4. Used by the GET path to dispatch between
1284/// the sync [`decrypt`] (S4E1/E2/E3) and the async
1285/// [`decrypt_with_kms`] (S4E4).
1286///
1287/// Returns the same length-gated result as [`looks_encrypted`]: any
1288/// body shorter than `SSE_HEADER_BYTES` (36 bytes) returns `None`,
1289/// so the caller can use this as both the "is encrypted" signal and
1290/// the "which frame" signal in one cheap byte-comparison.
1291pub fn peek_magic(body: &[u8]) -> Option<&'static str> {
1292    if body.len() < SSE_HEADER_BYTES {
1293        return None;
1294    }
1295    match &body[..4] {
1296        m if m == SSE_MAGIC_V1 => Some("S4E1"),
1297        m if m == SSE_MAGIC_V2 => Some("S4E2"),
1298        m if m == SSE_MAGIC_V3 => Some("S4E3"),
1299        m if m == SSE_MAGIC_V4 => Some("S4E4"),
1300        // v0.8 #52: chunked SSE-S4. service.rs's GET handler
1301        // dispatches "S4E5" / "S4E6" → `decrypt_chunked_stream`
1302        // for true streaming GET; the sync `decrypt(...)` also
1303        // accepts both (back-compat — buffered concat).
1304        m if m == SSE_MAGIC_V5 => Some("S4E5"),
1305        // v0.8.1 #57: same dispatch as S4E5 — wider salt only.
1306        m if m == SSE_MAGIC_V6 => Some("S4E6"),
1307        _ => None,
1308    }
1309}
1310
1311pub type SharedSseKey = Arc<SseKey>;
1312
1313// ===========================================================================
1314// v0.8 #52 (S4E5, read-only) + v0.8.1 #57 (S4E6, current emit) —
1315// chunked variant of S4E2 for streaming GET
1316// ===========================================================================
1317//
1318// ## S4E5 wire format (v0.8 #52, **read-only as of v0.8.1 #57**)
1319//
1320// ```text
1321// magic         4B    "S4E5"
1322// algo          1B    0x01 (AES-256-GCM)
1323// key_id        2B    BE — keyring slot the active key was at PUT time
1324// reserved      1B    0x00
1325// chunk_size    4B    BE — plaintext bytes per chunk (final chunk may be smaller)
1326// chunk_count   4B    BE — total chunks (always >= 1; empty plaintext = 1 zero-byte chunk)
1327// salt          4B    random per-PUT, mixed into every nonce
1328// [chunk_count] × {
1329//   tag         16B   AES-GCM auth tag for this chunk
1330//   ciphertext  N B   chunk_size bytes (final chunk: 0..=chunk_size bytes)
1331// }
1332// ```
1333//
1334// Fixed header = 20 bytes ([`S4E5_HEADER_BYTES`]).
1335//
1336// ## S4E6 wire format (v0.8.1 #57, current PUT emit)
1337//
1338// ```text
1339// magic         4B    "S4E6"
1340// algo          1B    0x01 (AES-256-GCM)
1341// key_id        2B    BE
1342// reserved      1B    0x00
1343// chunk_size    4B    BE
1344// chunk_count   4B    BE
1345// salt          8B    random per-PUT  ← 4B → 8B widened
1346// [chunk_count] × { tag 16B, ciphertext N B }
1347// ```
1348//
1349// Fixed header = 24 bytes ([`S4E6_HEADER_BYTES`]). Chunk array
1350// layout is byte-identical to S4E5; only the header (salt 4 → 8)
1351// and the nonce/AAD derivation differ.
1352//
1353// ## Per-chunk overhead (both S4E5 and S4E6)
1354//
1355// 16 bytes — just the AES-GCM auth tag. AES-GCM is CTR-mode, so
1356// `ciphertext.len() == plaintext.len()`. Total overhead for an
1357// N-byte plaintext at chunk size C: `header + ceil(N/C) * 16`.
1358//
1359// ## S4E5 nonce / AAD (read-only)
1360//
1361// ```text
1362// nonce_v5[0..4]  = b"E5\x00\x00"
1363// nonce_v5[4..8]  = salt (4 B)
1364// nonce_v5[8..12] = chunk_index BE (u32)
1365//
1366// aad_v5 = b"S4E5" || algo (1) || chunk_index BE (4) || total BE (4)
1367//        || key_id BE (2) || salt (4)
1368// ```
1369//
1370// Birthday-collision threshold on the 4-byte salt: ~50% at ~65,536
1371// distinct PUTs under the same key — the security regression that
1372// motivated #57.
1373//
1374// ## S4E6 nonce / AAD (current emit)
1375//
1376// ```text
1377// nonce_v6[0]     = b'E'                   (1 B fixed prefix)
1378// nonce_v6[1..9]  = salt (8 B)             (per-PUT random from OsRng)
1379// nonce_v6[9..12] = chunk_index BE (u24)   (3 B → max 16_777_215 chunks)
1380//
1381// aad_v6 = b"S4E6" || algo (1) || chunk_index BE (4) || total BE (4)
1382//        || key_id BE (2) || salt (8)
1383// ```
1384//
1385// Wider salt: birthday collision ~50% at ~2^32 = ~4.3 billion
1386// PUTs/key — four orders of magnitude over S4E5.
1387//
1388// chunk_index narrows from 32-bit to 24-bit, capping `chunk_count`
1389// at `2^24 - 1 = 16_777_215`. At the default `--sse-chunk-size
1390// 1048576` (1 MiB) that's ~16 PiB per object — three orders of
1391// magnitude over S3's 5 GiB single-object cap. Smaller chunk sizes
1392// need to be sized carefully: e.g. `--sse-chunk-size 64` on a
1393// > 1 GiB object would exceed the cap (1 GiB / 64 B = 16M+1
1394// chunks); such configurations surface
1395// [`SseError::ChunkCountTooLarge`] at PUT time rather than
1396// silently truncating.
1397//
1398// AAD on both variants includes the chunk index + total so chunk
1399// reordering or dropping fails the per-chunk tag, plus key_id +
1400// salt so header tampering also fails auth.
1401
1402/// Fixed header size of an S4E5 frame, before any chunks. `magic 4 +
1403/// algo 1 + key_id 2 + reserved 1 + chunk_size 4 + chunk_count 4 +
1404/// salt 4` = 20 bytes.
1405pub const S4E5_HEADER_BYTES: usize = 4 + 1 + 2 + 1 + 4 + 4 + 4; // = 20
1406
1407/// Per-chunk overhead inside an S4E5 / S4E6 frame: just the AES-GCM
1408/// auth tag. `ciphertext.len() == plaintext.len()` (CTR mode), so a
1409/// chunk of N plaintext bytes costs N + 16 on disk.
1410pub const S4E5_PER_CHUNK_OVERHEAD: usize = TAG_LEN; // = 16
1411
1412/// v0.8.1 #57: fixed header size of an S4E6 frame. Same layout as
1413/// S4E5 except the per-PUT salt widens 4 → 8 bytes: `magic 4 + algo
1414/// 1 + key_id 2 + reserved 1 + chunk_size 4 + chunk_count 4 + salt
1415/// 8` = 24 bytes.
1416pub const S4E6_HEADER_BYTES: usize = 4 + 1 + 2 + 1 + 4 + 4 + 8; // = 24
1417
1418/// v0.8.1 #57: per-chunk overhead for S4E6. Identical to S4E5
1419/// (same AES-GCM tag size). Re-exported as a distinct const so call
1420/// sites that compute on-disk size for S4E6 specifically can spell
1421/// the magic clearly in their arithmetic.
1422pub const S4E6_PER_CHUNK_OVERHEAD: usize = TAG_LEN; // = 16
1423
1424/// v0.8.1 #57: maximum `chunk_count` that fits in the S4E6 nonce's
1425/// 24-bit chunk_index field. At 1 MiB chunks this is ~16 PiB per
1426/// object — three orders of magnitude over S3's 5 GiB single-object
1427/// cap, so it's not a practical limit at the default chunk size.
1428pub const S4E6_MAX_CHUNK_COUNT: u32 = (1u32 << 24) - 1; // 16_777_215
1429
1430/// 4-byte fixed prefix of every S4E5 nonce. Distinct from the bytes
1431/// a random S4E1/E2 nonce could plausibly start with so debugging
1432/// dumps can immediately tell "this is a chunked nonce" from the
1433/// first 4 bytes.
1434const S4E5_NONCE_TAG: [u8; 4] = [b'E', b'5', 0, 0];
1435
1436/// 1-byte fixed prefix of every S4E6 nonce. Trades 3 of S4E5's 4
1437/// "tag" bytes for 4 extra salt bytes (4 → 8) and 0 of the chunk
1438/// index bytes (24-bit instead of 32-bit). The remaining `b'E'`
1439/// keeps debug dumps recognizable as "chunked SSE-S4 nonce".
1440const S4E6_NONCE_PREFIX: u8 = b'E';
1441
1442/// Variant tag for the chunked-frame helpers. Selects the nonce +
1443/// AAD derivation (and incidentally the salt width). The
1444/// chunk-array layout is byte-identical for both — only the header
1445/// size and the nonce/AAD derivation differ.
1446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1447enum ChunkedVariant {
1448    V5,
1449    V6,
1450}
1451
1452impl ChunkedVariant {
1453    fn header_bytes(self) -> usize {
1454        match self {
1455            ChunkedVariant::V5 => S4E5_HEADER_BYTES,
1456            ChunkedVariant::V6 => S4E6_HEADER_BYTES,
1457        }
1458    }
1459}
1460
1461/// Build the per-chunk AAD for an S4E5 chunk. Includes magic + algo
1462/// plus the structural chunk_index/total_chunks (so chunk reordering
1463/// fails auth) plus key_id + salt (so header tampering — flipping
1464/// key_id or salt — also fails auth).
1465fn aad_v5(
1466    chunk_index: u32,
1467    total_chunks: u32,
1468    key_id: u16,
1469    salt: &[u8; 4],
1470) -> [u8; 4 + 1 + 4 + 4 + 2 + 4] {
1471    let mut aad = [0u8; 4 + 1 + 4 + 4 + 2 + 4]; // = 19
1472    aad[..4].copy_from_slice(SSE_MAGIC_V5);
1473    aad[4] = ALGO_AES_256_GCM;
1474    aad[5..9].copy_from_slice(&chunk_index.to_be_bytes());
1475    aad[9..13].copy_from_slice(&total_chunks.to_be_bytes());
1476    aad[13..15].copy_from_slice(&key_id.to_be_bytes());
1477    aad[15..19].copy_from_slice(salt);
1478    aad
1479}
1480
1481/// v0.8.1 #57: per-chunk AAD for S4E6. Same structural fields as
1482/// [`aad_v5`] (magic + algo + chunk_index + total + key_id + salt)
1483/// but with the wider 8-byte salt and the new `b"S4E6"` magic, so
1484/// an attacker can't strip the version tag and replay an S4E5
1485/// nonce/tag against an S4E6 frame.
1486fn aad_v6(
1487    chunk_index: u32,
1488    total_chunks: u32,
1489    key_id: u16,
1490    salt: &[u8; 8],
1491) -> [u8; 4 + 1 + 4 + 4 + 2 + 8] {
1492    let mut aad = [0u8; 4 + 1 + 4 + 4 + 2 + 8]; // = 23
1493    aad[..4].copy_from_slice(SSE_MAGIC_V6);
1494    aad[4] = ALGO_AES_256_GCM;
1495    aad[5..9].copy_from_slice(&chunk_index.to_be_bytes());
1496    aad[9..13].copy_from_slice(&total_chunks.to_be_bytes());
1497    aad[13..15].copy_from_slice(&key_id.to_be_bytes());
1498    aad[15..23].copy_from_slice(salt);
1499    aad
1500}
1501
1502/// Derive the 12-byte AES-GCM nonce for chunk `chunk_index` from the
1503/// per-PUT `salt`. Pure function; no RNG state — the same `(salt,
1504/// chunk_index)` always yields the same nonce, which is the whole
1505/// point: GET reads `salt` from the header and walks the chunks
1506/// without storing 12 bytes of nonce per chunk.
1507fn nonce_v5(salt: &[u8; 4], chunk_index: u32) -> [u8; NONCE_LEN] {
1508    let mut n = [0u8; NONCE_LEN];
1509    n[..4].copy_from_slice(&S4E5_NONCE_TAG);
1510    n[4..8].copy_from_slice(salt);
1511    n[8..12].copy_from_slice(&chunk_index.to_be_bytes());
1512    n
1513}
1514
1515/// v0.8.1 #57: derive the 12-byte AES-GCM nonce for an S4E6 chunk:
1516/// `b'E'(1) || salt(8) || chunk_index_BE_u24(3)`. The 24-bit
1517/// chunk_index caps `chunk_count` at 16,777,215 — see
1518/// [`S4E6_MAX_CHUNK_COUNT`]. The pre-encrypt path enforces this cap
1519/// and surfaces [`SseError::ChunkCountTooLarge`], so this function
1520/// only ever sees `chunk_index <= 0xFF_FFFF` (the leading byte of
1521/// the BE u32 is dropped).
1522fn nonce_v6(salt: &[u8; 8], chunk_index: u32) -> [u8; NONCE_LEN] {
1523    debug_assert!(
1524        chunk_index <= S4E6_MAX_CHUNK_COUNT,
1525        "S4E6 chunk_index {chunk_index} exceeds 24-bit cap (caller MUST validate)",
1526    );
1527    let mut n = [0u8; NONCE_LEN];
1528    n[0] = S4E6_NONCE_PREFIX;
1529    n[1..9].copy_from_slice(salt);
1530    let be = chunk_index.to_be_bytes(); // [b3, b2, b1, b0] of u32
1531    // Take the low 3 bytes (b2, b1, b0) — the high byte is 0 by the
1532    // S4E6_MAX_CHUNK_COUNT cap above.
1533    n[9..12].copy_from_slice(&be[1..4]);
1534    n
1535}
1536
1537/// v0.8 #52 / v0.8.1 #57: encrypt `plaintext` under `keyring`'s
1538/// active key, sliced into independently-sealed AES-GCM chunks of
1539/// `chunk_size` plaintext bytes each. Returns the on-the-wire
1540/// **S4E6** frame (v0.8.1 #57 widened the per-PUT salt 4 B → 8 B;
1541/// the S4E5 emit path was retired but the [`decrypt`] /
1542/// [`decrypt_chunked_stream`] paths still read S4E5 objects for
1543/// back-compat).
1544///
1545/// Errors:
1546/// - [`SseError::ChunkSizeInvalid`] if `chunk_size == 0`.
1547/// - [`SseError::ChunkCountTooLarge`] if
1548///   `ceil(plaintext.len() / chunk_size) > 16_777_215` (the S4E6
1549///   24-bit chunk_index cap; pick a larger `--sse-chunk-size`).
1550///
1551/// Empty plaintext is permitted and produces a frame with
1552/// `chunk_count = 1, ciphertext_len = 0` (one all-tag chunk). That
1553/// keeps the GET chunk-walk loop simpler — it never has to
1554/// special-case zero chunks.
1555///
1556/// `chunk_size` is the *plaintext* bytes per chunk; the on-disk
1557/// ciphertext per chunk is the same number (AES-GCM is CTR-mode),
1558/// plus the 16-byte tag prepended.
1559pub fn encrypt_v2_chunked(
1560    plaintext: &[u8],
1561    keyring: &SseKeyring,
1562    chunk_size: usize,
1563) -> Result<Bytes, SseError> {
1564    if chunk_size == 0 {
1565        return Err(SseError::ChunkSizeInvalid);
1566    }
1567    let (key_id, key) = keyring.active();
1568    let cipher = Aes256Gcm::new(key.as_aes_key());
1569    let mut salt = [0u8; 8];
1570    rand::rngs::OsRng.fill_bytes(&mut salt);
1571
1572    // Always emit at least one chunk (so an empty plaintext still
1573    // has a well-defined header → chunk_count >= 1 invariant).
1574    let chunk_count_usize = if plaintext.is_empty() {
1575        1
1576    } else {
1577        plaintext.len().div_ceil(chunk_size)
1578    };
1579    // Saturating-cast to u32 so we report ChunkCountTooLarge cleanly
1580    // for inputs that would overflow u32 too (would need a > 16 EiB
1581    // plaintext at chunk_size = 1 — astronomical, but defensive).
1582    let chunk_count: u32 = u32::try_from(chunk_count_usize).unwrap_or(u32::MAX);
1583    if chunk_count > S4E6_MAX_CHUNK_COUNT {
1584        return Err(SseError::ChunkCountTooLarge {
1585            got: chunk_count,
1586            max: S4E6_MAX_CHUNK_COUNT,
1587        });
1588    }
1589
1590    let mut out = Vec::with_capacity(
1591        S4E6_HEADER_BYTES + plaintext.len() + (chunk_count as usize * S4E6_PER_CHUNK_OVERHEAD),
1592    );
1593    out.extend_from_slice(SSE_MAGIC_V6);
1594    out.push(ALGO_AES_256_GCM);
1595    out.extend_from_slice(&key_id.to_be_bytes());
1596    out.push(0u8); // reserved
1597    out.extend_from_slice(&(chunk_size as u32).to_be_bytes());
1598    out.extend_from_slice(&chunk_count.to_be_bytes());
1599    out.extend_from_slice(&salt);
1600
1601    for i in 0..chunk_count {
1602        let off = (i as usize).saturating_mul(chunk_size);
1603        let end = off.saturating_add(chunk_size).min(plaintext.len());
1604        let chunk_pt: &[u8] = if off >= plaintext.len() {
1605            // Empty-plaintext / past-end (only the single-chunk
1606            // empty-plaintext case lands here).
1607            &[]
1608        } else {
1609            &plaintext[off..end]
1610        };
1611        let nonce_bytes = nonce_v6(&salt, i);
1612        let nonce = Nonce::from_slice(&nonce_bytes);
1613        let aad = aad_v6(i, chunk_count, key_id, &salt);
1614        let ct_with_tag = cipher
1615            .encrypt(
1616                nonce,
1617                Payload {
1618                    msg: chunk_pt,
1619                    aad: &aad,
1620                },
1621            )
1622            .expect("aes-gcm encrypt cannot fail with a 32-byte key");
1623        debug_assert!(ct_with_tag.len() >= TAG_LEN);
1624        let split = ct_with_tag.len() - TAG_LEN;
1625        let (ct, tag) = ct_with_tag.split_at(split);
1626        out.extend_from_slice(tag);
1627        out.extend_from_slice(ct);
1628        crate::metrics::record_sse_streaming_chunk("encrypt");
1629    }
1630    Ok(Bytes::from(out))
1631}
1632
1633/// Salt material for a chunked frame — branches on variant so the
1634/// shared chunk-walking loop can carry both 4-byte (S4E5) and
1635/// 8-byte (S4E6) salts without an extra heap alloc.
1636#[derive(Debug, Clone, Copy)]
1637enum ChunkedSalt {
1638    V5([u8; 4]),
1639    V6([u8; 8]),
1640}
1641
1642/// Parsed S4E5 / S4E6 header — fixed-layout fields. Used by the
1643/// buffered ([`decrypt_chunked_buffered`]) and streaming
1644/// ([`decrypt_chunked_stream`]) paths to share frame validation
1645/// across both variants.
1646#[derive(Debug, Clone, Copy)]
1647struct ChunkedHeader {
1648    /// Used only by tests today (asserts on which frame variant
1649    /// parsed); in production the variant is implicit in
1650    /// `salt`'s ChunkedSalt arm. Kept as a field rather than
1651    /// re-deriving from `salt` so the parser writes one source of
1652    /// truth.
1653    #[allow(dead_code)]
1654    variant: ChunkedVariant,
1655    key_id: u16,
1656    chunk_size: u32,
1657    chunk_count: u32,
1658    salt: ChunkedSalt,
1659    /// Byte offset where the chunk array starts (always
1660    /// `variant.header_bytes()`; carried in the struct so call sites
1661    /// don't have to re-derive it from the variant tag).
1662    chunks_offset: usize,
1663}
1664
1665/// Parsed view of an S4E6 frame's fixed header. Public mirror of
1666/// the S4E4 parser — useful for admin tools or future inspectors
1667/// that want to enumerate object → key_id bindings without
1668/// re-implementing the offset math. The `salt` borrow keeps
1669/// allocations to zero (the slice points back into the input
1670/// buffer).
1671#[derive(Debug, Clone, Copy)]
1672pub struct S4E6Header<'a> {
1673    pub key_id: u16,
1674    pub chunk_size: u32,
1675    pub chunk_count: u32,
1676    pub salt: &'a [u8; 8],
1677}
1678
1679/// Pure byte-shuffle parser for an S4E6 fixed header (24 bytes). No
1680/// crypto, no keyring lookup. Errors on truncation, wrong magic,
1681/// unsupported algo, or zero `chunk_size` / `chunk_count`.
1682pub fn parse_s4e6_header(blob: &[u8]) -> Result<S4E6Header<'_>, SseError> {
1683    if blob.len() < S4E6_HEADER_BYTES {
1684        return Err(SseError::ChunkFrameTruncated { what: "header" });
1685    }
1686    if &blob[..4] != SSE_MAGIC_V6 {
1687        let mut got = [0u8; 4];
1688        got.copy_from_slice(&blob[..4]);
1689        return Err(SseError::BadMagic { got });
1690    }
1691    let algo = blob[4];
1692    if algo != ALGO_AES_256_GCM {
1693        return Err(SseError::UnsupportedAlgo { tag: algo });
1694    }
1695    let key_id = u16::from_be_bytes([blob[5], blob[6]]);
1696    // blob[7] = reserved (0; authenticated as 0 via AAD).
1697    let chunk_size = u32::from_be_bytes([blob[8], blob[9], blob[10], blob[11]]);
1698    let chunk_count = u32::from_be_bytes([blob[12], blob[13], blob[14], blob[15]]);
1699    if chunk_size == 0 {
1700        return Err(SseError::ChunkSizeInvalid);
1701    }
1702    if chunk_count == 0 {
1703        return Err(SseError::ChunkFrameTruncated {
1704            what: "chunk_count == 0",
1705        });
1706    }
1707    if chunk_count > S4E6_MAX_CHUNK_COUNT {
1708        return Err(SseError::ChunkCountTooLarge {
1709            got: chunk_count,
1710            max: S4E6_MAX_CHUNK_COUNT,
1711        });
1712    }
1713    let salt: &[u8; 8] = (&blob[16..24]).try_into().expect("8B salt slice");
1714    Ok(S4E6Header {
1715        key_id,
1716        chunk_size,
1717        chunk_count,
1718        salt,
1719    })
1720}
1721
1722fn parse_chunked_header(body: &[u8], max_body_bytes: usize) -> Result<ChunkedHeader, SseError> {
1723    if body.len() < 4 {
1724        return Err(SseError::ChunkFrameTruncated { what: "magic" });
1725    }
1726    let magic = &body[..4];
1727    let variant = if magic == SSE_MAGIC_V5 {
1728        ChunkedVariant::V5
1729    } else if magic == SSE_MAGIC_V6 {
1730        ChunkedVariant::V6
1731    } else {
1732        let mut got = [0u8; 4];
1733        got.copy_from_slice(magic);
1734        return Err(SseError::BadMagic { got });
1735    };
1736    let header_bytes = variant.header_bytes();
1737    if body.len() < header_bytes {
1738        return Err(SseError::ChunkFrameTruncated { what: "header" });
1739    }
1740    let algo = body[4];
1741    if algo != ALGO_AES_256_GCM {
1742        return Err(SseError::UnsupportedAlgo { tag: algo });
1743    }
1744    let key_id = u16::from_be_bytes([body[5], body[6]]);
1745    // body[7] = reserved (must be 0; authenticated as 0 via AAD).
1746    let chunk_size = u32::from_be_bytes([body[8], body[9], body[10], body[11]]);
1747    let chunk_count = u32::from_be_bytes([body[12], body[13], body[14], body[15]]);
1748    if chunk_size == 0 {
1749        return Err(SseError::ChunkSizeInvalid);
1750    }
1751    if chunk_count == 0 {
1752        return Err(SseError::ChunkFrameTruncated {
1753            what: "chunk_count == 0",
1754        });
1755    }
1756    let salt = match variant {
1757        ChunkedVariant::V5 => {
1758            let mut s = [0u8; 4];
1759            s.copy_from_slice(&body[16..20]);
1760            ChunkedSalt::V5(s)
1761        }
1762        ChunkedVariant::V6 => {
1763            // v0.8.1 #57 sanity check: the encoder enforces this cap,
1764            // but a tampered / malicious frame could declare a huge
1765            // chunk_count that would loop the walker 16M+ times if
1766            // we trusted it. Reject early.
1767            if chunk_count > S4E6_MAX_CHUNK_COUNT {
1768                return Err(SseError::ChunkCountTooLarge {
1769                    got: chunk_count,
1770                    max: S4E6_MAX_CHUNK_COUNT,
1771                });
1772            }
1773            let mut s = [0u8; 8];
1774            s.copy_from_slice(&body[16..24]);
1775            ChunkedSalt::V6(s)
1776        }
1777    };
1778
1779    // v0.8.2 #64 — DoS guard. Pre-validate the declared
1780    // (chunk_size × chunk_count) plaintext size in u64 before *any*
1781    // downstream allocation or chunk-walk loop. Rejects:
1782    //   1. arithmetic that would overflow u64 (e.g. chunk_size =
1783    //      u32::MAX, chunk_count = u32::MAX → 2^64 ≫ u64::MAX),
1784    //   2. a body that is *larger* than the maximum a header with
1785    //      these declared sizes could plausibly carry (i.e. trailing
1786    //      bytes after the final chunk → corruption / append attack),
1787    //      and
1788    //   3. a plaintext size larger than the caller's configured
1789    //      `max_body_bytes` (default 5 GiB).
1790    //
1791    // We do NOT require the body to be `>= max_total` here — the
1792    // final chunk may legitimately hold fewer than `chunk_size`
1793    // plaintext bytes (encoder uses
1794    // `chunk_count = ceil(plaintext.len() / chunk_size)`, so the last
1795    // chunk holds the remainder). The walker handles that
1796    // variable-length tail; truncation of the final chunk's tag /
1797    // ciphertext surfaces as `ChunkFrameTruncated` once the walker
1798    // gets there. The pre-alloc guard's job is the *upper-bound*
1799    // checks: kill obviously-impossible shapes before we
1800    // `Vec::with_capacity(chunk_size × chunk_count)`.
1801    //
1802    // All paths return concrete error variants; nothing panics on
1803    // adversarial input. The error strings carry only constant
1804    // operator-readable labels (no echoed numbers) so the response is
1805    // not a tampering oracle for the attacker.
1806    let chunk_size_u64 = chunk_size as u64;
1807    let chunk_count_u64 = chunk_count as u64;
1808    let expected_plain_size =
1809        chunk_size_u64
1810            .checked_mul(chunk_count_u64)
1811            .ok_or(SseError::ChunkFrameTooLarge {
1812                details: "chunk_size * chunk_count overflows u64",
1813            })?;
1814    let per_chunk_overhead = S4E5_PER_CHUNK_OVERHEAD as u64; // = 16 (AES-GCM tag)
1815    let total_tag_overhead =
1816        per_chunk_overhead
1817            .checked_mul(chunk_count_u64)
1818            .ok_or(SseError::ChunkFrameTooLarge {
1819                details: "tag_len * chunk_count overflows u64",
1820            })?;
1821    let max_total = expected_plain_size
1822        .checked_add(total_tag_overhead)
1823        .and_then(|t| t.checked_add(header_bytes as u64))
1824        .ok_or(SseError::ChunkFrameTooLarge {
1825            details: "header + plaintext + tag overhead overflows u64",
1826        })?;
1827    // Body cannot be *larger* than what the declared geometry could
1828    // legitimately produce. Any extra bytes past the final chunk are
1829    // either trailing junk (corruption) or an append attack — kill
1830    // them here so the walker doesn't even start verifying chunks
1831    // for an obviously-malformed body. (The walker's own end-of-loop
1832    // `cursor != body.len()` check also catches this; the pre-alloc
1833    // guard saves the chunk-walk worth of AES-GCM verifies in the
1834    // hostile case.)
1835    if (body.len() as u64) > max_total {
1836        return Err(SseError::ChunkFrameTruncated {
1837            what: "trailing bytes past declared chunk geometry",
1838        });
1839    }
1840    // The actual DoS guard: cap on declared plaintext. If the header
1841    // claims more than the operator's configured single-object
1842    // ceiling, refuse before the buffered path's
1843    // `Vec::with_capacity(chunk_size * chunk_count)` runs.
1844    if expected_plain_size > max_body_bytes as u64 {
1845        return Err(SseError::ChunkFrameTooLarge {
1846            details: "declared plaintext exceeds gateway max_body_bytes",
1847        });
1848    }
1849
1850    Ok(ChunkedHeader {
1851        variant,
1852        key_id,
1853        chunk_size,
1854        chunk_count,
1855        salt,
1856        chunks_offset: header_bytes,
1857    })
1858}
1859
1860/// Decrypt one chunk under either the S4E5 or S4E6 derivation. Used
1861/// by both the buffered and streaming paths so AAD / nonce
1862/// derivation lives in exactly one place.
1863fn decrypt_chunked_chunk(
1864    cipher: &Aes256Gcm,
1865    chunk_index: u32,
1866    chunk_count: u32,
1867    key_id: u16,
1868    salt: &ChunkedSalt,
1869    tag: &[u8; TAG_LEN],
1870    ct: &[u8],
1871) -> Result<Bytes, SseError> {
1872    let nonce_bytes = match salt {
1873        ChunkedSalt::V5(s) => nonce_v5(s, chunk_index),
1874        ChunkedSalt::V6(s) => nonce_v6(s, chunk_index),
1875    };
1876    let nonce = Nonce::from_slice(&nonce_bytes);
1877    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
1878    ct_with_tag.extend_from_slice(ct);
1879    ct_with_tag.extend_from_slice(tag);
1880    let result = match salt {
1881        ChunkedSalt::V5(s) => {
1882            let aad = aad_v5(chunk_index, chunk_count, key_id, s);
1883            cipher.decrypt(
1884                nonce,
1885                Payload {
1886                    msg: &ct_with_tag,
1887                    aad: &aad,
1888                },
1889            )
1890        }
1891        ChunkedSalt::V6(s) => {
1892            let aad = aad_v6(chunk_index, chunk_count, key_id, s);
1893            cipher.decrypt(
1894                nonce,
1895                Payload {
1896                    msg: &ct_with_tag,
1897                    aad: &aad,
1898                },
1899            )
1900        }
1901    };
1902    result
1903        .map(Bytes::from)
1904        .map_err(|_| SseError::ChunkAuthFailed { chunk_index })
1905}
1906
1907/// Walk an S4E5 / S4E6 body chunk-by-chunk, calling `emit` on each
1908/// successfully-verified plaintext chunk. Returns immediately on the
1909/// first chunk that fails auth or is truncated. Shared core between
1910/// the buffered ([`decrypt_chunked_buffered`]) and streaming
1911/// ([`decrypt_chunked_stream`]) paths.
1912fn walk_chunked<F: FnMut(Bytes) -> Result<(), SseError>>(
1913    body: &[u8],
1914    keyring: &SseKeyring,
1915    max_body_bytes: usize,
1916    mut emit: F,
1917) -> Result<(), SseError> {
1918    let hdr = parse_chunked_header(body, max_body_bytes)?;
1919    let key = keyring
1920        .get(hdr.key_id)
1921        .ok_or(SseError::KeyNotInKeyring { id: hdr.key_id })?;
1922    let cipher = Aes256Gcm::new(key.as_aes_key());
1923
1924    let mut cursor = hdr.chunks_offset;
1925    let chunk_size = hdr.chunk_size as usize;
1926    for i in 0..hdr.chunk_count {
1927        if cursor + TAG_LEN > body.len() {
1928            return Err(SseError::ChunkFrameTruncated { what: "chunk tag" });
1929        }
1930        let tag_off = cursor;
1931        let ct_off = tag_off + TAG_LEN;
1932        let is_last = i + 1 == hdr.chunk_count;
1933        let ct_len = if is_last {
1934            if ct_off > body.len() {
1935                return Err(SseError::ChunkFrameTruncated {
1936                    what: "final chunk ciphertext",
1937                });
1938            }
1939            let remaining = body.len() - ct_off;
1940            if remaining > chunk_size {
1941                return Err(SseError::ChunkFrameTruncated {
1942                    what: "trailing bytes after final chunk",
1943                });
1944            }
1945            remaining
1946        } else {
1947            chunk_size
1948        };
1949        let ct_end = ct_off + ct_len;
1950        if ct_end > body.len() {
1951            return Err(SseError::ChunkFrameTruncated {
1952                what: "chunk ciphertext",
1953            });
1954        }
1955        let mut tag = [0u8; TAG_LEN];
1956        tag.copy_from_slice(&body[tag_off..ct_off]);
1957        let ct = &body[ct_off..ct_end];
1958        let plain =
1959            decrypt_chunked_chunk(&cipher, i, hdr.chunk_count, hdr.key_id, &hdr.salt, &tag, ct)?;
1960        crate::metrics::record_sse_streaming_chunk("decrypt");
1961        emit(plain)?;
1962        cursor = ct_end;
1963    }
1964    if cursor != body.len() {
1965        return Err(SseError::ChunkFrameTruncated {
1966            what: "trailing bytes after declared chunk_count",
1967        });
1968    }
1969    Ok(())
1970}
1971
1972/// Sync back-compat path: decrypt every chunk and concatenate into
1973/// a single `Bytes`. Memory peak = full plaintext (defeats the
1974/// point of S4E5/S4E6 streaming, but useful for callers that already
1975/// need the whole body — e.g. server-side restream-rewrite paths or
1976/// unit tests). Accepts both S4E5 (legacy) and S4E6 (current) bodies.
1977///
1978/// `max_body_bytes` (v0.8.2 #64) caps the declared plaintext size in
1979/// the header **before any allocation**. A malicious / corrupted
1980/// frame that claims a multi-PB plaintext is rejected as
1981/// [`SseError::ChunkFrameTooLarge`] with no `Vec::with_capacity` call
1982/// behind it. Callers without a bespoke cap should use
1983/// [`decrypt_chunked_buffered_default`] (5 GiB cap).
1984pub fn decrypt_chunked_buffered(
1985    body: &[u8],
1986    keyring: &SseKeyring,
1987    max_body_bytes: usize,
1988) -> Result<Bytes, SseError> {
1989    let hdr = parse_chunked_header(body, max_body_bytes)?;
1990    // Header is validated above: chunk_size * chunk_count fits u64
1991    // and is ≤ max_body_bytes (≤ 5 GiB on this gateway), so the
1992    // `as usize` casts cannot truncate on a 64-bit host. The
1993    // `Vec::with_capacity` is therefore bounded by the same cap that
1994    // protects the rest of the gateway from oversized PUTs / GETs.
1995    let mut out = Vec::with_capacity(hdr.chunk_size as usize * hdr.chunk_count as usize);
1996    walk_chunked(body, keyring, max_body_bytes, |chunk| {
1997        out.extend_from_slice(&chunk);
1998        Ok(())
1999    })?;
2000    Ok(Bytes::from(out))
2001}
2002
2003/// Back-compat wrapper around [`decrypt_chunked_buffered`] that uses
2004/// [`DEFAULT_MAX_BODY_BYTES`] (5 GiB — AWS S3's single-object PUT
2005/// ceiling). Provided so the existing `decrypt(body, keyring)`
2006/// dispatcher and call sites that don't have a bespoke cap can keep
2007/// their signature unchanged after the v0.8.2 #64 pre-allocation
2008/// guard landed.
2009pub fn decrypt_chunked_buffered_default(
2010    body: &[u8],
2011    keyring: &SseKeyring,
2012) -> Result<Bytes, SseError> {
2013    decrypt_chunked_buffered(body, keyring, DEFAULT_MAX_BODY_BYTES)
2014}
2015
2016/// v0.9 #106: decrypt a contiguous **chunk range** out of an S4E6
2017/// body that was fetched via a Range GET (i.e. the caller did NOT
2018/// fetch the body's fixed header — only the byte slice covering
2019/// chunks `[chunk_idx_start..=chunk_idx_last_inclusive]`). Used by
2020/// the encryption-aware sidecar fast-path: the sidecar carries the
2021/// header fields (`salt`, `key_id`, `chunk_count`) in plaintext so
2022/// the GET path can derive per-chunk nonce/AAD without paying for an
2023/// extra HEAD/GET to grab the encrypted body's 24-byte fixed header.
2024///
2025/// `body_slice` must contain **exactly** the on-disk bytes
2026/// `[chunk_idx_start..=chunk_idx_last_inclusive]` — i.e. for each
2027/// chunk in that range, its 16-byte AES-GCM tag followed by its
2028/// ciphertext. The non-final chunks carry `chunk_size` ciphertext
2029/// bytes (+ 16-byte tag); the final chunk (only when
2030/// `chunk_idx_last_inclusive + 1 == chunk_count`) carries
2031/// `enc_plaintext_len - chunk_idx_last_inclusive * chunk_size`
2032/// ciphertext bytes (+ tag).
2033///
2034/// Returns the concatenated plaintext of the decrypted chunks (so the
2035/// caller can slice off the user-requested byte range within this
2036/// concatenation). Any chunk that fails AES-GCM auth surfaces as
2037/// [`SseError::ChunkAuthFailed`] with the failing index.
2038///
2039/// **Note**: this helper trusts the caller to pass `chunk_count` /
2040/// `chunk_size` / `salt` / `key_id` exactly as they appeared in the
2041/// encrypted body's header at PUT time — those values are part of
2042/// the per-chunk AAD, so any mismatch surfaces as a tag-verify
2043/// failure on the first chunk (fail-closed by design). The sidecar
2044/// reader is responsible for refusing the fast-path on any
2045/// inconsistency (binding mismatch, etc.) before invoking this.
2046#[allow(clippy::too_many_arguments)]
2047pub fn decrypt_s4e6_chunk_range(
2048    body_slice: &[u8],
2049    keyring: &SseKeyring,
2050    chunk_size: u32,
2051    chunk_count: u32,
2052    key_id: u16,
2053    salt: &[u8; 8],
2054    enc_plaintext_len: u64,
2055    chunk_idx_start: u32,
2056    chunk_idx_last_inclusive: u32,
2057) -> Result<Bytes, SseError> {
2058    if chunk_size == 0 || chunk_count == 0 {
2059        return Err(SseError::ChunkSizeInvalid);
2060    }
2061    if chunk_idx_last_inclusive >= chunk_count || chunk_idx_start > chunk_idx_last_inclusive {
2062        return Err(SseError::ChunkFrameTruncated {
2063            what: "chunk index out of range",
2064        });
2065    }
2066    let key = keyring
2067        .get(key_id)
2068        .ok_or(SseError::KeyNotInKeyring { id: key_id })?;
2069    let cipher = Aes256Gcm::new(key.as_aes_key());
2070    let salt_carrier = ChunkedSalt::V6(*salt);
2071    let chunk_size_usize = chunk_size as usize;
2072
2073    // Pre-compute expected slice length so a forged/short body fails
2074    // closed with a typed error instead of silently truncating the
2075    // final chunk's ciphertext.
2076    let expected_len: u64 = (chunk_idx_start..=chunk_idx_last_inclusive)
2077        .map(|i| {
2078            let pt_len = if i + 1 < chunk_count {
2079                chunk_size as u64
2080            } else {
2081                let prior = (i as u64) * (chunk_size as u64);
2082                enc_plaintext_len.saturating_sub(prior)
2083            };
2084            pt_len + TAG_LEN as u64
2085        })
2086        .sum();
2087    if (body_slice.len() as u64) != expected_len {
2088        return Err(SseError::ChunkFrameTruncated {
2089            what: "chunk-range body length mismatch",
2090        });
2091    }
2092
2093    // Worst-case plaintext = full stride × range, but the final chunk
2094    // may be smaller; use `expected_len - (range_len * TAG_LEN)`.
2095    let range_len = (chunk_idx_last_inclusive - chunk_idx_start + 1) as usize;
2096    let mut out = Vec::with_capacity((expected_len as usize).saturating_sub(range_len * TAG_LEN));
2097
2098    let mut cursor: usize = 0;
2099    for i in chunk_idx_start..=chunk_idx_last_inclusive {
2100        let pt_len = if i + 1 < chunk_count {
2101            chunk_size_usize
2102        } else {
2103            let prior = (i as usize) * chunk_size_usize;
2104            (enc_plaintext_len as usize).saturating_sub(prior)
2105        };
2106        let tag_off = cursor;
2107        let ct_off = tag_off + TAG_LEN;
2108        let ct_end = ct_off + pt_len;
2109        // Buffer-length check is redundant with `expected_len ==
2110        // body_slice.len()` above, but it costs nothing and keeps
2111        // the slice indexing trivially in-bounds for future refactors.
2112        if ct_end > body_slice.len() {
2113            return Err(SseError::ChunkFrameTruncated {
2114                what: "chunk ciphertext",
2115            });
2116        }
2117        let mut tag = [0u8; TAG_LEN];
2118        tag.copy_from_slice(&body_slice[tag_off..ct_off]);
2119        let ct = &body_slice[ct_off..ct_end];
2120        let plain =
2121            decrypt_chunked_chunk(&cipher, i, chunk_count, key_id, &salt_carrier, &tag, ct)?;
2122        crate::metrics::record_sse_streaming_chunk("decrypt");
2123        out.extend_from_slice(&plain);
2124        cursor = ct_end;
2125    }
2126    Ok(Bytes::from(out))
2127}
2128
2129/// v0.8 #52 (S4E5) / v0.8.1 #57 (S4E6): stream-decrypt API for
2130/// chunked SSE-S4 bodies. Returns a [`futures::Stream`] that yields
2131/// one `Bytes` per chunk in order. Each chunk is emitted only after
2132/// AES-GCM tag verify succeeds, so the client never sees plaintext
2133/// bytes that haven't been authenticated. A failing chunk yields
2134/// its [`SseError::ChunkAuthFailed`] (with the chunk index) and ends
2135/// the stream — earlier chunks may already have left the gateway,
2136/// which matches the standard streaming-AEAD trade-off (operators
2137/// MUST alert on the audit log + metric, not rely on connection
2138/// close to guarantee atomicity).
2139///
2140/// Accepts either S4E5 (v0.8 #52, legacy) or S4E6 (v0.8.1 #57,
2141/// current) magic. Non-chunked magic surfaces as
2142/// [`SseError::BadMagic`] / [`SseError::ChunkFrameTruncated`] on
2143/// the first poll — the stream is "fail-fast" rather than "fall
2144/// through to S4E2 buffered decrypt", because the caller has
2145/// already dispatched on [`peek_magic`] by the time it hands a body
2146/// to this function.
2147///
2148/// `body` is owned by the returned stream so the caller doesn't
2149/// need to keep the bytes alive separately. The returned stream is
2150/// `'static` — the `keyring` borrow is consumed up front to extract
2151/// the per-frame key and build the AES cipher (which owns its key
2152/// material), so the caller's keyring may be dropped immediately.
2153pub fn decrypt_chunked_stream(
2154    body: bytes::Bytes,
2155    keyring: &SseKeyring,
2156) -> impl futures::Stream<Item = Result<Bytes, SseError>> + 'static {
2157    use futures::stream::{self, StreamExt};
2158
2159    // Cheap pre-validation: parse the header + look up the key
2160    // once, up front, so a malformed frame surfaces on the first
2161    // poll instead of being deferred behind the first-chunk loop.
2162    // The `keyring` borrow ends here — we extract the AES key into
2163    // the owned `Aes256Gcm` cipher, then store that in the stream
2164    // state.
2165    let prelude = (|| {
2166        // v0.8.2 #64: streaming path is naturally memory-bounded
2167        // (one chunk-worth of plaintext at a time), so we don't cap
2168        // the *declared* total here — the per-chunk loop already
2169        // bounds memory by `chunk_size`. The truncation /
2170        // arithmetic-overflow checks inside `parse_chunked_header`
2171        // still run regardless of `max_body_bytes`, which is what
2172        // protects the streaming path from the same DoS class as the
2173        // buffered path (the overflow / declared-vs-actual length
2174        // mismatches are independent of the gateway cap).
2175        let hdr = parse_chunked_header(&body, usize::MAX)?;
2176        let key = keyring
2177            .get(hdr.key_id)
2178            .ok_or(SseError::KeyNotInKeyring { id: hdr.key_id })?;
2179        let cipher = Aes256Gcm::new(key.as_aes_key());
2180        Ok::<_, SseError>((hdr, cipher))
2181    })();
2182
2183    match prelude {
2184        Err(e) => stream::iter(std::iter::once(Err(e))).left_stream(),
2185        Ok((hdr, cipher)) => {
2186            let chunks_offset = hdr.chunks_offset;
2187            let state = ChunkedDecryptState {
2188                body,
2189                cipher,
2190                hdr,
2191                cursor: chunks_offset,
2192                next_index: 0,
2193            };
2194            stream::try_unfold(state, decrypt_next_chunk).right_stream()
2195        }
2196    }
2197}
2198
2199/// Per-stream state for [`decrypt_chunked_stream`]. Holds the owned
2200/// `body` (so the stream stays self-contained), the prepared
2201/// cipher, and the cursor position into the chunk array.
2202struct ChunkedDecryptState {
2203    body: bytes::Bytes,
2204    cipher: Aes256Gcm,
2205    hdr: ChunkedHeader,
2206    cursor: usize,
2207    next_index: u32,
2208}
2209
2210async fn decrypt_next_chunk(
2211    mut state: ChunkedDecryptState,
2212) -> Result<Option<(Bytes, ChunkedDecryptState)>, SseError> {
2213    if state.next_index >= state.hdr.chunk_count {
2214        // Final boundary check — anything past the declared
2215        // chunk_count would be a truncation / append attack.
2216        if state.cursor != state.body.len() {
2217            return Err(SseError::ChunkFrameTruncated {
2218                what: "trailing bytes after declared chunk_count",
2219            });
2220        }
2221        return Ok(None);
2222    }
2223    let i = state.next_index;
2224    let chunk_size = state.hdr.chunk_size as usize;
2225    if state.cursor + TAG_LEN > state.body.len() {
2226        return Err(SseError::ChunkFrameTruncated { what: "chunk tag" });
2227    }
2228    let tag_off = state.cursor;
2229    let ct_off = tag_off + TAG_LEN;
2230    let is_last = i + 1 == state.hdr.chunk_count;
2231    let ct_len = if is_last {
2232        if ct_off > state.body.len() {
2233            return Err(SseError::ChunkFrameTruncated {
2234                what: "final chunk ciphertext",
2235            });
2236        }
2237        let remaining = state.body.len() - ct_off;
2238        if remaining > chunk_size {
2239            return Err(SseError::ChunkFrameTruncated {
2240                what: "trailing bytes after final chunk",
2241            });
2242        }
2243        remaining
2244    } else {
2245        chunk_size
2246    };
2247    let ct_end = ct_off + ct_len;
2248    if ct_end > state.body.len() {
2249        return Err(SseError::ChunkFrameTruncated {
2250            what: "chunk ciphertext",
2251        });
2252    }
2253    let mut tag = [0u8; TAG_LEN];
2254    tag.copy_from_slice(&state.body[tag_off..ct_off]);
2255    let ct = &state.body[ct_off..ct_end];
2256    let plain = decrypt_chunked_chunk(
2257        &state.cipher,
2258        i,
2259        state.hdr.chunk_count,
2260        state.hdr.key_id,
2261        &state.hdr.salt,
2262        &tag,
2263        ct,
2264    )?;
2265    crate::metrics::record_sse_streaming_chunk("decrypt");
2266    state.cursor = ct_end;
2267    state.next_index += 1;
2268    Ok(Some((plain, state)))
2269}
2270
2271/// v0.8.1 #57: build an S4E5 frame. Identical body structure to the
2272/// pre-#57 `encrypt_v2_chunked` (4-byte salt + S4E5 magic + V5
2273/// nonce/AAD); kept around purely so the back-compat-read tests can
2274/// synthesize a "v0.8.0 vintage" blob and prove the new gateway
2275/// still decrypts it.
2276#[cfg(test)]
2277fn encrypt_v2_chunked_s4e5_for_test(
2278    plaintext: &[u8],
2279    keyring: &SseKeyring,
2280    chunk_size: usize,
2281) -> Result<Bytes, SseError> {
2282    if chunk_size == 0 {
2283        return Err(SseError::ChunkSizeInvalid);
2284    }
2285    let (key_id, key) = keyring.active();
2286    let cipher = Aes256Gcm::new(key.as_aes_key());
2287    let mut salt = [0u8; 4];
2288    rand::rngs::OsRng.fill_bytes(&mut salt);
2289
2290    let chunk_count: u32 = if plaintext.is_empty() {
2291        1
2292    } else {
2293        plaintext
2294            .len()
2295            .div_ceil(chunk_size)
2296            .try_into()
2297            .expect("chunk_count overflows u32")
2298    };
2299
2300    let mut out = Vec::with_capacity(
2301        S4E5_HEADER_BYTES + plaintext.len() + (chunk_count as usize * S4E5_PER_CHUNK_OVERHEAD),
2302    );
2303    out.extend_from_slice(SSE_MAGIC_V5);
2304    out.push(ALGO_AES_256_GCM);
2305    out.extend_from_slice(&key_id.to_be_bytes());
2306    out.push(0u8);
2307    out.extend_from_slice(&(chunk_size as u32).to_be_bytes());
2308    out.extend_from_slice(&chunk_count.to_be_bytes());
2309    out.extend_from_slice(&salt);
2310
2311    for i in 0..chunk_count {
2312        let off = (i as usize).saturating_mul(chunk_size);
2313        let end = off.saturating_add(chunk_size).min(plaintext.len());
2314        let chunk_pt: &[u8] = if off >= plaintext.len() {
2315            &[]
2316        } else {
2317            &plaintext[off..end]
2318        };
2319        let nonce_bytes = nonce_v5(&salt, i);
2320        let nonce = Nonce::from_slice(&nonce_bytes);
2321        let aad = aad_v5(i, chunk_count, key_id, &salt);
2322        let ct_with_tag = cipher
2323            .encrypt(
2324                nonce,
2325                Payload {
2326                    msg: chunk_pt,
2327                    aad: &aad,
2328                },
2329            )
2330            .expect("aes-gcm encrypt cannot fail with a 32-byte key");
2331        let split = ct_with_tag.len() - TAG_LEN;
2332        let (ct, tag) = ct_with_tag.split_at(split);
2333        out.extend_from_slice(tag);
2334        out.extend_from_slice(ct);
2335    }
2336    Ok(Bytes::from(out))
2337}
2338
2339#[cfg(test)]
2340mod tests {
2341    use super::*;
2342
2343    /// v0.9 #106: pin the cfg-gate on `DEFAULT_MAX_BODY_BYTES` so a future
2344    /// refactor can't silently drop the 32-bit arm (the literal
2345    /// `5 * 1024 * 1024 * 1024` const-overflows `usize` on a 32-bit target
2346    /// and breaks `cargo check --target i686-unknown-linux-gnu`). The
2347    /// 64-bit arm asserts the AWS 5 GiB ceiling; the 32-bit arm asserts
2348    /// `isize::MAX as usize` (≈ 2 GiB on 32-bit), which is the largest
2349    /// allocation any Rust buffer can hold — Codex P2 caught that the
2350    /// initial cut used `usize::MAX`, which would have let the gateway
2351    /// guard accept sizes that subsequently panic inside
2352    /// `Vec::with_capacity`.
2353    #[cfg(target_pointer_width = "64")]
2354    #[test]
2355    fn default_max_body_bytes_is_aws_5gib_on_64bit() {
2356        assert_eq!(DEFAULT_MAX_BODY_BYTES, 5 * 1024 * 1024 * 1024);
2357    }
2358
2359    #[cfg(target_pointer_width = "32")]
2360    #[test]
2361    fn default_max_body_bytes_clamps_to_isize_max_on_32bit() {
2362        // Rust caps any single allocation (Vec, Bytes, Box<[u8]>, etc.)
2363        // at `isize::MAX` bytes (not `usize::MAX`); pre-allocation guards
2364        // must respect the same ceiling or they'll let oversized inputs
2365        // OOM-panic downstream. Pin the choice here.
2366        assert_eq!(DEFAULT_MAX_BODY_BYTES, isize::MAX as usize);
2367        // sanity: 32-bit cap ≈ 2 GiB, well under the AWS 5 GiB single-PUT
2368        // ceiling, so the runtime cap stays valid (just lower).
2369        assert!((DEFAULT_MAX_BODY_BYTES as u64) < 5 * 1024 * 1024 * 1024);
2370    }
2371
2372    fn key32(seed: u8) -> Arc<SseKey> {
2373        Arc::new(SseKey::from_bytes(&[seed; 32]).unwrap())
2374    }
2375
2376    fn keyring_single(seed: u8) -> SseKeyring {
2377        SseKeyring::new(1, key32(seed))
2378    }
2379
2380    #[test]
2381    fn roundtrip_basic_v1() {
2382        // back-compat single-key API — still works.
2383        let k = SseKey::from_bytes(&[7u8; 32]).unwrap();
2384        let pt = b"the quick brown fox jumps over the lazy dog";
2385        let ct = encrypt(&k, pt);
2386        assert!(looks_encrypted(&ct));
2387        assert_eq!(&ct[..4], SSE_MAGIC_V1);
2388        assert_eq!(ct[4], ALGO_AES_256_GCM);
2389        assert_eq!(ct.len(), SSE_HEADER_BYTES + pt.len());
2390        // decrypt via single-key keyring
2391        let kr = SseKeyring::new(1, Arc::new(k));
2392        let pt2 = decrypt(&ct, &kr).unwrap();
2393        assert_eq!(pt2.as_ref(), pt);
2394    }
2395
2396    #[test]
2397    fn s4e2_roundtrip_active_key() {
2398        let kr = keyring_single(7);
2399        let pt = b"S4E2 active-key roundtrip";
2400        let ct = encrypt_v2(pt, &kr);
2401        assert_eq!(&ct[..4], SSE_MAGIC_V2);
2402        assert_eq!(ct[4], ALGO_AES_256_GCM);
2403        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1, "key_id BE");
2404        assert_eq!(ct[7], 0, "reserved byte");
2405        assert_eq!(ct.len(), SSE_HEADER_BYTES + pt.len());
2406        assert!(looks_encrypted(&ct));
2407        let pt2 = decrypt(&ct, &kr).unwrap();
2408        assert_eq!(pt2.as_ref(), pt);
2409    }
2410
2411    #[test]
2412    fn decrypt_s4e1_via_active_only_keyring() {
2413        // v0.4 wrote S4E1 with key K; v0.5 keyring has K as the only
2414        // (active) key. Decrypt must succeed.
2415        let k_arc = key32(11);
2416        let legacy_ct = encrypt(&k_arc, b"v0.4 vintage object");
2417        assert_eq!(&legacy_ct[..4], SSE_MAGIC_V1);
2418        let kr = SseKeyring::new(1, Arc::clone(&k_arc));
2419        let plain = decrypt(&legacy_ct, &kr).unwrap();
2420        assert_eq!(plain.as_ref(), b"v0.4 vintage object");
2421    }
2422
2423    #[test]
2424    fn decrypt_s4e2_under_old_key_after_rotation() {
2425        // Rotation flow: object was encrypted under key id=1 when 1
2426        // was active. Operator rotates to active=2 and keeps 1 in the
2427        // keyring. The S4E2 body must still decrypt.
2428        let k1 = key32(1);
2429        let k2 = key32(2);
2430        let mut kr_old = SseKeyring::new(1, Arc::clone(&k1));
2431        let ct = encrypt_v2(b"old-rotation object", &kr_old);
2432        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1);
2433
2434        // After rotation: active=2, but key 1 still in ring.
2435        kr_old.add(2, Arc::clone(&k2));
2436        let mut kr_new = SseKeyring::new(2, Arc::clone(&k2));
2437        kr_new.add(1, Arc::clone(&k1));
2438
2439        let plain = decrypt(&ct, &kr_new).unwrap();
2440        assert_eq!(plain.as_ref(), b"old-rotation object");
2441
2442        // And new PUTs go to id 2 (active).
2443        let new_ct = encrypt_v2(b"new-rotation object", &kr_new);
2444        assert_eq!(u16::from_be_bytes([new_ct[5], new_ct[6]]), 2);
2445        let plain_new = decrypt(&new_ct, &kr_new).unwrap();
2446        assert_eq!(plain_new.as_ref(), b"new-rotation object");
2447    }
2448
2449    #[test]
2450    fn s4e2_unknown_key_id_errors() {
2451        let kr = keyring_single(3); // only id=1 present
2452        let kr_other = SseKeyring::new(99, key32(3));
2453        let ct = encrypt_v2(b"x", &kr_other); // body claims key_id=99
2454        let err = decrypt(&ct, &kr).unwrap_err();
2455        assert!(
2456            matches!(err, SseError::KeyNotInKeyring { id: 99 }),
2457            "got {err:?}"
2458        );
2459    }
2460
2461    #[test]
2462    fn s4e2_tampered_key_id_fails_auth() {
2463        let kr = SseKeyring::new(1, key32(4));
2464        let mut kr_with_2 = kr.clone();
2465        kr_with_2.add(2, key32(5)); // a real but wrong key under id=2
2466        let mut ct = encrypt_v2(b"do not flip my key id", &kr).to_vec();
2467        // Flip key_id from 1 → 2 in the header. The keyring HAS a key
2468        // for 2, so the lookup succeeds — but AAD authenticates the
2469        // original key_id, so AES-GCM tag verification must fail.
2470        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1);
2471        ct[5] = 0;
2472        ct[6] = 2;
2473        let err = decrypt(&ct, &kr_with_2).unwrap_err();
2474        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
2475    }
2476
2477    #[test]
2478    fn s4e2_tampered_ciphertext_fails() {
2479        let kr = SseKeyring::new(7, key32(9));
2480        let mut ct = encrypt_v2(b"secret message v2", &kr).to_vec();
2481        let last = ct.len() - 1;
2482        ct[last] ^= 0x01;
2483        let err = decrypt(&ct, &kr).unwrap_err();
2484        assert!(matches!(err, SseError::DecryptFailed));
2485    }
2486
2487    #[test]
2488    fn s4e2_tampered_algo_byte_fails() {
2489        let kr = SseKeyring::new(1, key32(2));
2490        let mut ct = encrypt_v2(b"hi", &kr).to_vec();
2491        ct[4] = 99;
2492        let err = decrypt(&ct, &kr).unwrap_err();
2493        assert!(matches!(err, SseError::UnsupportedAlgo { tag: 99 }));
2494    }
2495
2496    #[test]
2497    fn wrong_key_fails_v1_via_keyring() {
2498        // S4E1 written under key K1; keyring has only K2 → DecryptFailed.
2499        let k1 = SseKey::from_bytes(&[1u8; 32]).unwrap();
2500        let ct = encrypt(&k1, b"secret");
2501        let kr_wrong = SseKeyring::new(1, Arc::new(SseKey::from_bytes(&[2u8; 32]).unwrap()));
2502        let err = decrypt(&ct, &kr_wrong).unwrap_err();
2503        assert!(matches!(err, SseError::DecryptFailed));
2504    }
2505
2506    #[test]
2507    fn rejects_short_body() {
2508        let kr = SseKeyring::new(1, key32(1));
2509        let err = decrypt(b"short", &kr).unwrap_err();
2510        assert!(matches!(err, SseError::TooShort { got: 5 }));
2511    }
2512
2513    #[test]
2514    fn looks_encrypted_passthrough_returns_false() {
2515        // S4F2 frame magic, NOT S4E1 / S4E2 — must not be confused.
2516        let f2 = b"S4F2\x01\x00\x00\x00........................................";
2517        assert!(!looks_encrypted(f2));
2518        assert!(!looks_encrypted(b""));
2519    }
2520
2521    #[test]
2522    fn looks_encrypted_detects_both_v1_and_v2() {
2523        let kr = SseKeyring::new(1, key32(8));
2524        let v1 = encrypt(&SseKey::from_bytes(&[8u8; 32]).unwrap(), b"x");
2525        let v2 = encrypt_v2(b"x", &kr);
2526        assert!(looks_encrypted(&v1));
2527        assert!(looks_encrypted(&v2));
2528    }
2529
2530    #[test]
2531    fn key_from_hex_string() {
2532        let bad =
2533            SseKey::from_bytes(b"0102030405060708090a0b0c0d0e0f10111213141516171819202122232425")
2534                .unwrap_err();
2535        assert!(matches!(bad, SseError::BadKeyLength { .. }));
2536        let good = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
2537        let _ = SseKey::from_bytes(good).expect("64-char hex should parse");
2538    }
2539
2540    #[test]
2541    fn encrypt_v2_uses_random_nonce() {
2542        let kr = SseKeyring::new(1, key32(3));
2543        let pt = b"deterministic input";
2544        let a = encrypt_v2(pt, &kr);
2545        let b = encrypt_v2(pt, &kr);
2546        assert_ne!(a, b, "nonce must be random per-call");
2547    }
2548
2549    #[test]
2550    fn keyring_active_and_get() {
2551        let k1 = key32(1);
2552        let k2 = key32(2);
2553        let mut kr = SseKeyring::new(1, Arc::clone(&k1));
2554        kr.add(2, Arc::clone(&k2));
2555        let (id, active) = kr.active();
2556        assert_eq!(id, 1);
2557        assert_eq!(active.bytes, [1u8; 32]);
2558        assert!(kr.get(2).is_some());
2559        assert!(kr.get(3).is_none());
2560    }
2561
2562    // -----------------------------------------------------------------
2563    // v0.5 #27 — SSE-C (customer-provided key, S4E3 frame) tests
2564    // -----------------------------------------------------------------
2565
2566    use base64::Engine as _;
2567
2568    fn cust_key(seed: u8) -> CustomerKeyMaterial {
2569        let key = [seed; KEY_LEN];
2570        let key_md5 = compute_key_md5(&key);
2571        CustomerKeyMaterial { key, key_md5 }
2572    }
2573
2574    #[test]
2575    fn s4e3_roundtrip_happy_path() {
2576        let m = cust_key(42);
2577        let pt = b"top-secret SSE-C payload";
2578        let ct = encrypt_with_source(
2579            pt,
2580            SseSource::CustomerKey {
2581                key: &m.key,
2582                key_md5: &m.key_md5,
2583            },
2584        );
2585        // Frame inspection.
2586        assert_eq!(&ct[..4], SSE_MAGIC_V3);
2587        assert_eq!(ct[4], ALGO_AES_256_GCM);
2588        assert_eq!(&ct[5..5 + KEY_MD5_LEN], &m.key_md5);
2589        assert_eq!(ct.len(), SSE_HEADER_BYTES_V3 + pt.len());
2590        assert!(looks_encrypted(&ct));
2591        // Decrypt round-trip.
2592        let plain = decrypt(
2593            &ct,
2594            SseSource::CustomerKey {
2595                key: &m.key,
2596                key_md5: &m.key_md5,
2597            },
2598        )
2599        .unwrap();
2600        assert_eq!(plain.as_ref(), pt);
2601        // And via the From impl on &CustomerKeyMaterial.
2602        let plain2 = decrypt(&ct, &m).unwrap();
2603        assert_eq!(plain2.as_ref(), pt);
2604    }
2605
2606    #[test]
2607    fn s4e3_wrong_key_yields_wrong_customer_key_error() {
2608        let m = cust_key(1);
2609        let other = cust_key(2);
2610        let ct = encrypt_with_source(b"payload", (&m).into());
2611        let err = decrypt(
2612            &ct,
2613            SseSource::CustomerKey {
2614                key: &other.key,
2615                key_md5: &other.key_md5,
2616            },
2617        )
2618        .unwrap_err();
2619        assert!(matches!(err, SseError::WrongCustomerKey), "got {err:?}");
2620    }
2621
2622    #[test]
2623    fn s4e3_tampered_stored_md5_is_caught() {
2624        // Attacker rewrites the stored MD5 to match a key they know.
2625        // Even though the supplied (attacker) key matches the rewritten
2626        // MD5, AES-GCM authenticates the ORIGINAL md5 via AAD, so the
2627        // tag check fails. Surface: WrongCustomerKey if the supplied
2628        // md5 != stored md5 (this test), or DecryptFailed if attacker
2629        // also rewrites their supplied md5 to match.
2630        let m = cust_key(7);
2631        let mut ct = encrypt_with_source(b"victim payload", (&m).into()).to_vec();
2632        // Flip a byte in the stored fingerprint.
2633        ct[5] ^= 0x55;
2634        // Client supplies the original (unmodified) key + md5.
2635        let err = decrypt(
2636            &ct,
2637            SseSource::CustomerKey {
2638                key: &m.key,
2639                key_md5: &m.key_md5,
2640            },
2641        )
2642        .unwrap_err();
2643        assert!(matches!(err, SseError::WrongCustomerKey), "got {err:?}");
2644    }
2645
2646    #[test]
2647    fn s4e3_tampered_md5_with_matching_supplied_md5_fails_aead() {
2648        // Both stored md5 AND supplied md5 are flipped to the same bogus
2649        // value. The fingerprint check passes (they match) but AAD
2650        // authenticates the *original* md5, so AES-GCM fails.
2651        let m = cust_key(3);
2652        let mut ct = encrypt_with_source(b"x", (&m).into()).to_vec();
2653        ct[5] ^= 0xFF;
2654        let mut bogus_md5 = m.key_md5;
2655        bogus_md5[0] ^= 0xFF;
2656        let err = decrypt(
2657            &ct,
2658            SseSource::CustomerKey {
2659                key: &m.key,
2660                key_md5: &bogus_md5,
2661            },
2662        )
2663        .unwrap_err();
2664        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
2665    }
2666
2667    #[test]
2668    fn s4e3_tampered_ciphertext_fails_aead() {
2669        let m = cust_key(8);
2670        let mut ct = encrypt_with_source(b"sealed message", (&m).into()).to_vec();
2671        let last = ct.len() - 1;
2672        ct[last] ^= 0x01;
2673        let err = decrypt(&ct, &m).unwrap_err();
2674        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
2675    }
2676
2677    #[test]
2678    fn s4e3_tampered_algo_byte_rejected() {
2679        let m = cust_key(9);
2680        let mut ct = encrypt_with_source(b"x", (&m).into()).to_vec();
2681        ct[4] = 99;
2682        let err = decrypt(&ct, &m).unwrap_err();
2683        assert!(matches!(err, SseError::UnsupportedAlgo { tag: 99 }));
2684    }
2685
2686    #[test]
2687    fn s4e3_uses_random_nonce() {
2688        let m = cust_key(10);
2689        let a = encrypt_with_source(b"deterministic input", (&m).into());
2690        let b = encrypt_with_source(b"deterministic input", (&m).into());
2691        assert_ne!(a, b, "nonce must be random per-call");
2692    }
2693
2694    #[test]
2695    fn parse_customer_key_headers_happy_path() {
2696        let key = [11u8; KEY_LEN];
2697        let md5 = compute_key_md5(&key);
2698        let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
2699        let md5_b64 = base64::engine::general_purpose::STANDARD.encode(md5);
2700        let m = parse_customer_key_headers("AES256", &key_b64, &md5_b64).unwrap();
2701        assert_eq!(m.key, key);
2702        assert_eq!(m.key_md5, md5);
2703    }
2704
2705    #[test]
2706    fn parse_customer_key_headers_rejects_wrong_algorithm() {
2707        let key = [1u8; KEY_LEN];
2708        let md5 = compute_key_md5(&key);
2709        let kb = base64::engine::general_purpose::STANDARD.encode(key);
2710        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
2711        let err = parse_customer_key_headers("AES128", &kb, &mb).unwrap_err();
2712        assert!(
2713            matches!(err, SseError::CustomerKeyAlgorithmUnsupported { ref algo } if algo == "AES128"),
2714            "got {err:?}"
2715        );
2716        // Lowercase variant still rejected (AWS S3 accepts only "AES256").
2717        let err2 = parse_customer_key_headers("aes256", &kb, &mb).unwrap_err();
2718        assert!(
2719            matches!(err2, SseError::CustomerKeyAlgorithmUnsupported { .. }),
2720            "got {err2:?}"
2721        );
2722    }
2723
2724    #[test]
2725    fn parse_customer_key_headers_rejects_wrong_key_length() {
2726        let short_key = vec![5u8; 16]; // half-length AES key
2727        let md5 = compute_key_md5(&short_key);
2728        let kb = base64::engine::general_purpose::STANDARD.encode(&short_key);
2729        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
2730        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
2731        assert!(
2732            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("key length")),
2733            "got {err:?}"
2734        );
2735    }
2736
2737    #[test]
2738    fn parse_customer_key_headers_rejects_wrong_md5_length() {
2739        let key = [3u8; KEY_LEN];
2740        let kb = base64::engine::general_purpose::STANDARD.encode(key);
2741        // Truncated MD5 (15 bytes instead of 16).
2742        let bad_md5 = vec![0u8; 15];
2743        let mb = base64::engine::general_purpose::STANDARD.encode(bad_md5);
2744        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
2745        assert!(
2746            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("MD5 length")),
2747            "got {err:?}"
2748        );
2749    }
2750
2751    #[test]
2752    fn parse_customer_key_headers_rejects_md5_mismatch() {
2753        let key = [4u8; KEY_LEN];
2754        let other = [5u8; KEY_LEN];
2755        let kb = base64::engine::general_purpose::STANDARD.encode(key);
2756        let wrong_md5 = compute_key_md5(&other);
2757        let mb = base64::engine::general_purpose::STANDARD.encode(wrong_md5);
2758        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
2759        assert!(
2760            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("MD5 does not match")),
2761            "got {err:?}"
2762        );
2763    }
2764
2765    #[test]
2766    fn parse_customer_key_headers_rejects_bad_base64() {
2767        let valid_key = [0u8; KEY_LEN];
2768        let md5 = compute_key_md5(&valid_key);
2769        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
2770        let err = parse_customer_key_headers("AES256", "!!!not-base64!!!", &mb).unwrap_err();
2771        assert!(
2772            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("base64")),
2773            "got {err:?}"
2774        );
2775        // Bad MD5 base64.
2776        let kb = base64::engine::general_purpose::STANDARD.encode(valid_key);
2777        let err2 = parse_customer_key_headers("AES256", &kb, "??not-base64??").unwrap_err();
2778        assert!(
2779            matches!(err2, SseError::InvalidCustomerKey { reason } if reason.contains("base64")),
2780            "got {err2:?}"
2781        );
2782    }
2783
2784    #[test]
2785    fn parse_customer_key_headers_trims_whitespace() {
2786        // S3 SDKs sometimes pad headers with trailing newlines.
2787        let key = [12u8; KEY_LEN];
2788        let md5 = compute_key_md5(&key);
2789        let kb = format!(
2790            "  {}\n",
2791            base64::engine::general_purpose::STANDARD.encode(key)
2792        );
2793        let mb = format!(
2794            "\t{}  ",
2795            base64::engine::general_purpose::STANDARD.encode(md5)
2796        );
2797        let m = parse_customer_key_headers("AES256", &kb, &mb).unwrap();
2798        assert_eq!(m.key, key);
2799    }
2800
2801    // -----------------------------------------------------------------
2802    // Back-compat + cross-source mixing
2803    // -----------------------------------------------------------------
2804
2805    #[test]
2806    fn back_compat_decrypt_s4e1_with_keyring_source() {
2807        let k = key32(33);
2808        let legacy_ct = encrypt(&k, b"v0.4 vintage object");
2809        let kr = SseKeyring::new(1, Arc::clone(&k));
2810        // Both call styles must work — `&kr` (back-compat) and
2811        // `SseSource::Keyring(&kr)` (explicit).
2812        let plain = decrypt(&legacy_ct, &kr).unwrap();
2813        assert_eq!(plain.as_ref(), b"v0.4 vintage object");
2814        let plain2 = decrypt(&legacy_ct, SseSource::Keyring(&kr)).unwrap();
2815        assert_eq!(plain2.as_ref(), b"v0.4 vintage object");
2816    }
2817
2818    #[test]
2819    fn back_compat_decrypt_s4e2_with_keyring_source() {
2820        let kr = keyring_single(34);
2821        let ct = encrypt_v2(b"v0.5 #29 object", &kr);
2822        let plain = decrypt(&ct, &kr).unwrap();
2823        assert_eq!(plain.as_ref(), b"v0.5 #29 object");
2824        // encrypt_with_source(Keyring) should produce the same wire
2825        // format (S4E2).
2826        let ct2 = encrypt_with_source(b"v0.5 #29 object", SseSource::Keyring(&kr));
2827        assert_eq!(&ct2[..4], SSE_MAGIC_V2);
2828        let plain2 = decrypt(&ct2, &kr).unwrap();
2829        assert_eq!(plain2.as_ref(), b"v0.5 #29 object");
2830    }
2831
2832    #[test]
2833    fn s4e2_blob_with_customer_key_source_is_rejected() {
2834        // An object stored with SSE-S4 (S4E2) but a client sending
2835        // SSE-C headers on the GET — this is a misuse, surface as
2836        // CustomerKeyUnexpected so service.rs can return 400.
2837        let kr = keyring_single(50);
2838        let ct = encrypt_v2(b"server-managed object", &kr);
2839        let m = cust_key(99);
2840        let err = decrypt(
2841            &ct,
2842            SseSource::CustomerKey {
2843                key: &m.key,
2844                key_md5: &m.key_md5,
2845            },
2846        )
2847        .unwrap_err();
2848        assert!(
2849            matches!(err, SseError::CustomerKeyUnexpected),
2850            "got {err:?}"
2851        );
2852    }
2853
2854    #[test]
2855    fn s4e3_blob_with_keyring_source_is_rejected() {
2856        // Inverse: object is SSE-C (S4E3) but client forgot to send
2857        // SSE-C headers. Service.rs should map this to 400.
2858        let m = cust_key(60);
2859        let ct = encrypt_with_source(b"customer-key object", (&m).into());
2860        let kr = keyring_single(60);
2861        let err = decrypt(&ct, &kr).unwrap_err();
2862        assert!(matches!(err, SseError::CustomerKeyRequired), "got {err:?}");
2863    }
2864
2865    #[test]
2866    fn looks_encrypted_detects_s4e3() {
2867        let m = cust_key(13);
2868        let ct = encrypt_with_source(b"x", (&m).into());
2869        assert!(looks_encrypted(&ct));
2870    }
2871
2872    #[test]
2873    fn s4e3_rejects_short_body() {
2874        // 36 bytes passes the looks_encrypted gate but is shorter than
2875        // S4E3's 49-byte header.
2876        let mut short = Vec::new();
2877        short.extend_from_slice(SSE_MAGIC_V3);
2878        short.push(ALGO_AES_256_GCM);
2879        // Padding to 36 bytes (SSE_HEADER_BYTES) so the outer length
2880        // check passes but the S4E3 inner check fails.
2881        short.extend_from_slice(&[0u8; SSE_HEADER_BYTES - 5]);
2882        assert_eq!(short.len(), SSE_HEADER_BYTES);
2883        let m = cust_key(1);
2884        let err = decrypt(
2885            &short,
2886            SseSource::CustomerKey {
2887                key: &m.key,
2888                key_md5: &m.key_md5,
2889            },
2890        )
2891        .unwrap_err();
2892        assert!(matches!(err, SseError::TooShort { .. }), "got {err:?}");
2893    }
2894
2895    #[test]
2896    fn customer_key_material_debug_redacts_key() {
2897        let m = cust_key(99);
2898        let s = format!("{m:?}");
2899        assert!(s.contains("redacted"));
2900        assert!(!s.contains(&format!("{:?}", m.key.as_slice())));
2901    }
2902
2903    #[test]
2904    fn constant_time_eq_basic() {
2905        assert!(constant_time_eq(b"abc", b"abc"));
2906        assert!(!constant_time_eq(b"abc", b"abd"));
2907        assert!(!constant_time_eq(b"abc", b"abcd"));
2908        assert!(constant_time_eq(b"", b""));
2909    }
2910
2911    #[test]
2912    fn compute_key_md5_known_vector() {
2913        // Empty input MD5 is known: d41d8cd98f00b204e9800998ecf8427e.
2914        let got = compute_key_md5(b"");
2915        let expected_hex = "d41d8cd98f00b204e9800998ecf8427e";
2916        assert_eq!(hex_lower(&got), expected_hex);
2917    }
2918
2919    // -----------------------------------------------------------------
2920    // v0.5 #28 — SSE-KMS envelope (S4E4) tests
2921    // -----------------------------------------------------------------
2922
2923    use crate::kms::{KmsBackend, LocalKms};
2924    use std::collections::HashMap;
2925    use std::path::PathBuf;
2926
2927    fn local_kms_with(key_ids: &[(&str, [u8; 32])]) -> LocalKms {
2928        let mut keks: HashMap<String, [u8; 32]> = HashMap::new();
2929        for (id, k) in key_ids {
2930            keks.insert((*id).to_string(), *k);
2931        }
2932        LocalKms::from_keks(PathBuf::from("/tmp/none"), keks)
2933    }
2934
2935    #[tokio::test]
2936    async fn s4e4_roundtrip_via_local_kms() {
2937        let kms = local_kms_with(&[("alpha", [42u8; 32])]);
2938        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
2939        let mut dek = [0u8; 32];
2940        dek.copy_from_slice(&dek_vec);
2941        let pt = b"SSE-KMS envelope payload across the S4E4 frame";
2942        let ct = encrypt_with_source(
2943            pt,
2944            SseSource::Kms {
2945                dek: &dek,
2946                wrapped: &wrapped,
2947            },
2948        );
2949        // Frame inspection.
2950        assert_eq!(&ct[..4], SSE_MAGIC_V4);
2951        assert_eq!(ct[4], ALGO_AES_256_GCM);
2952        let key_id_len = ct[5] as usize;
2953        assert_eq!(key_id_len, "alpha".len());
2954        assert_eq!(&ct[6..6 + key_id_len], b"alpha");
2955        // peek_magic + looks_encrypted both recognise S4E4.
2956        assert!(looks_encrypted(&ct));
2957        assert_eq!(peek_magic(&ct), Some("S4E4"));
2958        // Async decrypt round-trip.
2959        let plain = decrypt_with_kms(&ct, &kms).await.unwrap();
2960        assert_eq!(plain.as_ref(), pt);
2961    }
2962
2963    #[tokio::test]
2964    async fn s4e4_tampered_key_id_fails_aead() {
2965        let kms = local_kms_with(&[("alpha", [1u8; 32]), ("beta", [2u8; 32])]);
2966        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
2967        let mut dek = [0u8; 32];
2968        dek.copy_from_slice(&dek_vec);
2969        let mut ct = encrypt_with_source(
2970            b"do not redirect",
2971            SseSource::Kms {
2972                dek: &dek,
2973                wrapped: &wrapped,
2974            },
2975        )
2976        .to_vec();
2977        // Flip the key_id from "alpha" to "betaa" by changing the
2978        // first byte of the key_id field. The forged id "bltha" is
2979        // not in the KMS, so unwrap fails with KeyNotFound surfaced
2980        // through KmsBackend(KmsError::KeyNotFound).
2981        let key_id_off = 6;
2982        ct[key_id_off] = b'b';
2983        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
2984        assert!(
2985            matches!(
2986                err,
2987                SseError::KmsBackend(crate::kms::KmsError::UnwrapFailed { .. })
2988                    | SseError::KmsBackend(crate::kms::KmsError::KeyNotFound { .. })
2989            ),
2990            "got {err:?}"
2991        );
2992    }
2993
2994    #[tokio::test]
2995    async fn s4e4_tampered_key_id_to_real_other_id_still_fails() {
2996        // Wrap under "alpha" but rewrite the stored key_id to "beta"
2997        // (which IS in the KMS). KmsBackend will try to unwrap with
2998        // beta's KEK and AAD = "beta", but the wrapped bytes were
2999        // produced with alpha's KEK + AAD = "alpha", so the local
3000        // KMS unwrap fails with UnwrapFailed.
3001        let kms = local_kms_with(&[("alpha", [1u8; 32]), ("beta", [2u8; 32])]);
3002        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
3003        let mut dek = [0u8; 32];
3004        dek.copy_from_slice(&dek_vec);
3005        let mut ct = encrypt_with_source(
3006            b"redirect attempt",
3007            SseSource::Kms {
3008                dek: &dek,
3009                wrapped: &wrapped,
3010            },
3011        )
3012        .to_vec();
3013        // Both "alpha" and "beta" are 5 chars long so the rewrite
3014        // doesn't shift any other field offsets.
3015        let key_id_off = 6;
3016        ct[key_id_off..key_id_off + 5].copy_from_slice(b"beta_");
3017        // Trim back to 4-byte "beta" by also shrinking the length
3018        // prefix would change downstream offsets — instead pad the
3019        // forged id to keep length stable. This mirrors the realistic
3020        // tampering surface (attacker can flip bytes but not change
3021        // the on-disk layout). The KMS now sees key_id "beta_" which
3022        // is unknown → KeyNotFound.
3023        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
3024        assert!(
3025            matches!(
3026                err,
3027                SseError::KmsBackend(crate::kms::KmsError::KeyNotFound { .. })
3028            ),
3029            "got {err:?}"
3030        );
3031    }
3032
3033    #[tokio::test]
3034    async fn s4e4_tampered_wrapped_dek_fails_unwrap() {
3035        let kms = local_kms_with(&[("k", [3u8; 32])]);
3036        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
3037        let mut dek = [0u8; 32];
3038        dek.copy_from_slice(&dek_vec);
3039        let mut ct = encrypt_with_source(
3040            b"target body",
3041            SseSource::Kms {
3042                dek: &dek,
3043                wrapped: &wrapped,
3044            },
3045        )
3046        .to_vec();
3047        // Locate the wrapped_dek_len + wrapped_dek field and flip a
3048        // byte in the middle of the wrapped DEK. AES-GCM auth on the
3049        // wrap fails → KmsBackend(UnwrapFailed).
3050        let key_id_len = ct[5] as usize;
3051        let wrapped_len_off = 6 + key_id_len;
3052        let wrapped_off = wrapped_len_off + 4;
3053        let mid = wrapped_off + (wrapped.ciphertext.len() / 2);
3054        ct[mid] ^= 0xFF;
3055        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
3056        assert!(
3057            matches!(
3058                err,
3059                SseError::KmsBackend(crate::kms::KmsError::UnwrapFailed { .. })
3060            ),
3061            "got {err:?}"
3062        );
3063    }
3064
3065    #[tokio::test]
3066    async fn s4e4_tampered_ciphertext_fails_aead() {
3067        let kms = local_kms_with(&[("k", [4u8; 32])]);
3068        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
3069        let mut dek = [0u8; 32];
3070        dek.copy_from_slice(&dek_vec);
3071        let mut ct = encrypt_with_source(
3072            b"sealed body",
3073            SseSource::Kms {
3074                dek: &dek,
3075                wrapped: &wrapped,
3076            },
3077        )
3078        .to_vec();
3079        let last = ct.len() - 1;
3080        ct[last] ^= 0x01;
3081        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
3082        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
3083    }
3084
3085    #[tokio::test]
3086    async fn s4e4_uses_random_nonce_and_dek_per_put() {
3087        let kms = local_kms_with(&[("k", [5u8; 32])]);
3088        // Two PUTs of the same plaintext under the same KEK must
3089        // produce different ciphertexts (fresh DEK + fresh nonce).
3090        let (dek1_vec, wrapped1) = kms.generate_dek("k").await.unwrap();
3091        let (dek2_vec, wrapped2) = kms.generate_dek("k").await.unwrap();
3092        let mut dek1 = [0u8; 32];
3093        dek1.copy_from_slice(&dek1_vec);
3094        let mut dek2 = [0u8; 32];
3095        dek2.copy_from_slice(&dek2_vec);
3096        let pt = b"deterministic input";
3097        let a = encrypt_with_source(
3098            pt,
3099            SseSource::Kms {
3100                dek: &dek1,
3101                wrapped: &wrapped1,
3102            },
3103        );
3104        let b = encrypt_with_source(
3105            pt,
3106            SseSource::Kms {
3107                dek: &dek2,
3108                wrapped: &wrapped2,
3109            },
3110        );
3111        assert_ne!(a, b);
3112        // Both still decrypt round-trip.
3113        let plain_a = decrypt_with_kms(&a, &kms).await.unwrap();
3114        let plain_b = decrypt_with_kms(&b, &kms).await.unwrap();
3115        assert_eq!(plain_a.as_ref(), pt);
3116        assert_eq!(plain_b.as_ref(), pt);
3117    }
3118
3119    #[tokio::test]
3120    async fn s4e4_sync_decrypt_returns_kms_async_required() {
3121        // The whole point of KmsAsyncRequired: passing an S4E4 body
3122        // to the sync `decrypt` function must surface a distinct
3123        // error so service.rs's GET path notices the bug rather than
3124        // returning a generic "wrong source" 400.
3125        let kms = local_kms_with(&[("k", [6u8; 32])]);
3126        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
3127        let mut dek = [0u8; 32];
3128        dek.copy_from_slice(&dek_vec);
3129        let ct = encrypt_with_source(
3130            b"async only",
3131            SseSource::Kms {
3132                dek: &dek,
3133                wrapped: &wrapped,
3134            },
3135        );
3136        // Try via Keyring source (the default sync path).
3137        let kr = SseKeyring::new(1, key32(0));
3138        let err = decrypt(&ct, &kr).unwrap_err();
3139        assert!(matches!(err, SseError::KmsAsyncRequired), "got {err:?}");
3140    }
3141
3142    #[test]
3143    fn back_compat_s4e1_e2_e3_still_decrypt_via_sync() {
3144        // After adding S4E4, the sync `decrypt` path must still
3145        // handle every legacy frame variant unchanged.
3146        let k = key32(7);
3147        let v1 = encrypt(&k, b"v0.4 vintage");
3148        let kr = SseKeyring::new(1, Arc::clone(&k));
3149        assert_eq!(decrypt(&v1, &kr).unwrap().as_ref(), b"v0.4 vintage");
3150
3151        let v2 = encrypt_v2(b"v0.5 #29 vintage", &kr);
3152        assert_eq!(decrypt(&v2, &kr).unwrap().as_ref(), b"v0.5 #29 vintage");
3153
3154        let m = cust_key(7);
3155        let v3 = encrypt_with_source(b"v0.5 #27 vintage", (&m).into());
3156        assert_eq!(decrypt(&v3, &m).unwrap().as_ref(), b"v0.5 #27 vintage");
3157    }
3158
3159    #[test]
3160    fn peek_magic_distinguishes_all_variants() {
3161        // S4E1 / S4E2 / S4E3 — built from real encrypts so the
3162        // length gate also passes.
3163        let k = key32(9);
3164        let v1 = encrypt(&k, b"x");
3165        assert_eq!(peek_magic(&v1), Some("S4E1"));
3166        let kr = SseKeyring::new(1, Arc::clone(&k));
3167        let v2 = encrypt_v2(b"x", &kr);
3168        assert_eq!(peek_magic(&v2), Some("S4E2"));
3169        let m = cust_key(9);
3170        let v3 = encrypt_with_source(b"x", (&m).into());
3171        assert_eq!(peek_magic(&v3), Some("S4E3"));
3172        // Synthetic S4E4 magic with enough trailing bytes to clear
3173        // the 36-byte length gate. peek_magic does NOT validate the
3174        // S4E4 inner header, just the magic — that's the contract
3175        // (cheap dispatch signal).
3176        let mut v4 = Vec::new();
3177        v4.extend_from_slice(SSE_MAGIC_V4);
3178        v4.extend_from_slice(&[0u8; 40]);
3179        assert_eq!(peek_magic(&v4), Some("S4E4"));
3180        // Unknown magic / too-short input → None.
3181        assert!(peek_magic(b"NOPE").is_none());
3182        assert!(peek_magic(b"short").is_none());
3183        assert!(peek_magic(&[0u8; 100]).is_none());
3184    }
3185
3186    #[tokio::test]
3187    async fn s4e4_truncated_frame_errors_cleanly() {
3188        // Truncate to less than the minimum header. Must surface
3189        // KmsFrameTooShort, not panic, not return BadMagic.
3190        let truncated = b"S4E4\x01\x05hi";
3191        let kms = local_kms_with(&[("k", [1u8; 32])]);
3192        let err = decrypt_with_kms(truncated, &kms).await.unwrap_err();
3193        assert!(
3194            matches!(err, SseError::KmsFrameTooShort { .. }),
3195            "got {err:?}"
3196        );
3197    }
3198
3199    #[tokio::test]
3200    async fn s4e4_oob_key_id_len_errors() {
3201        // Build a body that claims key_id_len = 200 but only has 4
3202        // bytes after the length prefix. parse_s4e4_header must
3203        // refuse with KmsFrameFieldOob, not slice-panic.
3204        let mut body = Vec::new();
3205        body.extend_from_slice(SSE_MAGIC_V4);
3206        body.push(ALGO_AES_256_GCM);
3207        body.push(200u8); // key_id_len
3208        // Remaining bytes < 200; pad to clear the looks_encrypted
3209        // floor (36 bytes) but stay short of the claimed key_id +
3210        // wrapped_dek_len + nonce + tag layout.
3211        body.extend_from_slice(&[0u8; 50]);
3212        let kms = local_kms_with(&[("k", [1u8; 32])]);
3213        let err = decrypt_with_kms(&body, &kms).await.unwrap_err();
3214        assert!(
3215            matches!(err, SseError::KmsFrameFieldOob { .. }),
3216            "got {err:?}"
3217        );
3218    }
3219
3220    #[tokio::test]
3221    async fn s4e4_via_keyring_source_into_sync_decrypt_is_kms_async_required() {
3222        // S4E4 + Keyring source: sync decrypt sees the S4E4 magic
3223        // first and returns KmsAsyncRequired regardless of source —
3224        // the source mismatch never gets a chance to surface, which
3225        // is the right behaviour (caller's bug is "didn't peek
3226        // magic" not "wrong source").
3227        let kms = local_kms_with(&[("k", [9u8; 32])]);
3228        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
3229        let mut dek = [0u8; 32];
3230        dek.copy_from_slice(&dek_vec);
3231        let ct = encrypt_with_source(
3232            b"x",
3233            SseSource::Kms {
3234                dek: &dek,
3235                wrapped: &wrapped,
3236            },
3237        );
3238        let m = cust_key(1);
3239        let err = decrypt(&ct, &m).unwrap_err();
3240        assert!(matches!(err, SseError::KmsAsyncRequired), "got {err:?}");
3241    }
3242
3243    #[tokio::test]
3244    async fn s4e4_looks_encrypted_passthrough_returns_false_for_synthetic() {
3245        // S4F4 (note F not E) must NOT be confused with S4E4.
3246        let mut not_s4e4 = Vec::new();
3247        not_s4e4.extend_from_slice(b"S4F4");
3248        not_s4e4.extend_from_slice(&[0u8; 60]);
3249        assert!(!looks_encrypted(&not_s4e4));
3250        assert_eq!(peek_magic(&not_s4e4), None);
3251    }
3252
3253    #[tokio::test]
3254    async fn s4e4_aad_length_prefix_prevents_byte_shifting() {
3255        // Constructing an S4E4 body where the wrapped_dek_len is
3256        // shrunk by N bytes and the same N bytes are prepended to
3257        // the key_id-equivalent area would, without length-prefixed
3258        // AAD, produce the same AAD bytestream. Verify our AAD
3259        // includes the length prefixes by tampering with
3260        // wrapped_dek_len and confirming AES-GCM auth fails.
3261        let kms = local_kms_with(&[("kk", [11u8; 32])]);
3262        let (dek_vec, wrapped) = kms.generate_dek("kk").await.unwrap();
3263        let mut dek = [0u8; 32];
3264        dek.copy_from_slice(&dek_vec);
3265        let mut ct = encrypt_with_source(
3266            b"length-shift defense",
3267            SseSource::Kms {
3268                dek: &dek,
3269                wrapped: &wrapped,
3270            },
3271        )
3272        .to_vec();
3273        let key_id_len = ct[5] as usize;
3274        let wrapped_len_off = 6 + key_id_len;
3275        // Shrink wrapped_dek_len by 1. parse_s4e4_header now reads a
3276        // shorter wrapped_dek and a different nonce/tag/ciphertext
3277        // alignment — KMS unwrap fails OR AES-GCM fails OR frame
3278        // bounds reject. All three surface as auditable errors;
3279        // none should reach a successful decrypt.
3280        let original_len = u32::from_be_bytes([
3281            ct[wrapped_len_off],
3282            ct[wrapped_len_off + 1],
3283            ct[wrapped_len_off + 2],
3284            ct[wrapped_len_off + 3],
3285        ]);
3286        let new_len = (original_len - 1).to_be_bytes();
3287        ct[wrapped_len_off..wrapped_len_off + 4].copy_from_slice(&new_len);
3288        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
3289        // Acceptable failure modes: unwrap fail (truncated wrapped
3290        // DEK), AES-GCM fail (shifted nonce/tag/AAD), or frame bounds.
3291        assert!(
3292            matches!(
3293                err,
3294                SseError::KmsBackend(_)
3295                    | SseError::DecryptFailed
3296                    | SseError::KmsFrameFieldOob { .. }
3297                    | SseError::KmsFrameTooShort { .. }
3298            ),
3299            "got {err:?}"
3300        );
3301    }
3302
3303    // -----------------------------------------------------------------------
3304    // v0.8 #52: S4E5 chunked SSE-S4 — encrypt_v2_chunked / decrypt_chunked_stream
3305    // -----------------------------------------------------------------------
3306
3307    use futures::StreamExt;
3308
3309    /// Drain a chunked-decrypt stream into a `Vec<Bytes>` for assertion.
3310    /// Surfaces the first error verbatim (so tests can match on it).
3311    async fn collect_chunks(
3312        s: impl futures::Stream<Item = Result<Bytes, SseError>>,
3313    ) -> Result<Vec<Bytes>, SseError> {
3314        let mut out = Vec::new();
3315        let mut s = std::pin::pin!(s);
3316        while let Some(item) = s.next().await {
3317            out.push(item?);
3318        }
3319        Ok(out)
3320    }
3321
3322    #[test]
3323    fn s4e6_encrypt_layout_10mb_at_1mib() {
3324        // v0.8.1 #57: encrypt_v2_chunked now emits S4E6 (24-byte
3325        // header, 8-byte salt). 10 MB plaintext at 1 MiB chunk
3326        // size → magic "S4E6", chunk_count=10, header bytes line
3327        // up to the documented 24 + 10 * 16 + 10 MB layout.
3328        let kr = keyring_single(0x42);
3329        let chunk_size = 1024 * 1024;
3330        let pt_len = 10 * 1024 * 1024;
3331        let pt = vec![0xAB_u8; pt_len];
3332        let ct = encrypt_v2_chunked(&pt, &kr, chunk_size).expect("encrypt ok");
3333        assert_eq!(&ct[..4], SSE_MAGIC_V6, "new PUTs emit S4E6 (v0.8.1 #57)");
3334        assert_eq!(ct[4], ALGO_AES_256_GCM);
3335        assert_eq!(
3336            u16::from_be_bytes([ct[5], ct[6]]),
3337            1,
3338            "key_id BE = active id"
3339        );
3340        assert_eq!(ct[7], 0, "reserved must be 0");
3341        assert_eq!(
3342            u32::from_be_bytes([ct[8], ct[9], ct[10], ct[11]]),
3343            chunk_size as u32,
3344            "chunk_size BE",
3345        );
3346        assert_eq!(
3347            u32::from_be_bytes([ct[12], ct[13], ct[14], ct[15]]),
3348            10,
3349            "chunk_count BE — 10 MiB / 1 MiB = 10 (no remainder)",
3350        );
3351        // Salt now 8 bytes — verify the slice exists and isn't all
3352        // zeros (defensive: catches a stuck PRNG that would leave
3353        // the salt array uninitialized).
3354        assert_eq!(&ct[16..24].len(), &8, "S4E6 salt slot is 8 bytes");
3355        assert_ne!(
3356            &ct[16..24],
3357            &[0u8; 8],
3358            "S4E6 salt must be random, not zeros"
3359        );
3360        assert_eq!(
3361            ct.len(),
3362            S4E6_HEADER_BYTES + 10 * S4E6_PER_CHUNK_OVERHEAD + pt_len,
3363            "total = header (24) + 10 tags + plaintext",
3364        );
3365        assert!(looks_encrypted(&ct), "looks_encrypted must accept S4E6");
3366        assert_eq!(peek_magic(&ct), Some("S4E6"));
3367    }
3368
3369    #[tokio::test]
3370    async fn s4e6_decrypt_chunked_stream_byte_equal() {
3371        // v0.8.1 #57: round-trip via S4E6 path. encrypt_v2_chunked
3372        // emits S4E6, decrypt_chunked_stream consumes S4E5/S4E6.
3373        let kr = keyring_single(0x55);
3374        let pt: Vec<u8> = (0..(10 * 1024 * 1024_u32))
3375            .map(|i| (i & 0xFF) as u8)
3376            .collect();
3377        let ct = encrypt_v2_chunked(&pt, &kr, 1024 * 1024).unwrap();
3378        // Sanity: the new PUT is S4E6.
3379        assert_eq!(&ct[..4], SSE_MAGIC_V6, "new emit is S4E6");
3380        let stream = decrypt_chunked_stream(ct, &kr);
3381        let chunks = collect_chunks(stream).await.expect("stream ok");
3382        assert_eq!(chunks.len(), 10, "10 chunks expected for 10 MiB / 1 MiB");
3383        let mut joined = Vec::with_capacity(pt.len());
3384        for c in chunks {
3385            joined.extend_from_slice(&c);
3386        }
3387        assert_eq!(joined.len(), pt.len(), "byte length matches");
3388        assert_eq!(joined, pt, "byte-equal round-trip");
3389    }
3390
3391    #[tokio::test]
3392    async fn s4e6_single_chunk_for_small_object() {
3393        // Plaintext smaller than chunk_size → chunk_count=1. The
3394        // chunk_count field offset is unchanged between S4E5 and
3395        // S4E6 (both at body[12..16]); only the salt width differs.
3396        let kr = keyring_single(0x77);
3397        let pt = b"tiny payload, smaller than chunk_size";
3398        let ct = encrypt_v2_chunked(pt, &kr, 1024 * 1024).unwrap();
3399        assert_eq!(
3400            u32::from_be_bytes([ct[12], ct[13], ct[14], ct[15]]),
3401            1,
3402            "small plaintext = single chunk",
3403        );
3404        let stream = decrypt_chunked_stream(ct, &kr);
3405        let chunks = collect_chunks(stream).await.expect("stream ok");
3406        assert_eq!(chunks.len(), 1);
3407        assert_eq!(chunks[0].as_ref(), pt);
3408    }
3409
3410    #[tokio::test]
3411    async fn s4e6_tampered_chunk_n_reports_chunk_index() {
3412        // v0.8.1 #57: same tamper-detect contract as the S4E5
3413        // version, just with the wider 24-byte header. Tamper
3414        // byte inside chunk index 3 (= 4th chunk) — the stream
3415        // must yield 3 successful chunks, then ChunkAuthFailed { 3 }.
3416        let kr = keyring_single(0x91);
3417        let chunk_size = 1024;
3418        let pt = vec![0xCD_u8; chunk_size * 8]; // 8 chunks
3419        let mut ct = encrypt_v2_chunked(&pt, &kr, chunk_size).unwrap().to_vec();
3420        // Locate chunk 3's first ciphertext byte: header (24) + 3 *
3421        // (tag 16 + ct 1024) + tag 16 = 24 + 3*1040 + 16 = 3160.
3422        let target = S4E6_HEADER_BYTES + 3 * (TAG_LEN + chunk_size) + TAG_LEN;
3423        ct[target] ^= 0x42;
3424        let stream = decrypt_chunked_stream(bytes::Bytes::from(ct), &kr);
3425        let mut s = std::pin::pin!(stream);
3426        // Chunks 0, 1, 2 must succeed.
3427        for expected_i in 0..3_u32 {
3428            let item = s.next().await.expect("yield");
3429            item.unwrap_or_else(|e| panic!("chunk {expected_i}: {e:?}"));
3430        }
3431        // Chunk 3 fails with the right index.
3432        let err = s.next().await.expect("yield error").unwrap_err();
3433        assert!(
3434            matches!(err, SseError::ChunkAuthFailed { chunk_index: 3 }),
3435            "got {err:?}",
3436        );
3437    }
3438
3439    #[tokio::test]
3440    async fn s4e5_back_compat_s4e2_blob_rejected_with_clear_error() {
3441        // Feeding an S4E2 frame to decrypt_chunked_stream should
3442        // surface BadMagic on the first poll (NOT silently fall
3443        // back — the caller is expected to peek_magic and dispatch).
3444        let kr = keyring_single(0x12);
3445        let s4e2 = encrypt_v2(b"a v2 blob, not chunked", &kr);
3446        let stream = decrypt_chunked_stream(s4e2, &kr);
3447        let result = collect_chunks(stream).await;
3448        let err = result.unwrap_err();
3449        assert!(matches!(err, SseError::BadMagic { .. }), "got {err:?}");
3450    }
3451
3452    #[test]
3453    fn s4e6_salt_randomness_smoke() {
3454        // 8-byte salt → birthday paradox 50% collision at ~2^32
3455        // (~4.3 billion) PUTs. 1024 PUTs → effectively zero
3456        // expected collisions; we don't enforce zero, just
3457        // sanity-check the salt actually differs more than half
3458        // the time (catches a stuck PRNG without a 4-billion-PUT
3459        // test).
3460        let kr = keyring_single(0x33);
3461        let mut salts = std::collections::HashSet::new();
3462        let n = 1024;
3463        for _ in 0..n {
3464            let ct = encrypt_v2_chunked(b"x", &kr, 64).unwrap();
3465            let mut salt = [0u8; 8];
3466            salt.copy_from_slice(&ct[16..24]);
3467            salts.insert(salt);
3468        }
3469        assert!(
3470            salts.len() > n / 2,
3471            "expected most of the {n} salts to be unique (got {} unique)",
3472            salts.len(),
3473        );
3474    }
3475
3476    #[test]
3477    fn s4e6_chunk_size_zero_invalid() {
3478        let kr = keyring_single(0x66);
3479        let err = encrypt_v2_chunked(b"hi", &kr, 0).unwrap_err();
3480        assert!(matches!(err, SseError::ChunkSizeInvalid));
3481    }
3482
3483    #[tokio::test]
3484    async fn s4e6_truncated_body_reports_frame_truncated() {
3485        // Truncate inside chunk 2's tag → ChunkFrameTruncated, not
3486        // panic, not silent success. Header is now 24 bytes (S4E6).
3487        let kr = keyring_single(0xA1);
3488        let chunk_size = 256;
3489        let pt = vec![0u8; chunk_size * 4];
3490        let ct = encrypt_v2_chunked(&pt, &kr, chunk_size).unwrap();
3491        // Truncate to inside chunk 2's tag: header + chunk0 + chunk1
3492        // + 8B partial of chunk2's tag.
3493        let trunc = S4E6_HEADER_BYTES + 2 * (TAG_LEN + chunk_size) + 8;
3494        let truncated = bytes::Bytes::copy_from_slice(&ct[..trunc]);
3495        let stream = decrypt_chunked_stream(truncated, &kr);
3496        let result = collect_chunks(stream).await;
3497        let err = result.unwrap_err();
3498        assert!(
3499            matches!(err, SseError::ChunkFrameTruncated { .. }),
3500            "got {err:?}",
3501        );
3502    }
3503
3504    #[test]
3505    fn s4e6_decrypt_buffered_round_trip_via_top_level_decrypt() {
3506        // Sync `decrypt(blob, &keyring)` must also accept the
3507        // chunked frames (back-compat path for callers that need
3508        // the whole plaintext).
3509        let kr = keyring_single(0xDE);
3510        let pt = b"buffered sync decrypt path".repeat(32);
3511        let ct = encrypt_v2_chunked(&pt, &kr, 13).unwrap();
3512        let plain = decrypt(&ct, &kr).expect("buffered S4E6 decrypt ok");
3513        assert_eq!(plain.as_ref(), pt.as_slice());
3514    }
3515
3516    #[tokio::test]
3517    async fn s4e6_unknown_key_id_in_frame_errors() {
3518        // Encrypt under id=7, decrypt under a keyring that lacks id=7.
3519        let kr_put = SseKeyring::new(7, key32(0xCC));
3520        let kr_get = keyring_single(0xCC); // only id=1
3521        let ct = encrypt_v2_chunked(b"orphan key", &kr_put, 64).unwrap();
3522        // Sync path
3523        let err = decrypt(&ct, &kr_get).unwrap_err();
3524        assert!(
3525            matches!(err, SseError::KeyNotInKeyring { id: 7 }),
3526            "got {err:?}"
3527        );
3528        // Stream path
3529        let stream = decrypt_chunked_stream(ct, &kr_get);
3530        let result = collect_chunks(stream).await;
3531        assert!(
3532            matches!(result, Err(SseError::KeyNotInKeyring { id: 7 })),
3533            "got {result:?}",
3534        );
3535    }
3536
3537    #[tokio::test]
3538    async fn s4e6_final_chunk_smaller_than_chunk_size() {
3539        // Plaintext = 2.5 chunks → final chunk holds half the bytes.
3540        // S4E6 header = 24 bytes → total on-disk = 24 + 48 + 250.
3541        let kr = keyring_single(0xEF);
3542        let chunk_size = 100;
3543        let pt: Vec<u8> = (0..250_u32).map(|i| i as u8).collect();
3544        let ct = encrypt_v2_chunked(&pt, &kr, chunk_size).unwrap();
3545        assert_eq!(
3546            u32::from_be_bytes([ct[12], ct[13], ct[14], ct[15]]),
3547            3,
3548            "ceil(250/100) = 3 chunks",
3549        );
3550        // Total on-disk: 24 header + 3 tags (48) + 250 plaintext = 322.
3551        assert_eq!(ct.len(), S4E6_HEADER_BYTES + 48 + 250);
3552        let stream = decrypt_chunked_stream(ct, &kr);
3553        let chunks = collect_chunks(stream).await.expect("stream ok");
3554        assert_eq!(chunks.len(), 3);
3555        assert_eq!(chunks[0].len(), 100);
3556        assert_eq!(chunks[1].len(), 100);
3557        assert_eq!(chunks[2].len(), 50, "final chunk is the remainder");
3558        let joined: Vec<u8> = chunks.iter().flat_map(|c| c.iter().copied()).collect();
3559        assert_eq!(joined, pt);
3560    }
3561
3562    // -----------------------------------------------------------------------
3563    // v0.8.1 #57: S4E6-specific tests added on top of the renamed
3564    // s4e6_* battery above. Keep these focused on what's *new*:
3565    //   - back-compat read of legacy S4E5 blobs
3566    //   - 24-bit chunk_count cap
3567    //   - the parse_s4e6_header public API
3568    // -----------------------------------------------------------------------
3569
3570    #[test]
3571    fn s4e6_back_compat_read_s4e5_blob() {
3572        // Synthesize a "v0.8.0 vintage" S4E5 blob via the test-only
3573        // helper, then prove the v0.8.1 gateway decrypts it under
3574        // the same keyring — both via sync `decrypt` (buffered)
3575        // and the streaming path. Without this, every S4E5 object
3576        // in production becomes unreadable after the upgrade.
3577        let kr = keyring_single(0x57);
3578        let pt = b"v0.8.0 vintage chunked SSE-S4 object".repeat(64);
3579        let s4e5 = encrypt_v2_chunked_s4e5_for_test(&pt, &kr, 91).unwrap();
3580        // Confirm the test fixture really is S4E5 magic + 20-byte header.
3581        assert_eq!(&s4e5[..4], SSE_MAGIC_V5, "fixture must be S4E5");
3582        assert_eq!(peek_magic(&s4e5), Some("S4E5"));
3583        // Sync decrypt path (top-level `decrypt`, dispatches V5 + V6).
3584        let plain_sync = decrypt(&s4e5, &kr).expect("sync S4E5 decrypt ok");
3585        assert_eq!(plain_sync.as_ref(), pt.as_slice());
3586        // Streaming decrypt path — must also accept S4E5.
3587        let collected = futures::executor::block_on(async {
3588            let stream = decrypt_chunked_stream(s4e5.clone(), &kr);
3589            collect_chunks(stream).await
3590        })
3591        .expect("stream S4E5 decrypt ok");
3592        let mut joined = Vec::with_capacity(pt.len());
3593        for c in collected {
3594            joined.extend_from_slice(&c);
3595        }
3596        assert_eq!(joined, pt, "S4E5 streaming round-trip byte-equal");
3597    }
3598
3599    #[test]
3600    fn s4e6_layout_24_bytes_header() {
3601        // Sanity check: the S4E6 fixed header is exactly 24 bytes
3602        // (vs 20 for S4E5). Catches an accidental const drift in a
3603        // future PR.
3604        assert_eq!(S4E6_HEADER_BYTES, 24);
3605        assert_eq!(S4E6_PER_CHUNK_OVERHEAD, TAG_LEN);
3606        assert_eq!(S4E6_HEADER_BYTES, S4E5_HEADER_BYTES + 4);
3607    }
3608
3609    #[test]
3610    fn s4e6_parse_header_round_trip() {
3611        // parse_s4e6_header is the public mirror of the internal
3612        // parse_chunked_header, useful for admin tools. Verify it
3613        // returns the same field values that encrypt_v2_chunked wrote.
3614        let kr = keyring_single(0xAB);
3615        let chunk_size = 256;
3616        let pt = vec![1u8; 7 * chunk_size];
3617        let ct = encrypt_v2_chunked(&pt, &kr, chunk_size).unwrap();
3618        let hdr = parse_s4e6_header(&ct).expect("parse ok");
3619        assert_eq!(hdr.key_id, 1);
3620        assert_eq!(hdr.chunk_size, chunk_size as u32);
3621        assert_eq!(hdr.chunk_count, 7);
3622        assert_eq!(hdr.salt.len(), 8);
3623        // Bad magic on a non-S4E6 blob → BadMagic.
3624        let bogus = b"S4E2\x01\x00\x00\x00........................";
3625        let err = parse_s4e6_header(bogus).unwrap_err();
3626        assert!(matches!(err, SseError::BadMagic { .. }), "got {err:?}");
3627        // Truncation → ChunkFrameTruncated.
3628        let err2 = parse_s4e6_header(&ct[..10]).unwrap_err();
3629        assert!(
3630            matches!(err2, SseError::ChunkFrameTruncated { .. }),
3631            "got {err2:?}"
3632        );
3633    }
3634
3635    #[test]
3636    fn s4e6_salt_uniqueness_smoke_16m() {
3637        // v0.8.1 #57 security regression detection: with the
3638        // 4-byte S4E5 salt, ~65,536 PUTs already had ~50%
3639        // birthday collision (the bug that motivated this patch).
3640        // With the 8-byte S4E6 salt the expected collisions over
3641        // 65,536 PUTs is ~2^16 * 2^16 / 2^65 ≈ 2^-33 — i.e.
3642        // effectively zero.
3643        //
3644        // We can't actually run 16M PUTs in unit-test wall-clock
3645        // (each PUT does an AES-GCM encrypt), so we run a fast
3646        // smoke (16k) and additionally validate the math: at the
3647        // S4E5 4-byte salt width, 16k PUTs would already give a
3648        // ~3.1% collision probability by birthday bound; at the
3649        // S4E6 8-byte salt that drops to ~3.6e-11. The smoke test
3650        // therefore *would* show collisions if we'd accidentally
3651        // shipped the 4-byte salt — confirming the regression
3652        // detector.
3653        let kr = keyring_single(0xA6);
3654        let mut salts = std::collections::HashSet::with_capacity(16384);
3655        let n = 16384_usize;
3656        let mut collisions_top4 = 0usize;
3657        let mut top4_seen = std::collections::HashSet::with_capacity(16384);
3658        for _ in 0..n {
3659            let ct = encrypt_v2_chunked(b"x", &kr, 64).unwrap();
3660            let mut salt = [0u8; 8];
3661            salt.copy_from_slice(&ct[16..24]);
3662            salts.insert(salt);
3663            // Side-channel: count collisions on just the *first 4
3664            // bytes* of the 8-byte salt. If we'd kept the old
3665            // 4-byte salt, this collision count would be the only
3666            // collision count — and at n=16k it should be ~62
3667            // (birthday: n^2/(2 * 2^32) = 16384^2/2^33 ≈ 31, with
3668            // some noise). The full 8-byte salt test passes if the
3669            // FULL salts are all unique while the truncated-to-4
3670            // count is non-zero, proving the extra 4 bytes really
3671            // are doing the security work.
3672            let mut top4 = [0u8; 4];
3673            top4.copy_from_slice(&salt[..4]);
3674            if !top4_seen.insert(top4) {
3675                collisions_top4 += 1;
3676            }
3677        }
3678        assert_eq!(
3679            salts.len(),
3680            n,
3681            "all 8-byte salts must be unique across {n} PUTs (got {} unique)",
3682            salts.len(),
3683        );
3684        // Sanity check the regression detector: at 16k PUTs with a
3685        // 4-byte salt, birthday math predicts ~31 collisions on
3686        // average. Anything in the 0..200 range is statistically
3687        // believable for 16k uniform 32-bit draws; we only assert
3688        // ">= 1" (i.e. at least one collision happened — which
3689        // would have been a real bug under S4E5).
3690        eprintln!(
3691            "s4e6_salt_uniqueness_smoke_16m: 16k PUTs, full 8B salts \
3692             all unique ({}/{}), simulated 4B-truncated salt yielded \
3693             {} collisions (this is what S4E5 would have shipped)",
3694            salts.len(),
3695            n,
3696            collisions_top4,
3697        );
3698        // Don't make the test flaky on the simulated number (it's a
3699        // statistical signal); just leave the eprintln for the
3700        // operator audit log when the test runs verbose.
3701    }
3702
3703    #[test]
3704    fn s4e6_max_chunks_24bit() {
3705        // The S4E6 nonce embeds the chunk index as 24-bit BE, so
3706        // chunk_count > 2^24 - 1 must surface ChunkCountTooLarge
3707        // at PUT time. We can't actually run a 16M-chunk encrypt
3708        // in unit-test wall-clock (16M AES-GCM tag verifies even
3709        // on AES-NI is several minutes), but we can verify the
3710        // CAP constant matches expectations + exercise the cap by
3711        // picking a chunk_size that forces overflow on a tiny
3712        // plaintext.
3713        assert_eq!(S4E6_MAX_CHUNK_COUNT, (1u32 << 24) - 1);
3714        assert_eq!(S4E6_MAX_CHUNK_COUNT, 16_777_215);
3715
3716        // chunk_size=1 + plaintext.len()=16_777_216 → 16M+1 chunks
3717        // → over the cap → ChunkCountTooLarge. Allocating a 16
3718        // MiB plaintext is fine.
3719        let kr = keyring_single(0xC4);
3720        let pt = vec![0u8; (S4E6_MAX_CHUNK_COUNT as usize) + 1]; // 16,777,216 B
3721        let err = encrypt_v2_chunked(&pt, &kr, 1).unwrap_err();
3722        assert!(
3723            matches!(
3724                err,
3725                SseError::ChunkCountTooLarge {
3726                    got: 16_777_216,
3727                    max: 16_777_215
3728                }
3729            ),
3730            "got {err:?}",
3731        );
3732
3733        // And just under the cap (chunk_count = 16_777_215) should
3734        // succeed. We pick chunk_size that produces exactly the cap
3735        // so the inner loop only runs N times. 16M chunk-encrypts
3736        // would be slow, so test with a smaller cap-near config
3737        // that exercises the same boundary check: 1023 chunks of 1
3738        // byte each = 1023 chunks well under the cap → success.
3739        // The actual on-cap encrypt is exercised by the buffered
3740        // decrypt path through `parse_chunked_header`.
3741        let pt_ok = vec![0u8; 1023];
3742        let ct = encrypt_v2_chunked(&pt_ok, &kr, 1).expect("under-cap PUT must succeed");
3743        let hdr = parse_s4e6_header(&ct).unwrap();
3744        assert_eq!(hdr.chunk_count, 1023);
3745
3746        // Synthesize a frame that *claims* chunk_count > cap and
3747        // verify the parser rejects it (defensive: a tampered
3748        // header should not loop the walker 16M+ times).
3749        let mut tampered = ct.to_vec();
3750        // Rewrite chunk_count BE to S4E6_MAX_CHUNK_COUNT + 1 = 2^24.
3751        let bad = (S4E6_MAX_CHUNK_COUNT + 1).to_be_bytes();
3752        tampered[12..16].copy_from_slice(&bad);
3753        let err2 = parse_s4e6_header(&tampered).unwrap_err();
3754        assert!(
3755            matches!(
3756                err2,
3757                SseError::ChunkCountTooLarge {
3758                    got: 16_777_216,
3759                    max: 16_777_215
3760                }
3761            ),
3762            "got {err2:?}",
3763        );
3764    }
3765
3766    #[test]
3767    fn s4e6_nonce_v6_layout() {
3768        // Direct unit test on nonce_v6: prefix b'E', then 8B salt,
3769        // then 24-bit BE chunk_index. The high byte of u32
3770        // chunk_index must be dropped (caller-validated cap).
3771        let salt = [0xAA_u8; 8];
3772        let n0 = nonce_v6(&salt, 0);
3773        assert_eq!(n0[0], b'E');
3774        assert_eq!(&n0[1..9], &salt);
3775        assert_eq!(&n0[9..12], &[0, 0, 0]);
3776        let n1 = nonce_v6(&salt, 1);
3777        assert_eq!(&n1[9..12], &[0, 0, 1]);
3778        let n_mid = nonce_v6(&salt, 0x123456);
3779        assert_eq!(&n_mid[9..12], &[0x12, 0x34, 0x56]);
3780        let n_max = nonce_v6(&salt, S4E6_MAX_CHUNK_COUNT);
3781        assert_eq!(&n_max[9..12], &[0xFF, 0xFF, 0xFF]);
3782    }
3783
3784    #[tokio::test]
3785    async fn s4e6_tampered_salt_byte_fails_aead() {
3786        // Flipping a single byte of the 8-byte salt in the header
3787        // must invalidate every chunk's AES-GCM tag (salt is in
3788        // the AAD). Confirms the salt expansion didn't drop
3789        // header authentication.
3790        let kr = keyring_single(0xB6);
3791        let pt = b"salt-in-aad coverage".repeat(64);
3792        let mut ct = encrypt_v2_chunked(&pt, &kr, 128).unwrap().to_vec();
3793        // Salt bytes 16..24 — flip the middle byte.
3794        ct[20] ^= 0x01;
3795        let err = decrypt(&ct, &kr).unwrap_err();
3796        assert!(
3797            matches!(err, SseError::ChunkAuthFailed { chunk_index: 0 }),
3798            "got {err:?}",
3799        );
3800    }
3801
3802    // -----------------------------------------------------------------------
3803    // v0.8.2 #64: pre-allocation guard for chunked SSE frames
3804    //
3805    // The buffered decrypt path used to call `Vec::with_capacity(chunk_size
3806    // * chunk_count)` *before* validating either factor. A 24-byte malicious
3807    // S4E6 header could declare a multi-PB plaintext and trigger an instant
3808    // OOM / panic on a tiny ciphertext. Verify the guard rejects every
3809    // adversarial header pattern before any allocation happens.
3810    // -----------------------------------------------------------------------
3811
3812    /// Build a minimal S4E6 header with attacker-controlled chunk_size /
3813    /// chunk_count (no chunks attached — the body is exactly 24 bytes).
3814    /// Used by the DoS tests below to synthesize malicious frames without
3815    /// running an encrypt.
3816    fn synth_s4e6_header(chunk_size: u32, chunk_count: u32) -> Vec<u8> {
3817        let mut blob = Vec::with_capacity(S4E6_HEADER_BYTES);
3818        blob.extend_from_slice(SSE_MAGIC_V6);
3819        blob.push(ALGO_AES_256_GCM);
3820        blob.extend_from_slice(&1_u16.to_be_bytes()); // key_id = 1
3821        blob.push(0); // reserved
3822        blob.extend_from_slice(&chunk_size.to_be_bytes());
3823        blob.extend_from_slice(&chunk_count.to_be_bytes());
3824        blob.extend_from_slice(&[0u8; 8]); // 8-byte salt
3825        debug_assert_eq!(blob.len(), S4E6_HEADER_BYTES);
3826        blob
3827    }
3828
3829    #[test]
3830    fn s4e6_header_claims_huge_size_rejected_pre_alloc() {
3831        // 1 GiB chunk_size × 100 chunks = 100 GiB declared plaintext, but
3832        // the body the attacker actually shipped is only 100 bytes. The
3833        // pre-alloc guard's cap arm fires (100 GiB > 5 GiB default cap)
3834        // *before* the walker / `Vec::with_capacity(100 GiB)` — surfaces
3835        // as ChunkFrameTooLarge.
3836        let kr = keyring_single(0x01);
3837        let chunk_size: u32 = 1 << 30; // 1 GiB
3838        let chunk_count: u32 = 100;
3839        let mut blob = synth_s4e6_header(chunk_size, chunk_count);
3840        // Pad with 100 random bytes — the attack model: tiny payload,
3841        // monster header.
3842        blob.extend_from_slice(&[0u8; 100]);
3843        let err = decrypt_chunked_buffered_default(&blob, &kr).unwrap_err();
3844        assert!(
3845            matches!(err, SseError::ChunkFrameTooLarge { .. }),
3846            "expected ChunkFrameTooLarge (declared 100 GiB > 5 GiB cap), got {err:?}",
3847        );
3848        // Same input, tighter caller-supplied cap (1 MiB): still
3849        // ChunkFrameTooLarge. No panic, no OOM either way.
3850        let err2 = decrypt_chunked_buffered(&blob, &kr, 1024 * 1024).unwrap_err();
3851        assert!(
3852            matches!(err2, SseError::ChunkFrameTooLarge { .. }),
3853            "expected ChunkFrameTooLarge under tighter cap, got {err2:?}",
3854        );
3855    }
3856
3857    #[test]
3858    fn s4e6_header_chunk_size_x_chunk_count_overflows_u64() {
3859        // chunk_size = u32::MAX, chunk_count > 1 → product > u64 cap?
3860        // No — u32::MAX * u32::MAX fits in u64 (~2^64). To force u64
3861        // overflow we'd need both > 2^32. But chunk_count is capped to
3862        // 16M (S4E6_MAX_CHUNK_COUNT) by the earlier check, so the
3863        // realistic adversarial maximum is u32::MAX * 16M ≈ 2^56 — well
3864        // under u64::MAX. The overflow path is therefore *theoretically*
3865        // unreachable on S4E6 (24-bit chunk_count cap protects it), but
3866        // we still test it via S4E5 which has no 24-bit cap on
3867        // chunk_count: a 4-byte salt frame with chunk_size = u32::MAX
3868        // and chunk_count = u32::MAX gives 2^64 - 2^33 + 1 — fits u64
3869        // but adding the 16-byte tag overhead × u32::MAX chunks
3870        // overflows. Verify ChunkFrameTooLarge surfaces.
3871        let kr = keyring_single(0x02);
3872        // Synthesize an S4E5 header (20 bytes) with maximally
3873        // adversarial chunk_size + chunk_count.
3874        let mut blob = Vec::with_capacity(S4E5_HEADER_BYTES);
3875        blob.extend_from_slice(SSE_MAGIC_V5);
3876        blob.push(ALGO_AES_256_GCM);
3877        blob.extend_from_slice(&1_u16.to_be_bytes());
3878        blob.push(0);
3879        blob.extend_from_slice(&u32::MAX.to_be_bytes()); // chunk_size = 2^32 - 1
3880        blob.extend_from_slice(&u32::MAX.to_be_bytes()); // chunk_count = 2^32 - 1
3881        blob.extend_from_slice(&[0u8; 4]); // 4-byte S4E5 salt
3882        debug_assert_eq!(blob.len(), S4E5_HEADER_BYTES);
3883        let err = decrypt_chunked_buffered_default(&blob, &kr).unwrap_err();
3884        assert!(
3885            matches!(err, SseError::ChunkFrameTooLarge { .. }),
3886            "expected ChunkFrameTooLarge for u64 overflow, got {err:?}",
3887        );
3888
3889        // Also smoke the same input through `parse_chunked_header`
3890        // directly (the streaming path) — must error, never panic.
3891        let direct = parse_chunked_header(&blob, usize::MAX).unwrap_err();
3892        assert!(
3893            matches!(direct, SseError::ChunkFrameTooLarge { .. }),
3894            "streaming path: expected ChunkFrameTooLarge, got {direct:?}",
3895        );
3896    }
3897
3898    #[test]
3899    fn s4e6_header_within_max_body_bytes_passes() {
3900        // 1 MiB chunk_size × 100 chunks = 100 MiB declared plaintext —
3901        // well under the 5 GiB default cap. The header validation must
3902        // NOT reject this; instead it falls through to chunk-walk +
3903        // AES-GCM verify (which then fails on this synthetic frame
3904        // because we didn't write any ciphertext, so we expect
3905        // ChunkFrameTruncated *not* ChunkFrameTooLarge).
3906        let kr = keyring_single(0x03);
3907        let chunk_size: u32 = 1024 * 1024; // 1 MiB
3908        let chunk_count: u32 = 100;
3909        let mut blob = synth_s4e6_header(chunk_size, chunk_count);
3910        // Append the full declared chunk array so the truncation arm
3911        // doesn't fire — 100 chunks × (16 B tag + 1 MiB ct) = 100 MiB
3912        // + 1.6 KiB tags. We write zeros; AES-GCM verify will fail on
3913        // chunk 0, but that proves the pre-alloc guard *let it through*.
3914        let chunk_array_size =
3915            (chunk_count as usize) * (S4E6_PER_CHUNK_OVERHEAD + chunk_size as usize);
3916        blob.resize(blob.len() + chunk_array_size, 0);
3917        let err = decrypt_chunked_buffered(&blob, &kr, DEFAULT_MAX_BODY_BYTES).unwrap_err();
3918        // The guard let it through (we got past parse_chunked_header)
3919        // and AES-GCM tag verify failed on chunk 0 — that's the right
3920        // failure mode. ChunkFrameTooLarge would mean the cap fired
3921        // (a regression of this test). KeyNotInKeyring would mean the
3922        // header parsed but the key id (1) wasn't present (also a
3923        // regression — the keyring has id=1 active). Anything else is
3924        // a guard-too-strict bug.
3925        assert!(
3926            matches!(err, SseError::ChunkAuthFailed { chunk_index: 0 }),
3927            "expected ChunkAuthFailed (guard let it through), got {err:?}",
3928        );
3929    }
3930
3931    #[test]
3932    fn s4e6_header_exceeds_max_body_bytes_rejected() {
3933        // 1 MiB × 6000 chunks = 6 GiB declared plaintext > 5 GiB cap.
3934        // Must reject as ChunkFrameTooLarge before any alloc. We don't
3935        // attach the 6 GiB chunk array to the body — the cap arm only
3936        // looks at the declared `chunk_size × chunk_count`, not the
3937        // actual body length, so a tiny body suffices to exercise the
3938        // cap.
3939        let kr = keyring_single(0x04);
3940        let chunk_size: u32 = 1024 * 1024; // 1 MiB
3941        let chunk_count: u32 = 6000;
3942        let blob = synth_s4e6_header(chunk_size, chunk_count);
3943        // Body is exactly the 24-byte header, no chunks attached.
3944        // body.len() = 24 ≤ max_total (~6 GiB) so the trailing-bytes
3945        // check does NOT fire; the 6 GiB > 5 GiB cap check does.
3946        let err = decrypt_chunked_buffered(&blob, &kr, DEFAULT_MAX_BODY_BYTES).unwrap_err();
3947        assert!(
3948            matches!(err, SseError::ChunkFrameTooLarge { .. }),
3949            "expected ChunkFrameTooLarge (6 GiB declared > 5 GiB cap), got {err:?}",
3950        );
3951
3952        // Directly exercise the cap arm with a tighter cap (1 MiB)
3953        // and a 100 MiB declared plaintext that fits fully in the
3954        // body, so the cap is unambiguously the deciding factor.
3955        let chunk_size_b: u32 = 1024 * 1024; // 1 MiB
3956        let chunk_count_b: u32 = 100;
3957        let mut blob_b = synth_s4e6_header(chunk_size_b, chunk_count_b);
3958        let pad_b = (chunk_count_b as usize) * (S4E6_PER_CHUNK_OVERHEAD + chunk_size_b as usize);
3959        blob_b.resize(blob_b.len() + pad_b, 0);
3960        // Cap = 1 MiB, declared plaintext = 100 MiB → ChunkFrameTooLarge.
3961        let err_b = decrypt_chunked_buffered(&blob_b, &kr, 1024 * 1024).unwrap_err();
3962        assert!(
3963            matches!(err_b, SseError::ChunkFrameTooLarge { .. }),
3964            "expected ChunkFrameTooLarge (cap < declared), got {err_b:?}",
3965        );
3966    }
3967
3968    #[test]
3969    fn s4e6_random_header_never_panics() {
3970        // DoS regression — feed 100k random byte sequences shaped like
3971        // chunked SSE headers to `parse_chunked_header`. Every result
3972        // must be Ok or Err; no panic, no OOM. This exercises the
3973        // arithmetic-overflow + truncation arms of the v0.8.2 #64
3974        // guard against adversarial inputs the previous unit tests
3975        // didn't enumerate.
3976        //
3977        // We use a fixed seed (deterministic) rather than proptest
3978        // here so the test is repeatable across CI runs without
3979        // pulling in shrink machinery for a pure no-panic property.
3980        use rand::{Rng, SeedableRng, rngs::StdRng};
3981        let mut rng = StdRng::seed_from_u64(0xC0FF_EE64_6464_64DE);
3982        let mut max_body_bytes_choices = [
3983            0_usize,
3984            1024,
3985            1024 * 1024,
3986            DEFAULT_MAX_BODY_BYTES,
3987            usize::MAX,
3988        ]
3989        .iter()
3990        .copied()
3991        .cycle();
3992        for _ in 0..100_000 {
3993            // Body length in [0, 256] — tiny on purpose: most random
3994            // bytes won't be a valid header, but we want the parser
3995            // to robustly reject every shape (truncated, wrong magic,
3996            // wrong algo, declared-vs-actual mismatch, overflow).
3997            let body_len = rng.gen_range(0..=256_usize);
3998            let mut body = vec![0u8; body_len];
3999            rng.fill(body.as_mut_slice());
4000            // Bias 1/4 of trials toward valid magic so the deeper
4001            // arithmetic paths get exercised, not just the BadMagic
4002            // early-out.
4003            if body_len >= 4 && rng.gen_bool(0.25) {
4004                if rng.gen_bool(0.5) {
4005                    body[..4].copy_from_slice(SSE_MAGIC_V5);
4006                } else {
4007                    body[..4].copy_from_slice(SSE_MAGIC_V6);
4008                }
4009            }
4010            let max_cap = max_body_bytes_choices.next().unwrap();
4011            // The contract: never panic, never alloc more than
4012            // `max_cap` worth of memory. We assert only no-panic
4013            // here; OOM behavior is implicit (the function returns
4014            // Err before any large alloc).
4015            let _ = parse_chunked_header(&body, max_cap);
4016        }
4017    }
4018
4019    // Bonus: an extreme-overflow case the guard is specifically
4020    // hardened against — chunk_count = u32::MAX with the per-chunk
4021    // overhead multiplication forced to overflow u64. Catches a
4022    // future refactor that would replace `checked_mul` with `*`.
4023    #[test]
4024    fn s4e5_extreme_overflow_chunk_count_u32_max() {
4025        let kr = keyring_single(0x05);
4026        // u32::MAX chunk_size × u32::MAX chunk_count overflows u64
4027        // when adding the 16 * u32::MAX tag overhead. (Strictly:
4028        // chunk_size × chunk_count = (2^32-1)^2 ≈ 2^64 - 2^33 + 1
4029        // — already u64-saturating; the +tag step then overflows.)
4030        // The guard's `checked_add` chain must catch this.
4031        let mut blob = Vec::with_capacity(S4E5_HEADER_BYTES);
4032        blob.extend_from_slice(SSE_MAGIC_V5);
4033        blob.push(ALGO_AES_256_GCM);
4034        blob.extend_from_slice(&1_u16.to_be_bytes());
4035        blob.push(0);
4036        blob.extend_from_slice(&u32::MAX.to_be_bytes());
4037        blob.extend_from_slice(&u32::MAX.to_be_bytes());
4038        blob.extend_from_slice(&[0u8; 4]);
4039        let err = decrypt_chunked_buffered_default(&blob, &kr).unwrap_err();
4040        assert!(
4041            matches!(err, SseError::ChunkFrameTooLarge { .. }),
4042            "expected ChunkFrameTooLarge for extreme overflow, got {err:?}",
4043        );
4044    }
4045}