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(¬_s4e4));
3250 assert_eq!(peek_magic(¬_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}