Skip to main content

s4_server/
sse.rs

1//! Server-side encryption (SSE-S4) — AES-256-GCM (v0.4 #21, v0.5 #29, v0.5 #27, v0.5 #28).
2//!
3//! Wraps the post-compression S3 object body with authenticated
4//! encryption. Compress-then-encrypt is the right order: encryption
5//! produces high-entropy bytes that don't compress, so encrypting last
6//! preserves the codec's ratio.
7//!
8//! ## Wire formats
9//!
10//! ### S4E1 (v0.4) — single key, no rotation
11//!
12//! ```text
13//! [magic: "S4E1" 4B]
14//! [algo:  u8]            # 1 = AES-256-GCM
15//! [reserved: 3B]         # 0x00 0x00 0x00
16//! [nonce: 12B]           # random per-object
17//! [tag:   16B]           # AES-GCM authentication tag
18//! [ciphertext: variable] # encrypted-then-authenticated body
19//! ```
20//!
21//! Total overhead: 36 bytes per object.
22//!
23//! ### S4E2 (v0.5 #29) — keyring-aware, supports rotation
24//!
25//! ```text
26//! [magic:  "S4E2" 4B]
27//! [algo:   u8]            # 1 = AES-256-GCM
28//! [key_id: u16 BE]        # which keyring slot encrypted this body
29//! [reserved: 1B]          # 0x00
30//! [nonce:  12B]           # random per-object
31//! [tag:    16B]           # AES-GCM authentication tag
32//! [ciphertext: variable]
33//! ```
34//!
35//! Same 36-byte overhead — we reused the 3-byte reserved area in S4E1
36//! to fit a 2-byte key-id + 1-byte reserved without bumping the header
37//! size. The key-id is included in the AAD so a flipped key-id byte
38//! fails the auth tag (i.e. an attacker can't trick the gateway into
39//! decrypting under a different keyring slot).
40//!
41//! ### S4E3 (v0.5 #27) — SSE-C, customer-provided key
42//!
43//! ```text
44//! [magic:   "S4E3" 4B]
45//! [algo:    u8]            # 1 = AES-256-GCM
46//! [key_md5: 16B]           # MD5 fingerprint of the customer key
47//! [nonce:   12B]           # random per-object
48//! [tag:     16B]           # AES-GCM authentication tag
49//! [ciphertext: variable]
50//! ```
51//!
52//! Overhead: 49 bytes (`4 + 1 + 16 + 12 + 16`). Unlike S4E1/S4E2 the
53//! gateway does **not** persist the key — the client supplies it on
54//! every PUT/GET via `x-amz-server-side-encryption-customer-{algorithm,
55//! key,key-MD5}` headers. We store only the 16-byte MD5 in the on-disk
56//! frame so a GET with the wrong key surfaces as
57//! [`SseError::WrongCustomerKey`] before AES-GCM is even tried (saves a
58//! useless decrypt + gives operators a distinct error from generic auth
59//! failure).
60//!
61//! The `key_md5` is included in the AAD so flipping a single byte of
62//! the stored fingerprint also breaks AES-GCM auth — i.e. an attacker
63//! who tampered with the metadata can't sneak a different key past the
64//! check.
65//!
66//! ### S4E4 (v0.5 #28) — SSE-KMS envelope, per-object DEK
67//!
68//! ```text
69//! [magic:           "S4E4" 4B]
70//! [algo:            u8]            # 1 = AES-256-GCM
71//! [key_id_len:      u8]            # 1..=255, length of UTF-8 key_id
72//! [key_id:          variable]      # UTF-8, AAD-authenticated
73//! [wrapped_dek_len: u32 BE]        # length of the wrapped DEK blob
74//! [wrapped_dek:     variable]      # opaque, AAD-authenticated
75//! [nonce:           12B]           # random per-object
76//! [tag:             16B]           # AES-GCM auth tag for body
77//! [ciphertext:      variable]      # body encrypted under the DEK
78//! ```
79//!
80//! Header overhead: `4 + 1 + 1 + key_id_len + 4 + wrapped_dek_len + 12
81//! + 16` = 38 + key_id_len + wrapped_dek_len. For a typical
82//! [`crate::kms::LocalKms`] wrap (60-byte ciphertext) and a 36-char
83//! UUID-style `key_id`, that's ~134 bytes per object.
84//!
85//! `key_id` and `wrapped_dek` are both placed in the AAD so an
86//! attacker cannot rewrite either field to point the gateway at a
87//! different KEK or wrapped DEK without invalidating the body's
88//! AES-GCM tag. The plaintext DEK is never persisted; only the
89//! wrapped form is on disk, and the gateway holds the plaintext only
90//! for the duration of one PUT or GET.
91//!
92//! S4E4 decrypt requires an `async` round-trip to the KMS backend
93//! (to unwrap the DEK), so the synchronous [`decrypt`] function
94//! refuses S4E4 with [`SseError::KmsAsyncRequired`] — callers that
95//! peek `S4E4` via [`peek_magic`] must dispatch to
96//! [`decrypt_with_kms`] instead.
97//!
98//! ## v0.5 rotation flow (SSE-S4 only)
99//!
100//! Operators wire one [`SseKeyring`] holding the **active** key plus
101//! any number of **retired** keys. PUT always encrypts under the
102//! active key (S4E2 with that key's id). GET sniffs the magic:
103//!
104//! - `S4E1`: legacy single-key path. The keyring's active key is tried
105//!   first, then every retired key — this lets a v0.4 deployment
106//!   migrate to a keyring with the original key as active and decrypt
107//!   pre-rotation objects unchanged.
108//! - `S4E2`: read the key_id, look it up in the keyring, decrypt with
109//!   that exact key. Missing key_id surfaces as `KeyNotInKeyring`.
110//! - `S4E3`: keyring is **not** consulted. Caller must supply
111//!   [`SseSource::CustomerKey`] with the matching key + md5.
112//!
113//! ## Open follow-ups
114//!
115//! - **Server-managed key only** (for SSE-S4): keys come from local
116//!   files via `--sse-s4-key` / `--sse-s4-key-rotated`. KMS / vault
117//!   integration for the SSE-S4 keyring (i.e. wrapping the keyring's
118//!   keys with KMS) is a separate issue. SSE-KMS for per-object DEKs
119//!   is implemented (see [`SseSource::Kms`] + S4E4 above).
120
121use std::collections::HashMap;
122use std::path::Path;
123use std::sync::Arc;
124
125use aes_gcm::aead::{Aead, KeyInit, Payload};
126use aes_gcm::{Aes256Gcm, Key, Nonce};
127use bytes::Bytes;
128use md5::{Digest as Md5Digest, Md5};
129use rand::RngCore;
130use thiserror::Error;
131
132use crate::kms::{KmsBackend, KmsError, WrappedDek};
133
134pub const SSE_MAGIC_V1: &[u8; 4] = b"S4E1";
135pub const SSE_MAGIC_V2: &[u8; 4] = b"S4E2";
136pub const SSE_MAGIC_V3: &[u8; 4] = b"S4E3";
137pub const SSE_MAGIC_V4: &[u8; 4] = b"S4E4";
138/// Back-compat alias — v0.4 callers that imported `SSE_MAGIC` mean S4E1.
139pub const SSE_MAGIC: &[u8; 4] = SSE_MAGIC_V1;
140
141/// Header layout matches between S4E1 and S4E2 (both 36 bytes total)
142/// because S4E2 reuses the 3-byte reserved slot to fit `key_id (2B) +
143/// reserved (1B)`. Keeping them the same length means the rest of the
144/// pipeline (sidecar offsets, multipart math) doesn't care which
145/// frame variant is in flight.
146pub const SSE_HEADER_BYTES: usize = 4 + 1 + 3 + 12 + 16; // = 36
147/// S4E3 (SSE-C) replaces the 3-byte reserved area with a 16-byte
148/// customer-key MD5 fingerprint, so the header is 49 bytes total.
149/// `magic 4 + algo 1 + key_md5 16 + nonce 12 + tag 16`.
150pub const SSE_HEADER_BYTES_V3: usize = 4 + 1 + KEY_MD5_LEN + 12 + 16; // = 49
151pub const ALGO_AES_256_GCM: u8 = 1;
152const NONCE_LEN: usize = 12;
153const TAG_LEN: usize = 16;
154const KEY_LEN: usize = 32;
155const KEY_MD5_LEN: usize = 16;
156/// AWS S3 SSE-C only allows AES256 in the
157/// `x-amz-server-side-encryption-customer-algorithm` header, so we
158/// match that exact spelling for parity with real S3 clients.
159pub const SSE_C_ALGORITHM: &str = "AES256";
160
161#[derive(Debug, Error)]
162pub enum SseError {
163    #[error("SSE key file {path:?}: {source}")]
164    KeyFileIo {
165        path: std::path::PathBuf,
166        source: std::io::Error,
167    },
168    #[error(
169        "SSE key file must be exactly 32 raw bytes (or 64-char hex / 44-char base64); got {got} bytes after parse"
170    )]
171    BadKeyLength { got: usize },
172    #[error("SSE-encrypted body too short ({got} bytes; need at least {SSE_HEADER_BYTES})")]
173    TooShort { got: usize },
174    #[error("SSE bad magic: expected S4E1/S4E2/S4E3/S4E4, got {got:?}")]
175    BadMagic { got: [u8; 4] },
176    #[error("SSE unsupported algo tag: {tag} (this build only knows AES-256-GCM = 1)")]
177    UnsupportedAlgo { tag: u8 },
178    #[error(
179        "SSE key_id {id} (S4E2 frame) not present in keyring; rotation history likely incomplete"
180    )]
181    KeyNotInKeyring { id: u16 },
182    #[error("SSE decryption / authentication failed (key mismatch or ciphertext tampered with)")]
183    DecryptFailed,
184    // --- v0.5 #27: SSE-C specific errors ---
185    /// The MD5 fingerprint stored in the S4E3 frame doesn't match the
186    /// MD5 of the customer key the client supplied. This is the
187    /// "wrong customer key on GET" signal — distinct from
188    /// `DecryptFailed` so service.rs can map it to AWS S3's
189    /// `403 AccessDenied` (S3 returns AccessDenied when the supplied
190    /// SSE-C key doesn't match the one used at PUT time).
191    #[error("SSE-C key MD5 fingerprint mismatch — client supplied a different key than PUT")]
192    WrongCustomerKey,
193    /// `parse_customer_key_headers` saw a malformed input. `reason` is
194    /// a short human string ("base64 decode of key", "key length",
195    /// "md5 length", "md5 mismatch") for operator log lines — never
196    /// echoed to the client (would leak crypto details).
197    #[error("SSE-C customer-key headers invalid: {reason}")]
198    InvalidCustomerKey { reason: &'static str },
199    /// Client asked for an SSE-C algorithm the gateway doesn't speak.
200    /// AWS S3 only ever defines `AES256` here; surfacing the offending
201    /// string lets us 400 with a useful message.
202    #[error("SSE-C algorithm {algo:?} unsupported (only {SSE_C_ALGORITHM:?} is allowed)")]
203    CustomerKeyAlgorithmUnsupported { algo: String },
204    /// S4E3 body lacks an SSE-C key — caller passed `SseSource::Keyring`
205    /// when decrypting an SSE-C-encrypted object. service.rs should
206    /// translate this into the same "missing customer key" 400 that
207    /// AWS S3 returns when SSE-C headers are absent on a GET.
208    #[error("S4E3 frame requires SseSource::CustomerKey; got Keyring")]
209    CustomerKeyRequired,
210    /// Inverse: client sent SSE-C headers on a GET for an object stored
211    /// without SSE-C. The supplied key has no role in decryption, but
212    /// AWS S3 actually 400s in this case ("expected an unencrypted
213    /// object" / "extraneous SSE-C headers"), so we mirror that.
214    #[error("S4E1/S4E2 frame stored without SSE-C; SseSource::CustomerKey is unexpected")]
215    CustomerKeyUnexpected,
216    // --- v0.5 #28: SSE-KMS specific errors ---
217    /// `decrypt` (sync) was handed an S4E4 body. SSE-KMS unwrap is
218    /// async (it round-trips to the KMS backend), so callers must
219    /// peek the magic with [`peek_magic`] and dispatch S4E4 frames to
220    /// [`decrypt_with_kms`] instead. service.rs's GET handler does
221    /// this; tests / direct callers may hit this if they forget.
222    #[error(
223        "S4E4 (SSE-KMS) body requires async decrypt — call decrypt_with_kms() instead of decrypt()"
224    )]
225    KmsAsyncRequired,
226    /// S4E4 frame is shorter than the minimum-possible header (38
227    /// bytes for an empty `key_id` + empty `wrapped_dek`, which is
228    /// itself impossible — we just sanity-check the floor).
229    #[error("S4E4 frame too short ({got} bytes; need at least {min})")]
230    KmsFrameTooShort { got: usize, min: usize },
231    /// S4E4 declared a `key_id_len` or `wrapped_dek_len` that runs
232    /// past the end of the body. Almost certainly truncation /
233    /// corruption rather than tampering (tampering would fail the
234    /// AES-GCM tag instead).
235    #[error("S4E4 frame field length out of bounds: {what}")]
236    KmsFrameFieldOob { what: &'static str },
237    /// `key_id` field of an S4E4 frame is not valid UTF-8. We require
238    /// UTF-8 because `LocalKms` uses the basename of a `.kek` file
239    /// (which is OS-string-but-typically-UTF-8) and AWS KMS uses ARNs
240    /// (which are ASCII).
241    #[error("S4E4 key_id is not valid UTF-8")]
242    KmsKeyIdNotUtf8,
243    /// service.rs handed `decrypt_with_kms` a `WrappedDek` whose
244    /// `key_id` doesn't match the one stored in the S4E4 frame. This
245    /// is an integration bug (caller is meant to pull the wrapped
246    /// DEK *from the frame*, not from somewhere else), surface as a
247    /// distinct error so it shows up in tests rather than silently
248    /// failing the AES-GCM tag.
249    #[error(
250        "S4E4 SseSource::Kms wrapped DEK key_id {supplied:?} doesn't match frame key_id {stored:?}"
251    )]
252    KmsWrappedDekMismatch {
253        supplied: String,
254        stored: String,
255    },
256    /// SSE-KMS path got a non-Kms `SseSource` for an S4E4 body. The
257    /// async dispatch in `decrypt_with_kms` re-derives the source
258    /// internally so this can only happen if a future caller passes
259    /// `SseSource::Keyring` / `CustomerKey` to a path that expected
260    /// `Kms` — kept around for symmetry with the other "wrong source"
261    /// errors.
262    #[error("S4E4 frame requires SseSource::Kms")]
263    KmsRequired,
264    /// Pass-through for [`crate::kms::KmsError`] surfaced from
265    /// `KmsBackend::decrypt_dek` — boxed so the variant stays small.
266    #[error("KMS unwrap: {0}")]
267    KmsBackend(#[from] KmsError),
268}
269
270/// 32-byte symmetric key. `bytes` is `pub` so call sites can construct
271/// keys directly from already-validated bytes (e.g. KMS-decrypted DEKs)
272/// without going through the on-disk parser. Hold inside an `Arc` when
273/// sharing across handler tasks — `SseKeyring` does this internally.
274pub struct SseKey {
275    pub bytes: [u8; 32],
276}
277
278impl SseKey {
279    /// Load a 32-byte key from disk. Accepts three on-disk encodings:
280    /// raw 32 bytes, 64-char ASCII hex, or 44-char ASCII base64 (with or
281    /// without padding). Whitespace is trimmed.
282    pub fn from_path(path: &Path) -> Result<Self, SseError> {
283        let raw = std::fs::read(path).map_err(|source| SseError::KeyFileIo {
284            path: path.to_path_buf(),
285            source,
286        })?;
287        Self::from_bytes(&raw)
288    }
289
290    pub fn from_bytes(bytes: &[u8]) -> Result<Self, SseError> {
291        // Try raw first.
292        if bytes.len() == KEY_LEN {
293            let mut k = [0u8; KEY_LEN];
294            k.copy_from_slice(bytes);
295            return Ok(Self { bytes: k });
296        }
297        // Trim whitespace and try hex / base64.
298        let s = std::str::from_utf8(bytes).unwrap_or("").trim();
299        if s.len() == KEY_LEN * 2 && s.chars().all(|c| c.is_ascii_hexdigit()) {
300            let mut k = [0u8; KEY_LEN];
301            for (i, k_byte) in k.iter_mut().enumerate() {
302                *k_byte = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
303                    .map_err(|_| SseError::BadKeyLength { got: bytes.len() })?;
304            }
305            return Ok(Self { bytes: k });
306        }
307        if let Ok(decoded) =
308            base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s.as_bytes())
309            && decoded.len() == KEY_LEN
310        {
311            let mut k = [0u8; KEY_LEN];
312            k.copy_from_slice(&decoded);
313            return Ok(Self { bytes: k });
314        }
315        Err(SseError::BadKeyLength { got: bytes.len() })
316    }
317
318    fn as_aes_key(&self) -> &Key<Aes256Gcm> {
319        Key::<Aes256Gcm>::from_slice(&self.bytes)
320    }
321}
322
323impl std::fmt::Debug for SseKey {
324    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
325        f.debug_struct("SseKey")
326            .field("len", &KEY_LEN)
327            .field("key", &"<redacted>")
328            .finish()
329    }
330}
331
332/// v0.5 #29: a set of `SseKey`s indexed by `u16` key-id, plus a
333/// designated **active** id used for new encryptions. Rotation is just
334/// "add the new key, flip `active` to its id, leave the old keys for
335/// decryption-only". Cheap to clone (`Arc<SseKey>` per slot).
336#[derive(Clone)]
337pub struct SseKeyring {
338    active: u16,
339    keys: HashMap<u16, Arc<SseKey>>,
340}
341
342impl SseKeyring {
343    /// Create a keyring seeded with one key, immediately marked
344    /// active. Add older keys later via [`SseKeyring::add`] so the
345    /// gateway can still decrypt pre-rotation objects.
346    pub fn new(active: u16, key: Arc<SseKey>) -> Self {
347        let mut keys = HashMap::new();
348        keys.insert(active, key);
349        Self { active, keys }
350    }
351
352    /// Insert another key under id `id`. Does NOT change `active`. If
353    /// `id == active`, the slot is overwritten (useful for tests; in
354    /// production prefer minting a fresh id).
355    pub fn add(&mut self, id: u16, key: Arc<SseKey>) {
356        self.keys.insert(id, key);
357    }
358
359    /// Active (id, key) — used by [`encrypt_v2`] to pick the slot for
360    /// new objects.
361    pub fn active(&self) -> (u16, &SseKey) {
362        let id = self.active;
363        let key = self
364            .keys
365            .get(&id)
366            .expect("active key id must be present in keyring (constructor invariant)");
367        (id, key.as_ref())
368    }
369
370    /// Look up a key by id. Returns `None` for unknown ids — caller
371    /// should surface this as [`SseError::KeyNotInKeyring`].
372    pub fn get(&self, id: u16) -> Option<&SseKey> {
373        self.keys.get(&id).map(Arc::as_ref)
374    }
375}
376
377impl std::fmt::Debug for SseKeyring {
378    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
379        f.debug_struct("SseKeyring")
380            .field("active", &self.active)
381            .field("key_count", &self.keys.len())
382            .field("key_ids", &self.keys.keys().collect::<Vec<_>>())
383            .finish()
384    }
385}
386
387pub type SharedSseKeyring = Arc<SseKeyring>;
388
389/// Encrypt `plaintext` with the given key, producing the on-the-wire
390/// S4E1-framed output: `[magic 4][algo 1][reserved 3][nonce 12][tag 16][ciphertext]`.
391///
392/// Kept for back-compat: v0.4 callers that hand-built an `SseKey` (no
393/// keyring) still get the v1 frame. New code should use
394/// [`encrypt_v2`] which writes S4E2 and supports rotation on read.
395pub fn encrypt(key: &SseKey, plaintext: &[u8]) -> Bytes {
396    let cipher = Aes256Gcm::new(key.as_aes_key());
397    let mut nonce_bytes = [0u8; NONCE_LEN];
398    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
399    let nonce = Nonce::from_slice(&nonce_bytes);
400    // AAD = magic + algo. Tampering with either bumps the tag check.
401    let mut aad = [0u8; 8];
402    aad[..4].copy_from_slice(SSE_MAGIC_V1);
403    aad[4] = ALGO_AES_256_GCM;
404    let ct_with_tag = cipher
405        .encrypt(
406            nonce,
407            Payload {
408                msg: plaintext,
409                aad: &aad,
410            },
411        )
412        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
413    debug_assert!(ct_with_tag.len() >= TAG_LEN);
414    let split = ct_with_tag.len() - TAG_LEN;
415    let (ct, tag) = ct_with_tag.split_at(split);
416
417    let mut out = Vec::with_capacity(SSE_HEADER_BYTES + ct.len());
418    out.extend_from_slice(SSE_MAGIC_V1);
419    out.push(ALGO_AES_256_GCM);
420    out.extend_from_slice(&[0u8; 3]); // reserved
421    out.extend_from_slice(&nonce_bytes);
422    out.extend_from_slice(tag);
423    out.extend_from_slice(ct);
424    Bytes::from(out)
425}
426
427/// v0.5 #29: encrypt under the keyring's currently-active key, writing
428/// an S4E2-framed body (`[magic 4][algo 1][key_id 2 BE][reserved 1]
429/// [nonce 12][tag 16][ciphertext]`). The key-id is included in the
430/// AAD so flipping it fails the auth tag.
431pub fn encrypt_v2(plaintext: &[u8], keyring: &SseKeyring) -> Bytes {
432    let (key_id, key) = keyring.active();
433    let cipher = Aes256Gcm::new(key.as_aes_key());
434    let mut nonce_bytes = [0u8; NONCE_LEN];
435    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
436    let nonce = Nonce::from_slice(&nonce_bytes);
437    let aad = aad_v2(key_id);
438    let ct_with_tag = cipher
439        .encrypt(
440            nonce,
441            Payload {
442                msg: plaintext,
443                aad: &aad,
444            },
445        )
446        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
447    debug_assert!(ct_with_tag.len() >= TAG_LEN);
448    let split = ct_with_tag.len() - TAG_LEN;
449    let (ct, tag) = ct_with_tag.split_at(split);
450
451    let mut out = Vec::with_capacity(SSE_HEADER_BYTES + ct.len());
452    out.extend_from_slice(SSE_MAGIC_V2);
453    out.push(ALGO_AES_256_GCM);
454    out.extend_from_slice(&key_id.to_be_bytes()); // 2B BE key_id
455    out.push(0u8); // 1B reserved
456    out.extend_from_slice(&nonce_bytes);
457    out.extend_from_slice(tag);
458    out.extend_from_slice(ct);
459    Bytes::from(out)
460}
461
462fn aad_v1() -> [u8; 8] {
463    let mut aad = [0u8; 8];
464    aad[..4].copy_from_slice(SSE_MAGIC_V1);
465    aad[4] = ALGO_AES_256_GCM;
466    aad
467}
468
469fn aad_v2(key_id: u16) -> [u8; 8] {
470    let mut aad = [0u8; 8];
471    aad[..4].copy_from_slice(SSE_MAGIC_V2);
472    aad[4] = ALGO_AES_256_GCM;
473    aad[5..7].copy_from_slice(&key_id.to_be_bytes());
474    aad[7] = 0u8;
475    aad
476}
477
478/// AAD for S4E3 = magic (4) + algo (1) + key_md5 (16). Putting the
479/// fingerprint in the AAD means tampering with the stored MD5 (e.g. an
480/// attacker rewriting the header to match a *different* key they
481/// happen to know) breaks the AES-GCM tag — the wrong-key check isn't
482/// just a plain `==` we could be tricked past.
483fn aad_v3(key_md5: &[u8; KEY_MD5_LEN]) -> [u8; 4 + 1 + KEY_MD5_LEN] {
484    let mut aad = [0u8; 4 + 1 + KEY_MD5_LEN];
485    aad[..4].copy_from_slice(SSE_MAGIC_V3);
486    aad[4] = ALGO_AES_256_GCM;
487    aad[5..5 + KEY_MD5_LEN].copy_from_slice(key_md5);
488    aad
489}
490
491/// Parsed + verified SSE-C key material from the three customer
492/// headers. `key_md5` is the MD5 of `key` (we recompute and compare in
493/// [`parse_customer_key_headers`] — clients send their own to catch
494/// transport corruption, but we *trust* our own computation as the
495/// canonical fingerprint in the S4E3 frame).
496#[derive(Clone)]
497pub struct CustomerKeyMaterial {
498    pub key: [u8; KEY_LEN],
499    pub key_md5: [u8; KEY_MD5_LEN],
500}
501
502impl std::fmt::Debug for CustomerKeyMaterial {
503    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
504        // Don't leak the key into logs. The MD5 is a public fingerprint
505        // (S3 puts it on the wire), so that's safe to show.
506        f.debug_struct("CustomerKeyMaterial")
507            .field("key", &"<redacted>")
508            .field("key_md5_hex", &hex_lower(&self.key_md5))
509            .finish()
510    }
511}
512
513fn hex_lower(bytes: &[u8]) -> String {
514    let mut s = String::with_capacity(bytes.len() * 2);
515    for b in bytes {
516        s.push_str(&format!("{b:02x}"));
517    }
518    s
519}
520
521/// Source of the encryption key for [`encrypt_with_source`] /
522/// [`decrypt`]. SSE-S4 (server-managed, rotation-aware) goes through
523/// `Keyring`; SSE-C (customer-supplied) goes through `CustomerKey`.
524///
525/// Borrowed (not owned) so the caller can hold a long-lived
526/// `CustomerKeyMaterial` next to the request and just lend it for the
527/// duration of one PUT/GET.
528#[derive(Debug, Clone, Copy)]
529pub enum SseSource<'a> {
530    /// Server-managed keyring path → produces / consumes S4E1 (legacy)
531    /// or S4E2 (rotation-aware) frames.
532    Keyring(&'a SseKeyring),
533    /// Client-supplied AES-256 key + its MD5 fingerprint → produces /
534    /// consumes S4E3 frames. The server never persists the key; it
535    /// stores `key_md5` only.
536    CustomerKey {
537        key: &'a [u8; KEY_LEN],
538        key_md5: &'a [u8; KEY_MD5_LEN],
539    },
540    /// SSE-KMS envelope → produces / consumes S4E4 frames. The server
541    /// holds a per-object plaintext DEK (from a fresh
542    /// [`KmsBackend::generate_dek`] call) and the wrapped form to
543    /// persist alongside the body. The DEK is dropped after one
544    /// PUT/GET; only the wrapped form survives at rest.
545    Kms {
546        /// 32-byte plaintext DEK, used as the AES-GCM key.
547        dek: &'a [u8; KEY_LEN],
548        /// Wrapped form to persist in the S4E4 frame (PUT) or the one
549        /// read out of the frame (GET, after a successful unwrap).
550        wrapped: &'a WrappedDek,
551    },
552}
553
554/// Back-compat coercion: existing call sites pass `&SseKeyring`
555/// directly to [`decrypt`]. With this `From` impl the generic bound
556/// `Into<SseSource>` accepts `&SseKeyring` without the caller writing
557/// `.into()`, keeping v0.4 / v0.5 #29 service.rs callers compiling
558/// untouched while v0.5 #27 SSE-C callers pass `SseSource::CustomerKey`
559/// explicitly.
560impl<'a> From<&'a SseKeyring> for SseSource<'a> {
561    fn from(kr: &'a SseKeyring) -> Self {
562        SseSource::Keyring(kr)
563    }
564}
565
566/// service.rs holds keyring as `Option<Arc<SseKeyring>>` and unwraps to
567/// `&Arc<SseKeyring>` — let that coerce too, otherwise every existing
568/// call site needs `.as_ref()` boilerplate.
569impl<'a> From<&'a Arc<SseKeyring>> for SseSource<'a> {
570    fn from(kr: &'a Arc<SseKeyring>) -> Self {
571        SseSource::Keyring(kr.as_ref())
572    }
573}
574
575impl<'a> From<&'a CustomerKeyMaterial> for SseSource<'a> {
576    fn from(m: &'a CustomerKeyMaterial) -> Self {
577        SseSource::CustomerKey {
578            key: &m.key,
579            key_md5: &m.key_md5,
580        }
581    }
582}
583
584/// Parse the three AWS SSE-C headers and return verified key material.
585///
586/// Validates, in order:
587/// 1. `algorithm == "AES256"` (the only value AWS S3 defines).
588/// 2. `key_base64` decodes to exactly 32 bytes (AES-256 key length).
589/// 3. `key_md5_base64` decodes to exactly 16 bytes (MD5 digest length).
590/// 4. The actual MD5 of the decoded key matches the supplied MD5.
591///
592/// Step 4 catches transport corruption *and* a class of programming
593/// bugs where the client signs with one key but uploads another. AWS
594/// S3 also performs this check.
595pub fn parse_customer_key_headers(
596    algorithm: &str,
597    key_base64: &str,
598    key_md5_base64: &str,
599) -> Result<CustomerKeyMaterial, SseError> {
600    use base64::Engine as _;
601    if algorithm != SSE_C_ALGORITHM {
602        return Err(SseError::CustomerKeyAlgorithmUnsupported {
603            algo: algorithm.to_string(),
604        });
605    }
606    let key_bytes = base64::engine::general_purpose::STANDARD
607        .decode(key_base64.trim().as_bytes())
608        .map_err(|_| SseError::InvalidCustomerKey {
609            reason: "base64 decode of key",
610        })?;
611    if key_bytes.len() != KEY_LEN {
612        return Err(SseError::InvalidCustomerKey {
613            reason: "key length (must be 32 bytes after base64 decode)",
614        });
615    }
616    let supplied_md5 = base64::engine::general_purpose::STANDARD
617        .decode(key_md5_base64.trim().as_bytes())
618        .map_err(|_| SseError::InvalidCustomerKey {
619            reason: "base64 decode of key MD5",
620        })?;
621    if supplied_md5.len() != KEY_MD5_LEN {
622        return Err(SseError::InvalidCustomerKey {
623            reason: "key MD5 length (must be 16 bytes after base64 decode)",
624        });
625    }
626    let actual_md5 = compute_key_md5(&key_bytes);
627    // Constant-time compare — paranoia, MD5 is non-secret but the key
628    // it identifies is, so we don't want a timing oracle.
629    if !constant_time_eq(&actual_md5, &supplied_md5) {
630        return Err(SseError::InvalidCustomerKey {
631            reason: "supplied MD5 does not match MD5 of supplied key",
632        });
633    }
634    let mut key = [0u8; KEY_LEN];
635    key.copy_from_slice(&key_bytes);
636    let mut key_md5 = [0u8; KEY_MD5_LEN];
637    key_md5.copy_from_slice(&actual_md5);
638    Ok(CustomerKeyMaterial { key, key_md5 })
639}
640
641/// Convenience wrapper — compute the MD5 fingerprint of a 32-byte
642/// customer key. Callers that already have the bytes (e.g. derived
643/// from a KMS unwrap) can use this to construct a
644/// [`CustomerKeyMaterial`] directly.
645pub fn compute_key_md5(key: &[u8]) -> [u8; KEY_MD5_LEN] {
646    let mut h = Md5::new();
647    h.update(key);
648    let out = h.finalize();
649    let mut md5 = [0u8; KEY_MD5_LEN];
650    md5.copy_from_slice(&out);
651    md5
652}
653
654/// `subtle`-free constant-time byte slice equality. We only need this
655/// at one site (MD5 verification) so pulling `subtle` in feels excessive.
656fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
657    if a.len() != b.len() {
658        return false;
659    }
660    let mut acc: u8 = 0;
661    for (x, y) in a.iter().zip(b.iter()) {
662        acc |= x ^ y;
663    }
664    acc == 0
665}
666
667/// v0.5 #27: encrypt under whichever source the caller picked.
668///
669/// - `SseSource::Keyring` → delegates to [`encrypt_v2`] (S4E2 frame).
670/// - `SseSource::CustomerKey` → writes an S4E3 frame (no key persisted,
671///   just the MD5 fingerprint for GET-side verification).
672///
673/// service.rs picks the source per-request: SSE-C headers present →
674/// `CustomerKey`, otherwise (and only when `--sse-s4-key` is wired) →
675/// `Keyring`. Plaintext objects skip this function entirely.
676pub fn encrypt_with_source(plaintext: &[u8], source: SseSource<'_>) -> Bytes {
677    match source {
678        SseSource::Keyring(kr) => encrypt_v2(plaintext, kr),
679        SseSource::CustomerKey { key, key_md5 } => encrypt_v3(plaintext, key, key_md5),
680        SseSource::Kms { dek, wrapped } => encrypt_v4(plaintext, dek, wrapped),
681    }
682}
683
684fn encrypt_v3(
685    plaintext: &[u8],
686    key: &[u8; KEY_LEN],
687    key_md5: &[u8; KEY_MD5_LEN],
688) -> Bytes {
689    let aes_key = Key::<Aes256Gcm>::from_slice(key);
690    let cipher = Aes256Gcm::new(aes_key);
691    let mut nonce_bytes = [0u8; NONCE_LEN];
692    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
693    let nonce = Nonce::from_slice(&nonce_bytes);
694    let aad = aad_v3(key_md5);
695    let ct_with_tag = cipher
696        .encrypt(
697            nonce,
698            Payload {
699                msg: plaintext,
700                aad: &aad,
701            },
702        )
703        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
704    debug_assert!(ct_with_tag.len() >= TAG_LEN);
705    let split = ct_with_tag.len() - TAG_LEN;
706    let (ct, tag) = ct_with_tag.split_at(split);
707
708    let mut out = Vec::with_capacity(SSE_HEADER_BYTES_V3 + ct.len());
709    out.extend_from_slice(SSE_MAGIC_V3);
710    out.push(ALGO_AES_256_GCM);
711    out.extend_from_slice(key_md5);
712    out.extend_from_slice(&nonce_bytes);
713    out.extend_from_slice(tag);
714    out.extend_from_slice(ct);
715    Bytes::from(out)
716}
717
718/// v0.5 #29 + v0.5 #27: dispatch on the body's magic and decrypt under
719/// whichever source the caller supplied.
720///
721/// - `S4E1` / `S4E2` require `SseSource::Keyring` (return
722///   [`SseError::CustomerKeyRequired`] for `CustomerKey` — service.rs
723///   should map this to "extraneous SSE-C headers" 400).
724/// - `S4E3` requires `SseSource::CustomerKey` (return
725///   [`SseError::CustomerKeyUnexpected`] for `Keyring` — service.rs
726///   should map this to "missing SSE-C headers" 400).
727///
728/// Generic over `Into<SseSource>` so existing `decrypt(body, &keyring)`
729/// call sites compile unchanged via the `From<&SseKeyring>` impl above
730/// — only the new SSE-C path needs to type out
731/// `SseSource::CustomerKey { .. }`.
732///
733/// Distinct errors (`KeyNotInKeyring`, `DecryptFailed`,
734/// `WrongCustomerKey`) let operators tell rotation gaps, ciphertext
735/// tampering, and SSE-C key mismatch apart in audit logs.
736pub fn decrypt<'a, S: Into<SseSource<'a>>>(body: &[u8], source: S) -> Result<Bytes, SseError> {
737    let source = source.into();
738    // Outer short-check uses the smaller of the two header sizes
739    // (S4E1/S4E2 = 36 bytes). Anything below this can't be any valid
740    // SSE frame regardless of magic — keeps back-compat with v0.4 /
741    // v0.5 #29 callers that expected `TooShort` for absurdly short
742    // inputs even when the magic is garbage.
743    if body.len() < SSE_HEADER_BYTES {
744        return Err(SseError::TooShort { got: body.len() });
745    }
746    let mut magic = [0u8; 4];
747    magic.copy_from_slice(&body[..4]);
748    match &magic {
749        m if m == SSE_MAGIC_V1 || m == SSE_MAGIC_V2 => {
750            let keyring = match source {
751                SseSource::Keyring(kr) => kr,
752                SseSource::CustomerKey { .. } => return Err(SseError::CustomerKeyUnexpected),
753                // S4E1/E2 stored under the keyring → SseSource::Kms
754                // is just as nonsensical as CustomerKey here. Re-use
755                // the same "wrong source" error so service.rs can
756                // map both to AWS S3's "extraneous SSE-* headers"
757                // 400.
758                SseSource::Kms { .. } => return Err(SseError::CustomerKeyUnexpected),
759            };
760            if m == SSE_MAGIC_V1 {
761                decrypt_v1_with_keyring(body, keyring)
762            } else {
763                decrypt_v2_with_keyring(body, keyring)
764            }
765        }
766        m if m == SSE_MAGIC_V3 => {
767            // S4E3 has a larger 49-byte header, so re-check.
768            if body.len() < SSE_HEADER_BYTES_V3 {
769                return Err(SseError::TooShort { got: body.len() });
770            }
771            let (key, key_md5) = match source {
772                SseSource::CustomerKey { key, key_md5 } => (key, key_md5),
773                SseSource::Keyring(_) => return Err(SseError::CustomerKeyRequired),
774                SseSource::Kms { .. } => return Err(SseError::CustomerKeyRequired),
775            };
776            decrypt_v3(body, key, key_md5)
777        }
778        m if m == SSE_MAGIC_V4 => {
779            // SSE-KMS unwrap is async (KMS round-trip required).
780            // Caller must dispatch to `decrypt_with_kms` after
781            // peeking the magic — surface this as a distinct error
782            // rather than silently failing.
783            Err(SseError::KmsAsyncRequired)
784        }
785        _ => Err(SseError::BadMagic { got: magic }),
786    }
787}
788
789fn decrypt_v3(
790    body: &[u8],
791    key: &[u8; KEY_LEN],
792    supplied_md5: &[u8; KEY_MD5_LEN],
793) -> Result<Bytes, SseError> {
794    let algo = body[4];
795    if algo != ALGO_AES_256_GCM {
796        return Err(SseError::UnsupportedAlgo { tag: algo });
797    }
798    let mut stored_md5 = [0u8; KEY_MD5_LEN];
799    stored_md5.copy_from_slice(&body[5..5 + KEY_MD5_LEN]);
800    // Cheap fingerprint check first — if the supplied key has a
801    // different MD5 than what was used at PUT, fail fast with a
802    // dedicated error. AES-GCM auth would also catch this (different
803    // key → bad tag) but the bespoke error gives operators an audit
804    // signal distinct from "ciphertext was tampered with".
805    if !constant_time_eq(supplied_md5, &stored_md5) {
806        return Err(SseError::WrongCustomerKey);
807    }
808    let nonce_off = 5 + KEY_MD5_LEN;
809    let tag_off = nonce_off + NONCE_LEN;
810    let mut nonce_bytes = [0u8; NONCE_LEN];
811    nonce_bytes.copy_from_slice(&body[nonce_off..nonce_off + NONCE_LEN]);
812    let mut tag_bytes = [0u8; TAG_LEN];
813    tag_bytes.copy_from_slice(&body[tag_off..tag_off + TAG_LEN]);
814    let ct = &body[SSE_HEADER_BYTES_V3..];
815
816    let aad = aad_v3(&stored_md5);
817    let nonce = Nonce::from_slice(&nonce_bytes);
818    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
819    ct_with_tag.extend_from_slice(ct);
820    ct_with_tag.extend_from_slice(&tag_bytes);
821
822    let aes_key = Key::<Aes256Gcm>::from_slice(key);
823    let cipher = Aes256Gcm::new(aes_key);
824    let plain = cipher
825        .decrypt(
826            nonce,
827            Payload {
828                msg: &ct_with_tag,
829                aad: &aad,
830            },
831        )
832        .map_err(|_| SseError::DecryptFailed)?;
833    Ok(Bytes::from(plain))
834}
835
836/// AAD for S4E4 = magic (4) + algo (1) + key_id_len (1) + key_id +
837/// wrapped_dek_len (4 BE) + wrapped_dek. Putting the variable-length
838/// key_id and wrapped_dek into the AAD means an attacker cannot
839/// rewrite either field to redirect the gateway to a different KEK
840/// or wrapped DEK without invalidating the body's AES-GCM tag.
841///
842/// Length-prefixing key_id and wrapped_dek inside the AAD prevents a
843/// canonicalisation ambiguity: without the length prefix, an
844/// attacker could shift bytes between the two fields and produce the
845/// same AAD bytestream, defeating the per-field tampering check.
846fn aad_v4(key_id: &[u8], wrapped_dek: &[u8]) -> Vec<u8> {
847    let mut aad = Vec::with_capacity(4 + 1 + 1 + key_id.len() + 4 + wrapped_dek.len());
848    aad.extend_from_slice(SSE_MAGIC_V4);
849    aad.push(ALGO_AES_256_GCM);
850    aad.push(key_id.len() as u8);
851    aad.extend_from_slice(key_id);
852    aad.extend_from_slice(&(wrapped_dek.len() as u32).to_be_bytes());
853    aad.extend_from_slice(wrapped_dek);
854    aad
855}
856
857fn encrypt_v4(plaintext: &[u8], dek: &[u8; KEY_LEN], wrapped: &WrappedDek) -> Bytes {
858    // Pre-conditions: key_id must fit in a u8 length prefix and be
859    // non-empty (an empty id means we wouldn't be able to re-fetch
860    // the KEK on GET). wrapped_dek length fits in u32 by the same
861    // logic — at u32::MAX bytes you have bigger problems. We assert
862    // these in debug and silently truncate-or-panic in release; in
863    // practice key_id is a UUID or ARN (<256 chars) and wrapped_dek
864    // is 60 bytes (LocalKms) or ~200 bytes (AWS KMS).
865    assert!(
866        !wrapped.key_id.is_empty() && wrapped.key_id.len() <= u8::MAX as usize,
867        "S4E4 key_id must be 1..=255 bytes (got {})",
868        wrapped.key_id.len()
869    );
870    assert!(
871        wrapped.ciphertext.len() <= u32::MAX as usize,
872        "S4E4 wrapped_dek longer than u32::MAX",
873    );
874
875    let aes_key = Key::<Aes256Gcm>::from_slice(dek);
876    let cipher = Aes256Gcm::new(aes_key);
877    let mut nonce_bytes = [0u8; NONCE_LEN];
878    rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
879    let nonce = Nonce::from_slice(&nonce_bytes);
880    let aad = aad_v4(wrapped.key_id.as_bytes(), &wrapped.ciphertext);
881    let ct_with_tag = cipher
882        .encrypt(
883            nonce,
884            Payload {
885                msg: plaintext,
886                aad: &aad,
887            },
888        )
889        .expect("aes-gcm encrypt cannot fail with a 32-byte key");
890    debug_assert!(ct_with_tag.len() >= TAG_LEN);
891    let split = ct_with_tag.len() - TAG_LEN;
892    let (ct, tag) = ct_with_tag.split_at(split);
893
894    let key_id_bytes = wrapped.key_id.as_bytes();
895    let mut out = Vec::with_capacity(
896        4 + 1 + 1 + key_id_bytes.len() + 4 + wrapped.ciphertext.len() + NONCE_LEN + TAG_LEN + ct.len(),
897    );
898    out.extend_from_slice(SSE_MAGIC_V4);
899    out.push(ALGO_AES_256_GCM);
900    out.push(key_id_bytes.len() as u8);
901    out.extend_from_slice(key_id_bytes);
902    out.extend_from_slice(&(wrapped.ciphertext.len() as u32).to_be_bytes());
903    out.extend_from_slice(&wrapped.ciphertext);
904    out.extend_from_slice(&nonce_bytes);
905    out.extend_from_slice(tag);
906    out.extend_from_slice(ct);
907    Bytes::from(out)
908}
909
910/// Parsed view of an S4E4 frame's variable-length header. Returned
911/// by [`parse_s4e4_header`] so both the async [`decrypt_with_kms`]
912/// path and any future inspection code (e.g. an admin tool that
913/// needs to enumerate object → KMS-key bindings) can reuse the same
914/// parser without re-implementing offset math.
915#[derive(Debug)]
916pub struct S4E4Header<'a> {
917    pub key_id: &'a str,
918    pub wrapped_dek: &'a [u8],
919    pub nonce: &'a [u8],
920    pub tag: &'a [u8],
921    pub ciphertext: &'a [u8],
922}
923
924/// Parse the (variable-length) S4E4 header. Pure byte-shuffling — no
925/// crypto, no KMS round-trip. Returns errors on truncation /
926/// out-of-bounds field lengths / non-UTF-8 key_id.
927pub fn parse_s4e4_header(body: &[u8]) -> Result<S4E4Header<'_>, SseError> {
928    // Minimum: magic(4) + algo(1) + key_id_len(1) + key_id(>=1) +
929    // wrapped_dek_len(4) + wrapped_dek(>=1) + nonce(12) + tag(16)
930    // = 40 bytes. We use a slightly looser floor here (bytes for
931    // empty fields = 38) and let the per-field bounds checks below
932    // catch the actual short reads.
933    const S4E4_MIN: usize = 4 + 1 + 1 + 4 + NONCE_LEN + TAG_LEN; // 38
934    if body.len() < S4E4_MIN {
935        return Err(SseError::KmsFrameTooShort {
936            got: body.len(),
937            min: S4E4_MIN,
938        });
939    }
940    let magic = &body[..4];
941    if magic != SSE_MAGIC_V4 {
942        let mut got = [0u8; 4];
943        got.copy_from_slice(magic);
944        return Err(SseError::BadMagic { got });
945    }
946    let algo = body[4];
947    if algo != ALGO_AES_256_GCM {
948        return Err(SseError::UnsupportedAlgo { tag: algo });
949    }
950    let key_id_len = body[5] as usize;
951    let key_id_off: usize = 6;
952    let key_id_end = key_id_off
953        .checked_add(key_id_len)
954        .ok_or(SseError::KmsFrameFieldOob { what: "key_id_len" })?;
955    if key_id_end + 4 > body.len() {
956        return Err(SseError::KmsFrameFieldOob { what: "key_id" });
957    }
958    let key_id = std::str::from_utf8(&body[key_id_off..key_id_end])
959        .map_err(|_| SseError::KmsKeyIdNotUtf8)?;
960    let wrapped_len_off = key_id_end;
961    let wrapped_dek_len = u32::from_be_bytes([
962        body[wrapped_len_off],
963        body[wrapped_len_off + 1],
964        body[wrapped_len_off + 2],
965        body[wrapped_len_off + 3],
966    ]) as usize;
967    let wrapped_off = wrapped_len_off + 4;
968    let wrapped_end = wrapped_off
969        .checked_add(wrapped_dek_len)
970        .ok_or(SseError::KmsFrameFieldOob { what: "wrapped_dek_len" })?;
971    if wrapped_end + NONCE_LEN + TAG_LEN > body.len() {
972        return Err(SseError::KmsFrameFieldOob { what: "wrapped_dek" });
973    }
974    let wrapped_dek = &body[wrapped_off..wrapped_end];
975    let nonce_off = wrapped_end;
976    let tag_off = nonce_off + NONCE_LEN;
977    let ct_off = tag_off + TAG_LEN;
978    let nonce = &body[nonce_off..nonce_off + NONCE_LEN];
979    let tag = &body[tag_off..tag_off + TAG_LEN];
980    let ciphertext = &body[ct_off..];
981    Ok(S4E4Header {
982        key_id,
983        wrapped_dek,
984        nonce,
985        tag,
986        ciphertext,
987    })
988}
989
990/// Async decrypt for S4E4 (SSE-KMS) bodies. Caller supplies the KMS
991/// backend; this function parses the frame, calls
992/// `kms.decrypt_dek(...)` to unwrap the DEK, then runs AES-256-GCM
993/// to recover the plaintext.
994///
995/// service.rs's GET handler should peek the magic with [`peek_magic`]
996/// and dispatch:
997///
998/// - `Some("S4E4")` → `decrypt_with_kms(blob, &*kms).await`
999/// - everything else → existing sync `decrypt(blob, source)`
1000///
1001/// Note: we don't go through `SseSource::Kms` here because the
1002/// wrapped DEK + key_id come from the frame itself, not from the
1003/// request — the `SseSource` is built for sync paths where the
1004/// caller already knows the key.
1005pub async fn decrypt_with_kms(
1006    body: &[u8],
1007    kms: &dyn KmsBackend,
1008) -> Result<Bytes, SseError> {
1009    let hdr = parse_s4e4_header(body)?;
1010    let wrapped = WrappedDek {
1011        key_id: hdr.key_id.to_string(),
1012        ciphertext: hdr.wrapped_dek.to_vec(),
1013    };
1014    let dek_vec = kms.decrypt_dek(&wrapped).await?;
1015    if dek_vec.len() != KEY_LEN {
1016        // KMS returned a non-32-byte plaintext. AES-256 needs exactly
1017        // 32 bytes. This shouldn't happen with `KeySpec=AES_256` but
1018        // surface as a backend error so it's auditable rather than
1019        // panicking.
1020        return Err(SseError::KmsBackend(KmsError::BackendUnavailable {
1021            message: format!(
1022                "KMS returned {} byte DEK; expected {KEY_LEN}",
1023                dek_vec.len()
1024            ),
1025        }));
1026    }
1027    let mut dek = [0u8; KEY_LEN];
1028    dek.copy_from_slice(&dek_vec);
1029
1030    let aad = aad_v4(hdr.key_id.as_bytes(), hdr.wrapped_dek);
1031    let aes_key = Key::<Aes256Gcm>::from_slice(&dek);
1032    let cipher = Aes256Gcm::new(aes_key);
1033    let nonce = Nonce::from_slice(hdr.nonce);
1034    let mut ct_with_tag = Vec::with_capacity(hdr.ciphertext.len() + TAG_LEN);
1035    ct_with_tag.extend_from_slice(hdr.ciphertext);
1036    ct_with_tag.extend_from_slice(hdr.tag);
1037    let plain = cipher
1038        .decrypt(
1039            nonce,
1040            Payload {
1041                msg: &ct_with_tag,
1042                aad: &aad,
1043            },
1044        )
1045        .map_err(|_| SseError::DecryptFailed)?;
1046    Ok(Bytes::from(plain))
1047}
1048
1049fn decrypt_v1_with_keyring(body: &[u8], keyring: &SseKeyring) -> Result<Bytes, SseError> {
1050    let algo = body[4];
1051    if algo != ALGO_AES_256_GCM {
1052        return Err(SseError::UnsupportedAlgo { tag: algo });
1053    }
1054    // body[5..8] reserved (must be ignored — v0.4 wrote zeros, but we
1055    // didn't auth them so we can't insist on it).
1056    let mut nonce_bytes = [0u8; NONCE_LEN];
1057    nonce_bytes.copy_from_slice(&body[8..8 + NONCE_LEN]);
1058    let mut tag_bytes = [0u8; TAG_LEN];
1059    tag_bytes.copy_from_slice(&body[8 + NONCE_LEN..SSE_HEADER_BYTES]);
1060    let ct = &body[SSE_HEADER_BYTES..];
1061
1062    let aad = aad_v1();
1063    let nonce = Nonce::from_slice(&nonce_bytes);
1064    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
1065    ct_with_tag.extend_from_slice(ct);
1066    ct_with_tag.extend_from_slice(&tag_bytes);
1067
1068    // Active key first, then any others. v0.4 deployments that flip to
1069    // v0.5 with their original key as active hit this path on the
1070    // first try for every legacy object.
1071    let (active_id, _active_key) = keyring.active();
1072    let mut ids: Vec<u16> = keyring.keys.keys().copied().collect();
1073    ids.sort_by_key(|id| if *id == active_id { 0 } else { 1 });
1074    for id in ids {
1075        let key = keyring.get(id).expect("id came from keyring iteration");
1076        let cipher = Aes256Gcm::new(key.as_aes_key());
1077        if let Ok(plain) = cipher.decrypt(
1078            nonce,
1079            Payload {
1080                msg: &ct_with_tag,
1081                aad: &aad,
1082            },
1083        ) {
1084            return Ok(Bytes::from(plain));
1085        }
1086    }
1087    Err(SseError::DecryptFailed)
1088}
1089
1090fn decrypt_v2_with_keyring(body: &[u8], keyring: &SseKeyring) -> Result<Bytes, SseError> {
1091    let algo = body[4];
1092    if algo != ALGO_AES_256_GCM {
1093        return Err(SseError::UnsupportedAlgo { tag: algo });
1094    }
1095    let key_id = u16::from_be_bytes([body[5], body[6]]);
1096    // body[7] reserved (1B), authenticated as 0 via AAD.
1097    let key = keyring
1098        .get(key_id)
1099        .ok_or(SseError::KeyNotInKeyring { id: key_id })?;
1100    let mut nonce_bytes = [0u8; NONCE_LEN];
1101    nonce_bytes.copy_from_slice(&body[8..8 + NONCE_LEN]);
1102    let mut tag_bytes = [0u8; TAG_LEN];
1103    tag_bytes.copy_from_slice(&body[8 + NONCE_LEN..SSE_HEADER_BYTES]);
1104    let ct = &body[SSE_HEADER_BYTES..];
1105
1106    let aad = aad_v2(key_id);
1107    let nonce = Nonce::from_slice(&nonce_bytes);
1108    let mut ct_with_tag = Vec::with_capacity(ct.len() + TAG_LEN);
1109    ct_with_tag.extend_from_slice(ct);
1110    ct_with_tag.extend_from_slice(&tag_bytes);
1111    let cipher = Aes256Gcm::new(key.as_aes_key());
1112    let plain = cipher
1113        .decrypt(
1114            nonce,
1115            Payload {
1116                msg: &ct_with_tag,
1117                aad: &aad,
1118            },
1119        )
1120        .map_err(|_| SseError::DecryptFailed)?;
1121    Ok(Bytes::from(plain))
1122}
1123
1124/// Detect whether `body` is SSE-S4 encrypted (S4E1, S4E2, S4E3, or
1125/// S4E4) by sniffing the first 4 magic bytes. Used by the GET path
1126/// to decide whether to run decryption before frame parsing.
1127///
1128/// We require a length check that's safe for *any* of the four
1129/// frames — `SSE_HEADER_BYTES` (36) is the smallest valid header
1130/// (S4E1 / S4E2). S4E3 is 49 bytes; S4E4 is variable but always >=
1131/// 38 bytes. The per-frame decrypt path re-checks the appropriate
1132/// minimum, so this 36-byte gate is just a fast rejection of
1133/// obviously-too-short bodies.
1134pub fn looks_encrypted(body: &[u8]) -> bool {
1135    if body.len() < SSE_HEADER_BYTES {
1136        return false;
1137    }
1138    let m = &body[..4];
1139    m == SSE_MAGIC_V1 || m == SSE_MAGIC_V2 || m == SSE_MAGIC_V3 || m == SSE_MAGIC_V4
1140}
1141
1142/// Peek the SSE-S4 magic at the front of `body`, returning a
1143/// stringified frame variant identifier or `None` if `body` is not
1144/// recognized as SSE-S4. Used by the GET path to dispatch between
1145/// the sync [`decrypt`] (S4E1/E2/E3) and the async
1146/// [`decrypt_with_kms`] (S4E4).
1147///
1148/// Returns the same length-gated result as [`looks_encrypted`]: any
1149/// body shorter than `SSE_HEADER_BYTES` (36 bytes) returns `None`,
1150/// so the caller can use this as both the "is encrypted" signal and
1151/// the "which frame" signal in one cheap byte-comparison.
1152pub fn peek_magic(body: &[u8]) -> Option<&'static str> {
1153    if body.len() < SSE_HEADER_BYTES {
1154        return None;
1155    }
1156    match &body[..4] {
1157        m if m == SSE_MAGIC_V1 => Some("S4E1"),
1158        m if m == SSE_MAGIC_V2 => Some("S4E2"),
1159        m if m == SSE_MAGIC_V3 => Some("S4E3"),
1160        m if m == SSE_MAGIC_V4 => Some("S4E4"),
1161        _ => None,
1162    }
1163}
1164
1165pub type SharedSseKey = Arc<SseKey>;
1166
1167#[cfg(test)]
1168mod tests {
1169    use super::*;
1170
1171    fn key32(seed: u8) -> Arc<SseKey> {
1172        Arc::new(SseKey::from_bytes(&[seed; 32]).unwrap())
1173    }
1174
1175    fn keyring_single(seed: u8) -> SseKeyring {
1176        SseKeyring::new(1, key32(seed))
1177    }
1178
1179    #[test]
1180    fn roundtrip_basic_v1() {
1181        // back-compat single-key API — still works.
1182        let k = SseKey::from_bytes(&[7u8; 32]).unwrap();
1183        let pt = b"the quick brown fox jumps over the lazy dog";
1184        let ct = encrypt(&k, pt);
1185        assert!(looks_encrypted(&ct));
1186        assert_eq!(&ct[..4], SSE_MAGIC_V1);
1187        assert_eq!(ct[4], ALGO_AES_256_GCM);
1188        assert_eq!(ct.len(), SSE_HEADER_BYTES + pt.len());
1189        // decrypt via single-key keyring
1190        let kr = SseKeyring::new(1, Arc::new(k));
1191        let pt2 = decrypt(&ct, &kr).unwrap();
1192        assert_eq!(pt2.as_ref(), pt);
1193    }
1194
1195    #[test]
1196    fn s4e2_roundtrip_active_key() {
1197        let kr = keyring_single(7);
1198        let pt = b"S4E2 active-key roundtrip";
1199        let ct = encrypt_v2(pt, &kr);
1200        assert_eq!(&ct[..4], SSE_MAGIC_V2);
1201        assert_eq!(ct[4], ALGO_AES_256_GCM);
1202        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1, "key_id BE");
1203        assert_eq!(ct[7], 0, "reserved byte");
1204        assert_eq!(ct.len(), SSE_HEADER_BYTES + pt.len());
1205        assert!(looks_encrypted(&ct));
1206        let pt2 = decrypt(&ct, &kr).unwrap();
1207        assert_eq!(pt2.as_ref(), pt);
1208    }
1209
1210    #[test]
1211    fn decrypt_s4e1_via_active_only_keyring() {
1212        // v0.4 wrote S4E1 with key K; v0.5 keyring has K as the only
1213        // (active) key. Decrypt must succeed.
1214        let k_arc = key32(11);
1215        let legacy_ct = encrypt(&k_arc, b"v0.4 vintage object");
1216        assert_eq!(&legacy_ct[..4], SSE_MAGIC_V1);
1217        let kr = SseKeyring::new(1, Arc::clone(&k_arc));
1218        let plain = decrypt(&legacy_ct, &kr).unwrap();
1219        assert_eq!(plain.as_ref(), b"v0.4 vintage object");
1220    }
1221
1222    #[test]
1223    fn decrypt_s4e2_under_old_key_after_rotation() {
1224        // Rotation flow: object was encrypted under key id=1 when 1
1225        // was active. Operator rotates to active=2 and keeps 1 in the
1226        // keyring. The S4E2 body must still decrypt.
1227        let k1 = key32(1);
1228        let k2 = key32(2);
1229        let mut kr_old = SseKeyring::new(1, Arc::clone(&k1));
1230        let ct = encrypt_v2(b"old-rotation object", &kr_old);
1231        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1);
1232
1233        // After rotation: active=2, but key 1 still in ring.
1234        kr_old.add(2, Arc::clone(&k2));
1235        let mut kr_new = SseKeyring::new(2, Arc::clone(&k2));
1236        kr_new.add(1, Arc::clone(&k1));
1237
1238        let plain = decrypt(&ct, &kr_new).unwrap();
1239        assert_eq!(plain.as_ref(), b"old-rotation object");
1240
1241        // And new PUTs go to id 2 (active).
1242        let new_ct = encrypt_v2(b"new-rotation object", &kr_new);
1243        assert_eq!(u16::from_be_bytes([new_ct[5], new_ct[6]]), 2);
1244        let plain_new = decrypt(&new_ct, &kr_new).unwrap();
1245        assert_eq!(plain_new.as_ref(), b"new-rotation object");
1246    }
1247
1248    #[test]
1249    fn s4e2_unknown_key_id_errors() {
1250        let kr = keyring_single(3); // only id=1 present
1251        let kr_other = SseKeyring::new(99, key32(3));
1252        let ct = encrypt_v2(b"x", &kr_other); // body claims key_id=99
1253        let err = decrypt(&ct, &kr).unwrap_err();
1254        assert!(
1255            matches!(err, SseError::KeyNotInKeyring { id: 99 }),
1256            "got {err:?}"
1257        );
1258    }
1259
1260    #[test]
1261    fn s4e2_tampered_key_id_fails_auth() {
1262        let kr = SseKeyring::new(1, key32(4));
1263        let mut kr_with_2 = kr.clone();
1264        kr_with_2.add(2, key32(5)); // a real but wrong key under id=2
1265        let mut ct = encrypt_v2(b"do not flip my key id", &kr).to_vec();
1266        // Flip key_id from 1 → 2 in the header. The keyring HAS a key
1267        // for 2, so the lookup succeeds — but AAD authenticates the
1268        // original key_id, so AES-GCM tag verification must fail.
1269        assert_eq!(u16::from_be_bytes([ct[5], ct[6]]), 1);
1270        ct[5] = 0;
1271        ct[6] = 2;
1272        let err = decrypt(&ct, &kr_with_2).unwrap_err();
1273        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
1274    }
1275
1276    #[test]
1277    fn s4e2_tampered_ciphertext_fails() {
1278        let kr = SseKeyring::new(7, key32(9));
1279        let mut ct = encrypt_v2(b"secret message v2", &kr).to_vec();
1280        let last = ct.len() - 1;
1281        ct[last] ^= 0x01;
1282        let err = decrypt(&ct, &kr).unwrap_err();
1283        assert!(matches!(err, SseError::DecryptFailed));
1284    }
1285
1286    #[test]
1287    fn s4e2_tampered_algo_byte_fails() {
1288        let kr = SseKeyring::new(1, key32(2));
1289        let mut ct = encrypt_v2(b"hi", &kr).to_vec();
1290        ct[4] = 99;
1291        let err = decrypt(&ct, &kr).unwrap_err();
1292        assert!(matches!(err, SseError::UnsupportedAlgo { tag: 99 }));
1293    }
1294
1295    #[test]
1296    fn wrong_key_fails_v1_via_keyring() {
1297        // S4E1 written under key K1; keyring has only K2 → DecryptFailed.
1298        let k1 = SseKey::from_bytes(&[1u8; 32]).unwrap();
1299        let ct = encrypt(&k1, b"secret");
1300        let kr_wrong = SseKeyring::new(1, Arc::new(SseKey::from_bytes(&[2u8; 32]).unwrap()));
1301        let err = decrypt(&ct, &kr_wrong).unwrap_err();
1302        assert!(matches!(err, SseError::DecryptFailed));
1303    }
1304
1305    #[test]
1306    fn rejects_short_body() {
1307        let kr = SseKeyring::new(1, key32(1));
1308        let err = decrypt(b"short", &kr).unwrap_err();
1309        assert!(matches!(err, SseError::TooShort { got: 5 }));
1310    }
1311
1312    #[test]
1313    fn looks_encrypted_passthrough_returns_false() {
1314        // S4F2 frame magic, NOT S4E1 / S4E2 — must not be confused.
1315        let f2 = b"S4F2\x01\x00\x00\x00........................................";
1316        assert!(!looks_encrypted(f2));
1317        assert!(!looks_encrypted(b""));
1318    }
1319
1320    #[test]
1321    fn looks_encrypted_detects_both_v1_and_v2() {
1322        let kr = SseKeyring::new(1, key32(8));
1323        let v1 = encrypt(&SseKey::from_bytes(&[8u8; 32]).unwrap(), b"x");
1324        let v2 = encrypt_v2(b"x", &kr);
1325        assert!(looks_encrypted(&v1));
1326        assert!(looks_encrypted(&v2));
1327    }
1328
1329    #[test]
1330    fn key_from_hex_string() {
1331        let bad =
1332            SseKey::from_bytes(b"0102030405060708090a0b0c0d0e0f10111213141516171819202122232425")
1333                .unwrap_err();
1334        assert!(matches!(bad, SseError::BadKeyLength { .. }));
1335        let good = b"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
1336        let _ = SseKey::from_bytes(good).expect("64-char hex should parse");
1337    }
1338
1339    #[test]
1340    fn encrypt_v2_uses_random_nonce() {
1341        let kr = SseKeyring::new(1, key32(3));
1342        let pt = b"deterministic input";
1343        let a = encrypt_v2(pt, &kr);
1344        let b = encrypt_v2(pt, &kr);
1345        assert_ne!(a, b, "nonce must be random per-call");
1346    }
1347
1348    #[test]
1349    fn keyring_active_and_get() {
1350        let k1 = key32(1);
1351        let k2 = key32(2);
1352        let mut kr = SseKeyring::new(1, Arc::clone(&k1));
1353        kr.add(2, Arc::clone(&k2));
1354        let (id, active) = kr.active();
1355        assert_eq!(id, 1);
1356        assert_eq!(active.bytes, [1u8; 32]);
1357        assert!(kr.get(2).is_some());
1358        assert!(kr.get(3).is_none());
1359    }
1360
1361    // -----------------------------------------------------------------
1362    // v0.5 #27 — SSE-C (customer-provided key, S4E3 frame) tests
1363    // -----------------------------------------------------------------
1364
1365    use base64::Engine as _;
1366
1367    fn cust_key(seed: u8) -> CustomerKeyMaterial {
1368        let key = [seed; KEY_LEN];
1369        let key_md5 = compute_key_md5(&key);
1370        CustomerKeyMaterial { key, key_md5 }
1371    }
1372
1373    #[test]
1374    fn s4e3_roundtrip_happy_path() {
1375        let m = cust_key(42);
1376        let pt = b"top-secret SSE-C payload";
1377        let ct = encrypt_with_source(
1378            pt,
1379            SseSource::CustomerKey {
1380                key: &m.key,
1381                key_md5: &m.key_md5,
1382            },
1383        );
1384        // Frame inspection.
1385        assert_eq!(&ct[..4], SSE_MAGIC_V3);
1386        assert_eq!(ct[4], ALGO_AES_256_GCM);
1387        assert_eq!(&ct[5..5 + KEY_MD5_LEN], &m.key_md5);
1388        assert_eq!(ct.len(), SSE_HEADER_BYTES_V3 + pt.len());
1389        assert!(looks_encrypted(&ct));
1390        // Decrypt round-trip.
1391        let plain = decrypt(
1392            &ct,
1393            SseSource::CustomerKey {
1394                key: &m.key,
1395                key_md5: &m.key_md5,
1396            },
1397        )
1398        .unwrap();
1399        assert_eq!(plain.as_ref(), pt);
1400        // And via the From impl on &CustomerKeyMaterial.
1401        let plain2 = decrypt(&ct, &m).unwrap();
1402        assert_eq!(plain2.as_ref(), pt);
1403    }
1404
1405    #[test]
1406    fn s4e3_wrong_key_yields_wrong_customer_key_error() {
1407        let m = cust_key(1);
1408        let other = cust_key(2);
1409        let ct = encrypt_with_source(b"payload", (&m).into());
1410        let err = decrypt(
1411            &ct,
1412            SseSource::CustomerKey {
1413                key: &other.key,
1414                key_md5: &other.key_md5,
1415            },
1416        )
1417        .unwrap_err();
1418        assert!(matches!(err, SseError::WrongCustomerKey), "got {err:?}");
1419    }
1420
1421    #[test]
1422    fn s4e3_tampered_stored_md5_is_caught() {
1423        // Attacker rewrites the stored MD5 to match a key they know.
1424        // Even though the supplied (attacker) key matches the rewritten
1425        // MD5, AES-GCM authenticates the ORIGINAL md5 via AAD, so the
1426        // tag check fails. Surface: WrongCustomerKey if the supplied
1427        // md5 != stored md5 (this test), or DecryptFailed if attacker
1428        // also rewrites their supplied md5 to match.
1429        let m = cust_key(7);
1430        let mut ct = encrypt_with_source(b"victim payload", (&m).into()).to_vec();
1431        // Flip a byte in the stored fingerprint.
1432        ct[5] ^= 0x55;
1433        // Client supplies the original (unmodified) key + md5.
1434        let err = decrypt(
1435            &ct,
1436            SseSource::CustomerKey {
1437                key: &m.key,
1438                key_md5: &m.key_md5,
1439            },
1440        )
1441        .unwrap_err();
1442        assert!(matches!(err, SseError::WrongCustomerKey), "got {err:?}");
1443    }
1444
1445    #[test]
1446    fn s4e3_tampered_md5_with_matching_supplied_md5_fails_aead() {
1447        // Both stored md5 AND supplied md5 are flipped to the same bogus
1448        // value. The fingerprint check passes (they match) but AAD
1449        // authenticates the *original* md5, so AES-GCM fails.
1450        let m = cust_key(3);
1451        let mut ct = encrypt_with_source(b"x", (&m).into()).to_vec();
1452        ct[5] ^= 0xFF;
1453        let mut bogus_md5 = m.key_md5;
1454        bogus_md5[0] ^= 0xFF;
1455        let err = decrypt(
1456            &ct,
1457            SseSource::CustomerKey {
1458                key: &m.key,
1459                key_md5: &bogus_md5,
1460            },
1461        )
1462        .unwrap_err();
1463        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
1464    }
1465
1466    #[test]
1467    fn s4e3_tampered_ciphertext_fails_aead() {
1468        let m = cust_key(8);
1469        let mut ct = encrypt_with_source(b"sealed message", (&m).into()).to_vec();
1470        let last = ct.len() - 1;
1471        ct[last] ^= 0x01;
1472        let err = decrypt(&ct, &m).unwrap_err();
1473        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
1474    }
1475
1476    #[test]
1477    fn s4e3_tampered_algo_byte_rejected() {
1478        let m = cust_key(9);
1479        let mut ct = encrypt_with_source(b"x", (&m).into()).to_vec();
1480        ct[4] = 99;
1481        let err = decrypt(&ct, &m).unwrap_err();
1482        assert!(matches!(err, SseError::UnsupportedAlgo { tag: 99 }));
1483    }
1484
1485    #[test]
1486    fn s4e3_uses_random_nonce() {
1487        let m = cust_key(10);
1488        let a = encrypt_with_source(b"deterministic input", (&m).into());
1489        let b = encrypt_with_source(b"deterministic input", (&m).into());
1490        assert_ne!(a, b, "nonce must be random per-call");
1491    }
1492
1493    #[test]
1494    fn parse_customer_key_headers_happy_path() {
1495        let key = [11u8; KEY_LEN];
1496        let md5 = compute_key_md5(&key);
1497        let key_b64 = base64::engine::general_purpose::STANDARD.encode(key);
1498        let md5_b64 = base64::engine::general_purpose::STANDARD.encode(md5);
1499        let m = parse_customer_key_headers("AES256", &key_b64, &md5_b64).unwrap();
1500        assert_eq!(m.key, key);
1501        assert_eq!(m.key_md5, md5);
1502    }
1503
1504    #[test]
1505    fn parse_customer_key_headers_rejects_wrong_algorithm() {
1506        let key = [1u8; KEY_LEN];
1507        let md5 = compute_key_md5(&key);
1508        let kb = base64::engine::general_purpose::STANDARD.encode(key);
1509        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
1510        let err = parse_customer_key_headers("AES128", &kb, &mb).unwrap_err();
1511        assert!(
1512            matches!(err, SseError::CustomerKeyAlgorithmUnsupported { ref algo } if algo == "AES128"),
1513            "got {err:?}"
1514        );
1515        // Lowercase variant still rejected (AWS S3 accepts only "AES256").
1516        let err2 = parse_customer_key_headers("aes256", &kb, &mb).unwrap_err();
1517        assert!(
1518            matches!(err2, SseError::CustomerKeyAlgorithmUnsupported { .. }),
1519            "got {err2:?}"
1520        );
1521    }
1522
1523    #[test]
1524    fn parse_customer_key_headers_rejects_wrong_key_length() {
1525        let short_key = vec![5u8; 16]; // half-length AES key
1526        let md5 = compute_key_md5(&short_key);
1527        let kb = base64::engine::general_purpose::STANDARD.encode(&short_key);
1528        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
1529        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
1530        assert!(
1531            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("key length")),
1532            "got {err:?}"
1533        );
1534    }
1535
1536    #[test]
1537    fn parse_customer_key_headers_rejects_wrong_md5_length() {
1538        let key = [3u8; KEY_LEN];
1539        let kb = base64::engine::general_purpose::STANDARD.encode(key);
1540        // Truncated MD5 (15 bytes instead of 16).
1541        let bad_md5 = vec![0u8; 15];
1542        let mb = base64::engine::general_purpose::STANDARD.encode(bad_md5);
1543        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
1544        assert!(
1545            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("MD5 length")),
1546            "got {err:?}"
1547        );
1548    }
1549
1550    #[test]
1551    fn parse_customer_key_headers_rejects_md5_mismatch() {
1552        let key = [4u8; KEY_LEN];
1553        let other = [5u8; KEY_LEN];
1554        let kb = base64::engine::general_purpose::STANDARD.encode(key);
1555        let wrong_md5 = compute_key_md5(&other);
1556        let mb = base64::engine::general_purpose::STANDARD.encode(wrong_md5);
1557        let err = parse_customer_key_headers("AES256", &kb, &mb).unwrap_err();
1558        assert!(
1559            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("MD5 does not match")),
1560            "got {err:?}"
1561        );
1562    }
1563
1564    #[test]
1565    fn parse_customer_key_headers_rejects_bad_base64() {
1566        let valid_key = [0u8; KEY_LEN];
1567        let md5 = compute_key_md5(&valid_key);
1568        let mb = base64::engine::general_purpose::STANDARD.encode(md5);
1569        let err = parse_customer_key_headers("AES256", "!!!not-base64!!!", &mb).unwrap_err();
1570        assert!(
1571            matches!(err, SseError::InvalidCustomerKey { reason } if reason.contains("base64")),
1572            "got {err:?}"
1573        );
1574        // Bad MD5 base64.
1575        let kb = base64::engine::general_purpose::STANDARD.encode(valid_key);
1576        let err2 = parse_customer_key_headers("AES256", &kb, "??not-base64??").unwrap_err();
1577        assert!(
1578            matches!(err2, SseError::InvalidCustomerKey { reason } if reason.contains("base64")),
1579            "got {err2:?}"
1580        );
1581    }
1582
1583    #[test]
1584    fn parse_customer_key_headers_trims_whitespace() {
1585        // S3 SDKs sometimes pad headers with trailing newlines.
1586        let key = [12u8; KEY_LEN];
1587        let md5 = compute_key_md5(&key);
1588        let kb = format!(
1589            "  {}\n",
1590            base64::engine::general_purpose::STANDARD.encode(key)
1591        );
1592        let mb = format!(
1593            "\t{}  ",
1594            base64::engine::general_purpose::STANDARD.encode(md5)
1595        );
1596        let m = parse_customer_key_headers("AES256", &kb, &mb).unwrap();
1597        assert_eq!(m.key, key);
1598    }
1599
1600    // -----------------------------------------------------------------
1601    // Back-compat + cross-source mixing
1602    // -----------------------------------------------------------------
1603
1604    #[test]
1605    fn back_compat_decrypt_s4e1_with_keyring_source() {
1606        let k = key32(33);
1607        let legacy_ct = encrypt(&k, b"v0.4 vintage object");
1608        let kr = SseKeyring::new(1, Arc::clone(&k));
1609        // Both call styles must work — `&kr` (back-compat) and
1610        // `SseSource::Keyring(&kr)` (explicit).
1611        let plain = decrypt(&legacy_ct, &kr).unwrap();
1612        assert_eq!(plain.as_ref(), b"v0.4 vintage object");
1613        let plain2 = decrypt(&legacy_ct, SseSource::Keyring(&kr)).unwrap();
1614        assert_eq!(plain2.as_ref(), b"v0.4 vintage object");
1615    }
1616
1617    #[test]
1618    fn back_compat_decrypt_s4e2_with_keyring_source() {
1619        let kr = keyring_single(34);
1620        let ct = encrypt_v2(b"v0.5 #29 object", &kr);
1621        let plain = decrypt(&ct, &kr).unwrap();
1622        assert_eq!(plain.as_ref(), b"v0.5 #29 object");
1623        // encrypt_with_source(Keyring) should produce the same wire
1624        // format (S4E2).
1625        let ct2 = encrypt_with_source(b"v0.5 #29 object", SseSource::Keyring(&kr));
1626        assert_eq!(&ct2[..4], SSE_MAGIC_V2);
1627        let plain2 = decrypt(&ct2, &kr).unwrap();
1628        assert_eq!(plain2.as_ref(), b"v0.5 #29 object");
1629    }
1630
1631    #[test]
1632    fn s4e2_blob_with_customer_key_source_is_rejected() {
1633        // An object stored with SSE-S4 (S4E2) but a client sending
1634        // SSE-C headers on the GET — this is a misuse, surface as
1635        // CustomerKeyUnexpected so service.rs can return 400.
1636        let kr = keyring_single(50);
1637        let ct = encrypt_v2(b"server-managed object", &kr);
1638        let m = cust_key(99);
1639        let err = decrypt(
1640            &ct,
1641            SseSource::CustomerKey {
1642                key: &m.key,
1643                key_md5: &m.key_md5,
1644            },
1645        )
1646        .unwrap_err();
1647        assert!(matches!(err, SseError::CustomerKeyUnexpected), "got {err:?}");
1648    }
1649
1650    #[test]
1651    fn s4e3_blob_with_keyring_source_is_rejected() {
1652        // Inverse: object is SSE-C (S4E3) but client forgot to send
1653        // SSE-C headers. Service.rs should map this to 400.
1654        let m = cust_key(60);
1655        let ct = encrypt_with_source(b"customer-key object", (&m).into());
1656        let kr = keyring_single(60);
1657        let err = decrypt(&ct, &kr).unwrap_err();
1658        assert!(matches!(err, SseError::CustomerKeyRequired), "got {err:?}");
1659    }
1660
1661    #[test]
1662    fn looks_encrypted_detects_s4e3() {
1663        let m = cust_key(13);
1664        let ct = encrypt_with_source(b"x", (&m).into());
1665        assert!(looks_encrypted(&ct));
1666    }
1667
1668    #[test]
1669    fn s4e3_rejects_short_body() {
1670        // 36 bytes passes the looks_encrypted gate but is shorter than
1671        // S4E3's 49-byte header.
1672        let mut short = Vec::new();
1673        short.extend_from_slice(SSE_MAGIC_V3);
1674        short.push(ALGO_AES_256_GCM);
1675        // Padding to 36 bytes (SSE_HEADER_BYTES) so the outer length
1676        // check passes but the S4E3 inner check fails.
1677        short.extend_from_slice(&[0u8; SSE_HEADER_BYTES - 5]);
1678        assert_eq!(short.len(), SSE_HEADER_BYTES);
1679        let m = cust_key(1);
1680        let err = decrypt(
1681            &short,
1682            SseSource::CustomerKey {
1683                key: &m.key,
1684                key_md5: &m.key_md5,
1685            },
1686        )
1687        .unwrap_err();
1688        assert!(matches!(err, SseError::TooShort { .. }), "got {err:?}");
1689    }
1690
1691    #[test]
1692    fn customer_key_material_debug_redacts_key() {
1693        let m = cust_key(99);
1694        let s = format!("{m:?}");
1695        assert!(s.contains("redacted"));
1696        assert!(!s.contains(&format!("{:?}", m.key.as_slice())));
1697    }
1698
1699    #[test]
1700    fn constant_time_eq_basic() {
1701        assert!(constant_time_eq(b"abc", b"abc"));
1702        assert!(!constant_time_eq(b"abc", b"abd"));
1703        assert!(!constant_time_eq(b"abc", b"abcd"));
1704        assert!(constant_time_eq(b"", b""));
1705    }
1706
1707    #[test]
1708    fn compute_key_md5_known_vector() {
1709        // Empty input MD5 is known: d41d8cd98f00b204e9800998ecf8427e.
1710        let got = compute_key_md5(b"");
1711        let expected_hex = "d41d8cd98f00b204e9800998ecf8427e";
1712        assert_eq!(hex_lower(&got), expected_hex);
1713    }
1714
1715    // -----------------------------------------------------------------
1716    // v0.5 #28 — SSE-KMS envelope (S4E4) tests
1717    // -----------------------------------------------------------------
1718
1719    use crate::kms::{KmsBackend, LocalKms};
1720    use std::collections::HashMap;
1721    use std::path::PathBuf;
1722
1723    fn local_kms_with(key_ids: &[(&str, [u8; 32])]) -> LocalKms {
1724        let mut keks: HashMap<String, [u8; 32]> = HashMap::new();
1725        for (id, k) in key_ids {
1726            keks.insert((*id).to_string(), *k);
1727        }
1728        LocalKms::from_keks(PathBuf::from("/tmp/none"), keks)
1729    }
1730
1731    #[tokio::test]
1732    async fn s4e4_roundtrip_via_local_kms() {
1733        let kms = local_kms_with(&[("alpha", [42u8; 32])]);
1734        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
1735        let mut dek = [0u8; 32];
1736        dek.copy_from_slice(&dek_vec);
1737        let pt = b"SSE-KMS envelope payload across the S4E4 frame";
1738        let ct = encrypt_with_source(
1739            pt,
1740            SseSource::Kms {
1741                dek: &dek,
1742                wrapped: &wrapped,
1743            },
1744        );
1745        // Frame inspection.
1746        assert_eq!(&ct[..4], SSE_MAGIC_V4);
1747        assert_eq!(ct[4], ALGO_AES_256_GCM);
1748        let key_id_len = ct[5] as usize;
1749        assert_eq!(key_id_len, "alpha".len());
1750        assert_eq!(&ct[6..6 + key_id_len], b"alpha");
1751        // peek_magic + looks_encrypted both recognise S4E4.
1752        assert!(looks_encrypted(&ct));
1753        assert_eq!(peek_magic(&ct), Some("S4E4"));
1754        // Async decrypt round-trip.
1755        let plain = decrypt_with_kms(&ct, &kms).await.unwrap();
1756        assert_eq!(plain.as_ref(), pt);
1757    }
1758
1759    #[tokio::test]
1760    async fn s4e4_tampered_key_id_fails_aead() {
1761        let kms = local_kms_with(&[("alpha", [1u8; 32]), ("beta", [2u8; 32])]);
1762        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
1763        let mut dek = [0u8; 32];
1764        dek.copy_from_slice(&dek_vec);
1765        let mut ct = encrypt_with_source(
1766            b"do not redirect",
1767            SseSource::Kms {
1768                dek: &dek,
1769                wrapped: &wrapped,
1770            },
1771        )
1772        .to_vec();
1773        // Flip the key_id from "alpha" to "betaa" by changing the
1774        // first byte of the key_id field. The forged id "bltha" is
1775        // not in the KMS, so unwrap fails with KeyNotFound surfaced
1776        // through KmsBackend(KmsError::KeyNotFound).
1777        let key_id_off = 6;
1778        ct[key_id_off] = b'b';
1779        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
1780        assert!(
1781            matches!(
1782                err,
1783                SseError::KmsBackend(crate::kms::KmsError::UnwrapFailed { .. })
1784                    | SseError::KmsBackend(crate::kms::KmsError::KeyNotFound { .. })
1785            ),
1786            "got {err:?}"
1787        );
1788    }
1789
1790    #[tokio::test]
1791    async fn s4e4_tampered_key_id_to_real_other_id_still_fails() {
1792        // Wrap under "alpha" but rewrite the stored key_id to "beta"
1793        // (which IS in the KMS). KmsBackend will try to unwrap with
1794        // beta's KEK and AAD = "beta", but the wrapped bytes were
1795        // produced with alpha's KEK + AAD = "alpha", so the local
1796        // KMS unwrap fails with UnwrapFailed.
1797        let kms = local_kms_with(&[("alpha", [1u8; 32]), ("beta", [2u8; 32])]);
1798        let (dek_vec, wrapped) = kms.generate_dek("alpha").await.unwrap();
1799        let mut dek = [0u8; 32];
1800        dek.copy_from_slice(&dek_vec);
1801        let mut ct = encrypt_with_source(
1802            b"redirect attempt",
1803            SseSource::Kms {
1804                dek: &dek,
1805                wrapped: &wrapped,
1806            },
1807        )
1808        .to_vec();
1809        // Both "alpha" and "beta" are 5 chars long so the rewrite
1810        // doesn't shift any other field offsets.
1811        let key_id_off = 6;
1812        ct[key_id_off..key_id_off + 5].copy_from_slice(b"beta_");
1813        // Trim back to 4-byte "beta" by also shrinking the length
1814        // prefix would change downstream offsets — instead pad the
1815        // forged id to keep length stable. This mirrors the realistic
1816        // tampering surface (attacker can flip bytes but not change
1817        // the on-disk layout). The KMS now sees key_id "beta_" which
1818        // is unknown → KeyNotFound.
1819        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
1820        assert!(
1821            matches!(
1822                err,
1823                SseError::KmsBackend(crate::kms::KmsError::KeyNotFound { .. })
1824            ),
1825            "got {err:?}"
1826        );
1827    }
1828
1829    #[tokio::test]
1830    async fn s4e4_tampered_wrapped_dek_fails_unwrap() {
1831        let kms = local_kms_with(&[("k", [3u8; 32])]);
1832        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
1833        let mut dek = [0u8; 32];
1834        dek.copy_from_slice(&dek_vec);
1835        let mut ct = encrypt_with_source(
1836            b"target body",
1837            SseSource::Kms {
1838                dek: &dek,
1839                wrapped: &wrapped,
1840            },
1841        )
1842        .to_vec();
1843        // Locate the wrapped_dek_len + wrapped_dek field and flip a
1844        // byte in the middle of the wrapped DEK. AES-GCM auth on the
1845        // wrap fails → KmsBackend(UnwrapFailed).
1846        let key_id_len = ct[5] as usize;
1847        let wrapped_len_off = 6 + key_id_len;
1848        let wrapped_off = wrapped_len_off + 4;
1849        let mid = wrapped_off + (wrapped.ciphertext.len() / 2);
1850        ct[mid] ^= 0xFF;
1851        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
1852        assert!(
1853            matches!(
1854                err,
1855                SseError::KmsBackend(crate::kms::KmsError::UnwrapFailed { .. })
1856            ),
1857            "got {err:?}"
1858        );
1859    }
1860
1861    #[tokio::test]
1862    async fn s4e4_tampered_ciphertext_fails_aead() {
1863        let kms = local_kms_with(&[("k", [4u8; 32])]);
1864        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
1865        let mut dek = [0u8; 32];
1866        dek.copy_from_slice(&dek_vec);
1867        let mut ct = encrypt_with_source(
1868            b"sealed body",
1869            SseSource::Kms {
1870                dek: &dek,
1871                wrapped: &wrapped,
1872            },
1873        )
1874        .to_vec();
1875        let last = ct.len() - 1;
1876        ct[last] ^= 0x01;
1877        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
1878        assert!(matches!(err, SseError::DecryptFailed), "got {err:?}");
1879    }
1880
1881    #[tokio::test]
1882    async fn s4e4_uses_random_nonce_and_dek_per_put() {
1883        let kms = local_kms_with(&[("k", [5u8; 32])]);
1884        // Two PUTs of the same plaintext under the same KEK must
1885        // produce different ciphertexts (fresh DEK + fresh nonce).
1886        let (dek1_vec, wrapped1) = kms.generate_dek("k").await.unwrap();
1887        let (dek2_vec, wrapped2) = kms.generate_dek("k").await.unwrap();
1888        let mut dek1 = [0u8; 32];
1889        dek1.copy_from_slice(&dek1_vec);
1890        let mut dek2 = [0u8; 32];
1891        dek2.copy_from_slice(&dek2_vec);
1892        let pt = b"deterministic input";
1893        let a = encrypt_with_source(
1894            pt,
1895            SseSource::Kms {
1896                dek: &dek1,
1897                wrapped: &wrapped1,
1898            },
1899        );
1900        let b = encrypt_with_source(
1901            pt,
1902            SseSource::Kms {
1903                dek: &dek2,
1904                wrapped: &wrapped2,
1905            },
1906        );
1907        assert_ne!(a, b);
1908        // Both still decrypt round-trip.
1909        let plain_a = decrypt_with_kms(&a, &kms).await.unwrap();
1910        let plain_b = decrypt_with_kms(&b, &kms).await.unwrap();
1911        assert_eq!(plain_a.as_ref(), pt);
1912        assert_eq!(plain_b.as_ref(), pt);
1913    }
1914
1915    #[tokio::test]
1916    async fn s4e4_sync_decrypt_returns_kms_async_required() {
1917        // The whole point of KmsAsyncRequired: passing an S4E4 body
1918        // to the sync `decrypt` function must surface a distinct
1919        // error so service.rs's GET path notices the bug rather than
1920        // returning a generic "wrong source" 400.
1921        let kms = local_kms_with(&[("k", [6u8; 32])]);
1922        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
1923        let mut dek = [0u8; 32];
1924        dek.copy_from_slice(&dek_vec);
1925        let ct = encrypt_with_source(
1926            b"async only",
1927            SseSource::Kms {
1928                dek: &dek,
1929                wrapped: &wrapped,
1930            },
1931        );
1932        // Try via Keyring source (the default sync path).
1933        let kr = SseKeyring::new(1, key32(0));
1934        let err = decrypt(&ct, &kr).unwrap_err();
1935        assert!(matches!(err, SseError::KmsAsyncRequired), "got {err:?}");
1936    }
1937
1938    #[test]
1939    fn back_compat_s4e1_e2_e3_still_decrypt_via_sync() {
1940        // After adding S4E4, the sync `decrypt` path must still
1941        // handle every legacy frame variant unchanged.
1942        let k = key32(7);
1943        let v1 = encrypt(&k, b"v0.4 vintage");
1944        let kr = SseKeyring::new(1, Arc::clone(&k));
1945        assert_eq!(decrypt(&v1, &kr).unwrap().as_ref(), b"v0.4 vintage");
1946
1947        let v2 = encrypt_v2(b"v0.5 #29 vintage", &kr);
1948        assert_eq!(
1949            decrypt(&v2, &kr).unwrap().as_ref(),
1950            b"v0.5 #29 vintage"
1951        );
1952
1953        let m = cust_key(7);
1954        let v3 = encrypt_with_source(b"v0.5 #27 vintage", (&m).into());
1955        assert_eq!(
1956            decrypt(&v3, &m).unwrap().as_ref(),
1957            b"v0.5 #27 vintage"
1958        );
1959    }
1960
1961    #[test]
1962    fn peek_magic_distinguishes_all_variants() {
1963        // S4E1 / S4E2 / S4E3 — built from real encrypts so the
1964        // length gate also passes.
1965        let k = key32(9);
1966        let v1 = encrypt(&k, b"x");
1967        assert_eq!(peek_magic(&v1), Some("S4E1"));
1968        let kr = SseKeyring::new(1, Arc::clone(&k));
1969        let v2 = encrypt_v2(b"x", &kr);
1970        assert_eq!(peek_magic(&v2), Some("S4E2"));
1971        let m = cust_key(9);
1972        let v3 = encrypt_with_source(b"x", (&m).into());
1973        assert_eq!(peek_magic(&v3), Some("S4E3"));
1974        // Synthetic S4E4 magic with enough trailing bytes to clear
1975        // the 36-byte length gate. peek_magic does NOT validate the
1976        // S4E4 inner header, just the magic — that's the contract
1977        // (cheap dispatch signal).
1978        let mut v4 = Vec::new();
1979        v4.extend_from_slice(SSE_MAGIC_V4);
1980        v4.extend_from_slice(&[0u8; 40]);
1981        assert_eq!(peek_magic(&v4), Some("S4E4"));
1982        // Unknown magic / too-short input → None.
1983        assert!(peek_magic(b"NOPE").is_none());
1984        assert!(peek_magic(b"short").is_none());
1985        assert!(peek_magic(&[0u8; 100]).is_none());
1986    }
1987
1988    #[tokio::test]
1989    async fn s4e4_truncated_frame_errors_cleanly() {
1990        // Truncate to less than the minimum header. Must surface
1991        // KmsFrameTooShort, not panic, not return BadMagic.
1992        let truncated = b"S4E4\x01\x05hi";
1993        let kms = local_kms_with(&[("k", [1u8; 32])]);
1994        let err = decrypt_with_kms(truncated, &kms).await.unwrap_err();
1995        assert!(
1996            matches!(err, SseError::KmsFrameTooShort { .. }),
1997            "got {err:?}"
1998        );
1999    }
2000
2001    #[tokio::test]
2002    async fn s4e4_oob_key_id_len_errors() {
2003        // Build a body that claims key_id_len = 200 but only has 4
2004        // bytes after the length prefix. parse_s4e4_header must
2005        // refuse with KmsFrameFieldOob, not slice-panic.
2006        let mut body = Vec::new();
2007        body.extend_from_slice(SSE_MAGIC_V4);
2008        body.push(ALGO_AES_256_GCM);
2009        body.push(200u8); // key_id_len
2010        // Remaining bytes < 200; pad to clear the looks_encrypted
2011        // floor (36 bytes) but stay short of the claimed key_id +
2012        // wrapped_dek_len + nonce + tag layout.
2013        body.extend_from_slice(&[0u8; 50]);
2014        let kms = local_kms_with(&[("k", [1u8; 32])]);
2015        let err = decrypt_with_kms(&body, &kms).await.unwrap_err();
2016        assert!(
2017            matches!(err, SseError::KmsFrameFieldOob { .. }),
2018            "got {err:?}"
2019        );
2020    }
2021
2022    #[tokio::test]
2023    async fn s4e4_via_keyring_source_into_sync_decrypt_is_kms_async_required() {
2024        // S4E4 + Keyring source: sync decrypt sees the S4E4 magic
2025        // first and returns KmsAsyncRequired regardless of source —
2026        // the source mismatch never gets a chance to surface, which
2027        // is the right behaviour (caller's bug is "didn't peek
2028        // magic" not "wrong source").
2029        let kms = local_kms_with(&[("k", [9u8; 32])]);
2030        let (dek_vec, wrapped) = kms.generate_dek("k").await.unwrap();
2031        let mut dek = [0u8; 32];
2032        dek.copy_from_slice(&dek_vec);
2033        let ct = encrypt_with_source(
2034            b"x",
2035            SseSource::Kms {
2036                dek: &dek,
2037                wrapped: &wrapped,
2038            },
2039        );
2040        let m = cust_key(1);
2041        let err = decrypt(&ct, &m).unwrap_err();
2042        assert!(matches!(err, SseError::KmsAsyncRequired), "got {err:?}");
2043    }
2044
2045    #[tokio::test]
2046    async fn s4e4_looks_encrypted_passthrough_returns_false_for_synthetic() {
2047        // S4F4 (note F not E) must NOT be confused with S4E4.
2048        let mut not_s4e4 = Vec::new();
2049        not_s4e4.extend_from_slice(b"S4F4");
2050        not_s4e4.extend_from_slice(&[0u8; 60]);
2051        assert!(!looks_encrypted(&not_s4e4));
2052        assert_eq!(peek_magic(&not_s4e4), None);
2053    }
2054
2055    #[tokio::test]
2056    async fn s4e4_aad_length_prefix_prevents_byte_shifting() {
2057        // Constructing an S4E4 body where the wrapped_dek_len is
2058        // shrunk by N bytes and the same N bytes are prepended to
2059        // the key_id-equivalent area would, without length-prefixed
2060        // AAD, produce the same AAD bytestream. Verify our AAD
2061        // includes the length prefixes by tampering with
2062        // wrapped_dek_len and confirming AES-GCM auth fails.
2063        let kms = local_kms_with(&[("kk", [11u8; 32])]);
2064        let (dek_vec, wrapped) = kms.generate_dek("kk").await.unwrap();
2065        let mut dek = [0u8; 32];
2066        dek.copy_from_slice(&dek_vec);
2067        let mut ct = encrypt_with_source(
2068            b"length-shift defense",
2069            SseSource::Kms {
2070                dek: &dek,
2071                wrapped: &wrapped,
2072            },
2073        )
2074        .to_vec();
2075        let key_id_len = ct[5] as usize;
2076        let wrapped_len_off = 6 + key_id_len;
2077        // Shrink wrapped_dek_len by 1. parse_s4e4_header now reads a
2078        // shorter wrapped_dek and a different nonce/tag/ciphertext
2079        // alignment — KMS unwrap fails OR AES-GCM fails OR frame
2080        // bounds reject. All three surface as auditable errors;
2081        // none should reach a successful decrypt.
2082        let original_len = u32::from_be_bytes([
2083            ct[wrapped_len_off],
2084            ct[wrapped_len_off + 1],
2085            ct[wrapped_len_off + 2],
2086            ct[wrapped_len_off + 3],
2087        ]);
2088        let new_len = (original_len - 1).to_be_bytes();
2089        ct[wrapped_len_off..wrapped_len_off + 4].copy_from_slice(&new_len);
2090        let err = decrypt_with_kms(&ct, &kms).await.unwrap_err();
2091        // Acceptable failure modes: unwrap fail (truncated wrapped
2092        // DEK), AES-GCM fail (shifted nonce/tag/AAD), or frame bounds.
2093        assert!(
2094            matches!(
2095                err,
2096                SseError::KmsBackend(_)
2097                    | SseError::DecryptFailed
2098                    | SseError::KmsFrameFieldOob { .. }
2099                    | SseError::KmsFrameTooShort { .. }
2100            ),
2101            "got {err:?}"
2102        );
2103    }
2104}