Skip to main content

lib_q_saturnin/
aead_ctx.rs

1//! CTX committing-AEAD transform applied to Saturnin CTR-Cascade — `SaturninAeadCtx`.
2//!
3//! # What this is
4//!
5//! **CTX** — John Chan and Phillip Rogaway, *On Committing Authenticated-Encryption*, ESORICS
6//! 2022 (full version: IACR ePrint 2022/1260), Fig. 2 / Theorem 2 — applied to Saturnin
7//! CTR-Cascade (`SaturninAead`, `crate::aead`), instantiated with [`SaturninHash`]
8//! (`crate::hash`). Where `SaturninAead::encrypt` produces `C ‖ T` (CTR-Cascade ciphertext body,
9//! 32-byte cascade tag), `SaturninAeadCtx::encrypt` replaces `T` with
10//!
11//! ```text
12//! T' = SaturninHash(CASCADE_CTX_LABEL_V0 ‖ K ‖ N ‖ T ‖ A)      (32 bytes)
13//! ```
14//!
15//! at the same 32-byte offset and width — see `crate::commit::ctx_tag` for the shared
16//! implementation (this instantiation reuses it, with [`CASCADE_CTX_LABEL_V0`] as the label) and
17//! `crate::commit` module docs for the general byte-layout/injectivity argument. Decryption
18//! recomputes `T` from CTR-Cascade's own cascade construction, then checks `T' =? SaturninHash(...)`.
19//!
20//! This byte layout is **frozen**; any change (a second variable-length field, moving `A` off the
21//! end, etc.) must mint a new label (`libq.saturnin.cascade.ctx.v1`), never reuse this one — see
22//! the injectivity argument below.
23//!
24//! # Why a distinct type, not a flag on `SaturninAead`
25//!
26//! `SaturninAead` ciphertexts are already stored in shipped products: the My-Grid vault (VMK
27//! wraps, `mygrid_vault_v1`), My-Grid recovery (mnemonic-derived), and GIP's wrapkey
28//! (Argon2id-sealed secret key, `bitlink-wrapkey-argon2id-v1`). Changing `SaturninAead`'s tag
29//! construction in place would make every one of those blobs permanently undecryptable — that is
30//! data loss in shipped products, not a version bump.
31//!
32//! `SaturninAeadCtx` is therefore a **separate, opt-in type** with its own, incompatible wire
33//! format:
34//!
35//! - `SaturninAead`'s existing wire output is byte-for-byte **unchanged** — pinned by
36//!   `tests/aead_kat_pin.rs`.
37//! - `SaturninAeadCtx` ciphertext **never** decrypts under `SaturninAead`, and vice versa — see
38//!   `tests/cascade_ctx_spec.rs::cross_mode_ciphertexts_rejected`.
39//! - Making the incompatible wire format a compile-time-distinct type (rather than a runtime
40//!   constructor flag such as `SaturninAead::new_committing()`) means a call site's choice of
41//!   format is visible in its type, not a config value one misrouted call away from writing one
42//!   format and reading it back as the other.
43//!
44//! **Migration guidance:** existing stored formats (`mygrid_vault_v1`, My-Grid recovery,
45//! `bitlink-wrapkey-argon2id-v1`) MUST keep decrypting via `SaturninAead`. Adopting
46//! `SaturninAeadCtx` for one of them means minting a **new** format tag and re-encrypting / dual
47//! reading during a migration window — never switching the AEAD under an existing tag. New
48//! designs that want key-commitment-shaped properties should start from `SaturninAeadCtx`
49//! directly (with the RED qualifier below).
50//!
51//! # The two types share a keystream — a migration re-encrypt MUST use a fresh nonce
52//!
53//! The types are wire-incompatible, but they are **not** cryptographically independent. Only the
54//! tag differs: the ciphertext *body* is CTR-Cascade's in both cases, and CTR-Cascade's keystream
55//! is a function of `(K, N)` **alone** — the associated data does not enter it. So encrypting
56//! under `SaturninAead` and under `SaturninAeadCtx` with the same key and the same nonce produces
57//! the *same* keystream, and if the two plaintexts differ, that is a classic two-time pad:
58//! `C_plain ⊕ C_ctx == M_plain ⊕ M_ctx`, and either plaintext reveals the other. Changing the AD
59//! between the two calls does not help.
60//!
61//! This matters precisely during the migration window recommended above. **When re-encrypting
62//! stored data from `SaturninAead` to `SaturninAeadCtx`, draw a fresh nonce for the new
63//! ciphertext.** Do not carry the old record's nonce over, and do not treat "different type,
64//! different wire format" as if it were "different keystream" — the distinct type protects the
65//! *format*, not the nonce discipline. Reusing a nonce across the two types is exactly as
66//! dangerous as reusing it twice within one of them; CTR-Cascade is nonce-catastrophic for
67//! confidentiality either way (the Saturnin LWC submission claims no security whatsoever under
68//! nonce repetition, spec §5.1 footnote 3).
69//!
70//! `tests/cascade_ctx_spec.rs::cross_mode_ciphertexts_rejected` pins the underlying fact: for
71//! identical inputs the two types' ciphertext bodies are byte-identical and only the last 32
72//! bytes differ.
73//!
74//! # Injectivity (no length prefix needed)
75//!
76//! Re-verified against `SaturninAead`'s actual parameters (the general argument is
77//! `crate::commit` module docs):
78//!
79//! - `K` is exactly 32 bytes and `N` is exactly 16 (`SaturninAead::key_size()` /
80//!   `nonce_size()`). **Both `SaturninAeadCtx::encrypt_bytes` and `SaturninAeadCtx::decrypt_core`
81//!   check both lengths themselves, before `ctx_tag` is reached** — not merely by inheriting
82//!   `SaturninAead`'s own checks. That redundancy is deliberate: it keeps injectivity a local
83//!   property of each entry point rather than a property of call order. A wrong-length key would
84//!   at least panic at the `copy_from_slice` that stages it, but a wrong-length nonce is passed to
85//!   `ctx_tag` as a plain slice and would otherwise be absorbed silently as a *second*
86//!   variable-length field, breaking injectivity with no symptom. Do not remove those checks.
87//! - `T` is exactly 32 bytes (`Zeroizing<[u8; 32]>`; `crate::commit::ctx_tag` takes
88//!   `base_tag: &[u8; 32]`).
89//! - `CASCADE_CTX_LABEL_V0` is a compile-time constant (28 bytes).
90//! - `A` is the only variable-length field and it is the suffix; Saturnin-Hash's `10*` padding
91//!   makes the hash input self-delimiting, so `|A|` — and hence the tuple `(K, N, T, A)` — is
92//!   recovered uniquely from `H_input`'s length.
93//!
94//! The map `(K, N, T, A) ↦ H_input` is therefore injective and no explicit length prefix is
95//! required, on the same grounds as the QCB instantiation.
96//!
97//! # S-2 does not apply to this instantiation
98//!
99//! Open obligation S-2 (`crate::commit` module docs, and see `crate::qcb` module docs) is the
100//! concern that Chan–Rogaway's Theorem 2 assumes the base scheme's core `C` has the same length
101//! as the message `M` (so encryption is bijective for fixed `K, N, A`), which QCB's `10*` message
102//! padding violates (`|C| != |M|`).
103//!
104//! **CTR-Cascade is a stream mode: `|C| == |M|` exactly, natively.** `SaturninAead::ctr_encrypt`
105//! XORs a keystream over exactly the bytes present in the input (`block_len = remaining.min(32)`
106//! on the final partial block; see `crate::aead::SaturninAead`'s implementation) — no padding is
107//! ever appended to the ciphertext body. The `0x80` padding byte used inside the cascade tag's own
108//! internal block construction never appears in the ciphertext body itself. `SaturninAead`'s own
109//! test suite pins `ciphertext.len() == plaintext.len() + 32` (tag only, no body padding).
110//! Moreover, for fixed `(K, N)` the map `M ↦ C = M ⊕ keystream` is a length-preserving *bijection*
111//! on each message length — exactly Theorem 2's "encrypt half is bijective for fixed `K, N, A`"
112//! hypothesis. CTR-Cascade also fits CTX's tag-based syntax directly: encryption produces `C ‖ T`
113//! and decryption *recomputes* `T` deterministically from `(K, N, A, C)` rather than inverting it.
114//!
115//! **Conclusion, stated both ways:** Theorem 2's length/bijectivity hypothesis is satisfied
116//! **natively** by CTR-Cascade; S-2 is an artifact of QCB's message padding and does **not** apply
117//! to this instantiation. On this specific axis, CTX-on-CTR-Cascade rests on *firmer* ground than
118//! CTX-on-QCB. (Register note: the code facts above are OBSERVED against this crate's source; that
119//! they satisfy Theorem 2's stated hypothesis is this module's reading of the theorem as
120//! transcribed in `crate::commit`'s module docs — pending confirmation as part of the human
121//! cryptographer sign-off below, alongside H-1, Q-1′, L-1 and Q-2. There is no known structural
122//! gap of the S-2 kind for this instantiation.) S-2 remains open for `SaturninQcb` — closing it
123//! there is out of scope for this module.
124//!
125//! # Security posture — RED
126//!
127//! This transform is a proven construction ([Theorem 2](https://eprint.iacr.org/2022/1260))
128//! instantiated with a primitive that only carries a designer *claim*, not a proof, of collision
129//! resistance, and it has not had a human cryptographer's sign-off. Do not describe
130//! `SaturninAeadCtx` as "committing" or "CMT-4 secure" without these qualifiers, and do not
131//! describe plain `SaturninAead` as committing at all — it is not (see
132//! `lib-q-saturnin/README.md`'s Key commitment section and `lib-q-aead/tests/key_commitment.rs`'s
133//! rule that a null search result is not evidence of commitment).
134//!
135//! Open obligations:
136//!
137//! - **H-1** (shared with `SaturninQcb`) — is Saturnin-Hash's own claimed bound (no classical
138//!   collision below `2^112`, no quantum collision below `~2^75`; `crate::hash` / `crate::commit`)
139//!   the right number to publish, or is there room to argue tighter? `2^112` is *below* the
140//!   best-known generic classical cost of `2^128` (birthday bound on a 256-bit digest, LWC spec
141//!   §5.4.1); the designers claim under it for margin — "additional constant factors that these
142//!   bounds do not take into account, which is why our final security claims are reduced"
143//!   (§5.4.1) — **not** because of a NIST-LWC floor. Never publish `2^112` without that reason,
144//!   never round it up to `2^128`, and never quote it against a *quantum* adversary — the quantum
145//!   figure is `~2^75` claimed. It sits below all three generic quantum collision costs at
146//!   `n = 256`: `2^85.3 = 2^(n/3)` with `2^85.3` qRAM (Brassard–Høyer–Tapp, LATIN '98);
147//!   `2^102.4 = 2^(2n/5)` with no qRAM but `2^51.2` classical memory
148//!   (Chailloux–Naya-Plasencia–Schrottenloher, 2017); `2^128` memoryless. The `2^85.3` figure is
149//!   BHT's, not CNS's, and CNS is qRAM-free rather than memory-free.
150//! - **S-2** — does **not** apply here; see above. (Stays open for `SaturninQcb`, though it was
151//!   *narrowed* on 2026-08-07: Chan–Rogaway's proof turns out to consume only injectivity, not
152//!   the length-preserving bijectivity their §4 prose asserts — see `crate::commit`. The same
153//!   review surfaced a *second* Chan–Rogaway syntactic requirement QCB violates, "its expansion,
154//!   which is a constant `τ` such that `|E(K,N,A,M)| = |M| + τ`" (2022/1260 p.4). That one does
155//!   not arise here either: CTR-Cascade's expansion is exactly 32 bytes, the tag, for every
156//!   message length.)
157//! - **Q-1′** (adapted from QCB's Q-1, and **widened** on the 2026-08-07 source review — read the
158//!   Q-1 bullet in `crate::commit` first, its analysis is the normative one) — CTX's own
159//!   nAE-preservation proof (Theorem 3, IACR ePrint 2022/1260) is in the *classical*
160//!   random-oracle model, and the gap is **structural**: the authenticity reduction `B3` recovers
161//!   the base tag only by iterating a recorded table of the adversary's hash queries ("It then
162//!   iterates through all such entries", App. B), which no-cloning forbids under superposition
163//!   queries. The Saturnin LWC submission markets CTR-Cascade with quantum-adversary security
164//!   claims; no published QROM analysis of CTX was found — the committing-AE papers on file,
165//!   through 2026/1222, never use the words "quantum", "QROM" or "superposition". One sub-problem
166//!   *is* narrower here than for `SaturninQcb`: Q-1's sharpest leg is a counting mismatch between
167//!   Theorem 3's `qe + 1` reduction queries and QCB's BZ / plus-one notion, and CTR-Cascade makes
168//!   no BZ claim, so that specific off-by-one does not arise. That is narrower ground, not safe
169//!   ground — it means there is no Q2 *integrity* theorem here to preserve in the first place.
170//!   Mitigation to keep in mind: against a *quantum* attacker the CMT bound also degrades to the
171//!   quantum collision claim (`~2^75` / generic `2^85`–`2^102`), not the classical `2^112`
172//!   figure.
173//! - **L-1** (new 2026-08-07, shared with `SaturninQcb`; full statement in `crate::commit`) —
174//!   CTX's nAE-preservation proof is **single-user and single-verification-query**.
175//!   Bellare–Hoang, IACR ePrint 2024/875 p.12: "Chan and Rogaway \[16\] only consider a
176//!   restricted setting where the adversary attacks just a single user, and it can only make a
177//!   single verification query. Translating this result to the general setting via a hybrid
178//!   argument will lead to a very poor bound." Both instantiations inherit Theorem 3, so both
179//!   inherit this. It is orthogonal to S-2 (Theorem 2 has no oracles) and to Q-1′ (query counts,
180//!   not oracle model).
181//! - **Q-2** (new 2026-08-07; concerns the *base* mode, so it also lands on the frozen
182//!   `SaturninAead` — and does **not** apply to `SaturninQcb`, which is an integrated TBC mode
183//!   rather than a generic composition) — the Saturnin spec's IND-qCCA claim for CTR-Cascade
184//!   rests on a citation that has since been disproved. Spec §2.2: "we combine the counter mode …
185//!   and the Cascade construction \[BCK96\] for authentication, following the Encrypt-then-MAC
186//!   composition"; §4.3: the modes "are intended to provide quantum security against chosen
187//!   message superposition attacks and superposition verification queries (IND-qCCA security)";
188//!   and §4.3.1 supplies the load-bearing step: "Soukharev, Jao and Seshadri have revisited these
189//!   results \[SJS16\], and proved that the encrypt-then-MAC composition offers IND-qCCA
190//!   security, assuming that the encryption scheme is IND-qCPA, and the MAC is SUF-qCMA." IACR
191//!   ePrint 2025/387 disproves exactly that claim ("we disprove a claim made by Soukharev et al.
192//!   at PQCrypto 2016"; "\[SJS16, Theorem 3.6\] … is inconclusive"). **The conclusion looks
193//!   repairable, and that is the point of naming this rather than panicking:** 2025/387 §7 gives
194//!   a replacement criterion — "1. the generic composition is Encrypt-then-MAC,
195//!   Encrypt-and-MAC or Encrypt-and+then-MAC, 2. the underlying encryption scheme is IND-qCPA
196//!   secure, and 3. the underlying MAC is a qPRF" (formally its **Thm 3** — "The EatM composition
197//!   of an IND-qCPA\[LoR\] secure symmetric encryption scheme SE and a qPRF F (used as a MAC) is
198//!   IND-qCCA\[LoR\] secure" — carried across to EtM by Thm 4 and Cor 1. Cite all three: Thm 4 and
199//!   Cor 1 only say the three compositions stand or fall together and are vacuous without Thm 3
200//!   supplying the antecedent; §6.2 extends it to
201//!   nonce-based AE with associated data) — and the spec argues all three itself: EtM (§2.2), CTR
202//!   IND-qCPA via \[ATTU16\] (§4.3.3), and Cascade as a qPRF via "Theorem 5.1 in \[SY17\] … (if we
203//!   fix the number of message blocks as a constant)" (§4.3.3), which is the *stronger*
204//!   hypothesis, not the SUF-qCMA / plus-one one the counterexample defeats. What must be signed:
205//!   that the \[SJS16\] → 2025/387 Thm 3 + Thm 4 + Cor 1 citation swap actually goes through, given the
206//!   spec's own caveats (constant block count; "This proof seems not tight") and 2025/387's
207//!   caution "we are not aware of any practical MAC, which has been proven to be a qPRF" — its
208//!   authors did not consider Cascade / \[SY17\]. Until then, do not restate the spec's IND-qCCA
209//!   claim without this footnote.
210//!
211//! The 2026-08-07 review added L-1 and Q-2 to this instantiation's list; before it, the list was
212//! H-1 and Q-1′ only, on the reasoning that this instantiation's inputs, layout, and hash are
213//! identical in shape to the QCB instantiation and its only differences (stream core, no
214//! unpadding branch) *remove* a QCB-specific hypothesis (S-2) rather than adding one. That
215//! reasoning still holds for the *transform*: L-1 is inherited from CTX Theorem 3 and was simply
216//! never documented, and Q-2 is about the **base mode**, not about CTX.
217//!
218//! # Perf note
219//!
220//! CTX adds a fixed number of Saturnin-Hash compression calls per message, plus a second pass
221//! over the associated data (once inside CTR-Cascade's own AAD cascade step, again inside the CTX
222//! tag) — the same structural shape documented for the QCB instantiation in this crate's README.
223
224#[cfg(feature = "alloc")]
225use alloc::vec::Vec;
226
227use lib_q_core::{
228    Aead,
229    AeadDecryptSemantic,
230    AeadKey,
231    DecryptSemanticOutcome,
232    Error,
233    Nonce,
234    Result,
235};
236use zeroize::{
237    Zeroize,
238    Zeroizing,
239};
240
241use crate::aead::SaturninAead;
242use crate::commit::{
243    CASCADE_CTX_LABEL_V0,
244    ctx_tag,
245};
246use crate::hash::SaturninHash;
247
248/// CTX-committed AEAD on Saturnin CTR-Cascade.
249///
250/// Holds a plain [`SaturninAead`] (for the pre-built cascade/CTR cores) plus a pre-built
251/// [`SaturninHash`] for the CTX tag, mirroring `SaturninQcb`'s `committer: SaturninHash` field —
252/// building a `SaturninHash` clocks the round-constant LFSR for both of its domains, so hoisting
253/// it here keeps per-message CTX overhead down to the permutation-call cost the design predicts
254/// instead of paying that setup on every `encrypt`/`decrypt` call (see `crate::commit::ctx_tag`'s
255/// doc comment for the measurement that caught this the first time, on the QCB instantiation).
256///
257/// See the module docs for why this is a distinct type from [`SaturninAead`] rather than a
258/// constructor flag, and for the frozen byte layout.
259pub struct SaturninAeadCtx {
260    base: SaturninAead,
261    committer: SaturninHash,
262}
263
264impl SaturninAeadCtx {
265    /// Create a new committed CTR-Cascade AEAD instance.
266    pub fn new() -> Self {
267        Self {
268            base: SaturninAead::new(),
269            committer: SaturninHash::new(),
270        }
271    }
272
273    /// Key size in bytes (256 bits), identical to [`SaturninAead::key_size`].
274    pub const fn key_size() -> usize {
275        32
276    }
277
278    /// Nonce size in bytes (128 bits), identical to [`SaturninAead::nonce_size`].
279    pub const fn nonce_size() -> usize {
280        16
281    }
282
283    /// Tag size in bytes (256 bits), identical to [`SaturninAead::tag_size`].
284    pub const fn tag_size() -> usize {
285        32
286    }
287
288    /// Allocation-free encrypt: takes key/nonce as byte slices. See [`SaturninAead::encrypt_bytes`]
289    /// for the allocation-free rationale; this wrapper adds the CTX tag replacement on top.
290    pub fn encrypt_bytes(
291        &self,
292        key: &[u8],
293        nonce: &[u8],
294        plaintext: &[u8],
295        associated_data: Option<&[u8]>,
296    ) -> Result<Vec<u8>> {
297        // The injectivity of `LABEL ‖ K ‖ N ‖ T ‖ A` rests on `K` and `N` being fixed-width, so
298        // both lengths are checked HERE, locally, before `nonce` is handed to `ctx_tag` below.
299        // `base.encrypt_bytes` re-checks them (same error variants, so this is not observable for
300        // any input), but relying on that alone would make injectivity a property of call order
301        // rather than of this function: a wrong-length key would at least panic at
302        // `key32.copy_from_slice`, whereas a wrong-length nonce would be absorbed silently as a
303        // second variable-length field and break the argument with no symptom. Do not remove.
304        if key.len() != Self::key_size() {
305            return Err(Error::InvalidKeySize {
306                expected: Self::key_size(),
307                actual: key.len(),
308            });
309        }
310        if nonce.len() != Self::nonce_size() {
311            return Err(Error::InvalidNonceSize {
312                expected: Self::nonce_size(),
313                actual: nonce.len(),
314            });
315        }
316
317        // `base.encrypt_bytes` performs ALL input validation (key/nonce length, size limit) and
318        // produces `C ‖ T`, `T` the 32-byte CTR-Cascade cascade tag.
319        let mut ciphertext = self
320            .base
321            .encrypt_bytes(key, nonce, plaintext, associated_data)?;
322        let ad = associated_data.unwrap_or(&[]);
323
324        let body_len = ciphertext.len() - Self::tag_size();
325        let mut base_tag = Zeroizing::new([0u8; 32]);
326        base_tag.copy_from_slice(&ciphertext[body_len..]);
327        ciphertext.truncate(body_len);
328
329        let mut key32 = Zeroizing::new([0u8; 32]);
330        key32.copy_from_slice(key);
331
332        let committed_tag = ctx_tag(
333            &self.committer,
334            CASCADE_CTX_LABEL_V0,
335            &key32,
336            nonce,
337            &base_tag,
338            ad,
339        )?;
340        ciphertext.extend_from_slice(&*committed_tag);
341        Ok(ciphertext)
342    }
343
344    /// Allocation-free Layer A decrypt: byte-slice counterpart to [`Aead::decrypt`]. Returns the
345    /// plaintext on success, or [`Error::VerificationFailed`] on tag mismatch.
346    pub fn decrypt_bytes(
347        &self,
348        key: &[u8],
349        nonce: &[u8],
350        ciphertext: &[u8],
351        associated_data: Option<&[u8]>,
352    ) -> Result<Vec<u8>> {
353        match self.decrypt_core(key, nonce, ciphertext, associated_data) {
354            Ok(DecryptSemanticOutcome::Success(p)) => Ok(Vec::clone(&*p)),
355            Ok(DecryptSemanticOutcome::AuthenticationFailed) => Err(Error::VerificationFailed {
356                operation: "Saturnin CTR-Cascade CTX tag verification".into(),
357            }),
358            Err(e) => Err(e),
359        }
360    }
361
362    /// Shared decrypt core for Layer A ([`Aead::decrypt`]) and Layer B
363    /// ([`AeadDecryptSemantic::decrypt_semantic`]).
364    ///
365    /// Validation (key/nonce length, minimum ciphertext length) mirrors
366    /// `SaturninAead::decrypt_core` and MUST precede the `ctx_tag` call — see the module docs'
367    /// injectivity argument, which depends on the nonce never reaching `ctx_tag` at any length
368    /// other than 16. The CTX tag is recomputed unconditionally, before any comparison, and CTR is
369    /// always run over the full body before the authentication outcome is allowed to influence the
370    /// returned plaintext — the same full-work-no-early-exit schedule as `SaturninAead`.
371    fn decrypt_core(
372        &self,
373        key: &[u8],
374        nonce: &[u8],
375        ciphertext: &[u8],
376        associated_data: Option<&[u8]>,
377    ) -> Result<DecryptSemanticOutcome> {
378        if key.len() != Self::key_size() {
379            return Err(Error::InvalidKeySize {
380                expected: Self::key_size(),
381                actual: key.len(),
382            });
383        }
384        if nonce.len() != Self::nonce_size() {
385            return Err(Error::InvalidNonceSize {
386                expected: Self::nonce_size(),
387                actual: nonce.len(),
388            });
389        }
390        if (ciphertext.len() >> 5) >= 0xFFFF_FFFE {
391            return Err(Error::InvalidMessageSize {
392                max: 0xFFFF_FFFE << 5,
393                actual: ciphertext.len(),
394            });
395        }
396        if ciphertext.len() < Self::tag_size() {
397            return Err(Error::aead_ciphertext_shorter_than_tag(
398                Self::tag_size(),
399                ciphertext.len(),
400            ));
401        }
402
403        let ad = associated_data.unwrap_or(&[]);
404        let body_len = ciphertext.len() - Self::tag_size();
405        let body = &ciphertext[..body_len];
406        let received = &ciphertext[body_len..];
407
408        let mut key_staged = Zeroizing::new([0u8; 32]);
409        key_staged.copy_from_slice(key);
410        let mut nonce_staged = Zeroizing::new([0u8; 16]);
411        nonce_staged.copy_from_slice(nonce);
412        let kb = key_staged.as_slice();
413        let nb = nonce_staged.as_slice();
414
415        let base_tag = self.base.base_tag_over(kb, nb, ad, body)?;
416        let expected = ctx_tag(
417            &self.committer,
418            CASCADE_CTX_LABEL_V0,
419            &key_staged,
420            nb,
421            &base_tag,
422            ad,
423        )?;
424
425        let tag_valid = lib_q_core::Utils::constant_time_compare(&*expected, received);
426
427        let mut plaintext = body.to_vec();
428        if let Err(e) = self.base.ctr_encrypt(kb, nb, &mut plaintext) {
429            plaintext.zeroize();
430            return Err(e);
431        }
432
433        if tag_valid {
434            Ok(DecryptSemanticOutcome::Success(Zeroizing::new(plaintext)))
435        } else {
436            plaintext.zeroize();
437            Ok(DecryptSemanticOutcome::AuthenticationFailed)
438        }
439    }
440}
441
442impl Aead for SaturninAeadCtx {
443    fn encrypt(
444        &self,
445        key: &AeadKey,
446        nonce: &Nonce,
447        plaintext: &[u8],
448        associated_data: Option<&[u8]>,
449    ) -> Result<Vec<u8>> {
450        self.encrypt_bytes(key.as_bytes(), nonce.as_bytes(), plaintext, associated_data)
451    }
452
453    fn decrypt(
454        &self,
455        key: &AeadKey,
456        nonce: &Nonce,
457        ciphertext: &[u8],
458        associated_data: Option<&[u8]>,
459    ) -> Result<Vec<u8>> {
460        self.decrypt_bytes(
461            key.as_bytes(),
462            nonce.as_bytes(),
463            ciphertext,
464            associated_data,
465        )
466    }
467}
468
469impl AeadDecryptSemantic for SaturninAeadCtx {
470    fn decrypt_semantic(
471        &self,
472        key: &AeadKey,
473        nonce: &Nonce,
474        ciphertext: &[u8],
475        associated_data: Option<&[u8]>,
476    ) -> Result<DecryptSemanticOutcome> {
477        self.decrypt_core(
478            key.as_bytes(),
479            nonce.as_bytes(),
480            ciphertext,
481            associated_data,
482        )
483    }
484}
485
486impl Default for SaturninAeadCtx {
487    fn default() -> Self {
488        Self::new()
489    }
490}
491
492#[cfg(test)]
493mod tests {
494    use super::*;
495
496    fn key() -> AeadKey {
497        AeadKey::new((0u8..32).collect::<Vec<_>>())
498    }
499
500    fn nonce() -> Nonce {
501        Nonce::new((0u8..16).collect::<Vec<_>>())
502    }
503
504    #[test]
505    fn constants() {
506        assert_eq!(SaturninAeadCtx::key_size(), 32);
507        assert_eq!(SaturninAeadCtx::nonce_size(), 16);
508        assert_eq!(SaturninAeadCtx::tag_size(), 32);
509    }
510
511    #[test]
512    fn round_trip() -> Result<()> {
513        let aead = SaturninAeadCtx::new();
514        let ct = aead.encrypt(&key(), &nonce(), b"hello", Some(b"ad"))?;
515        assert_eq!(ct.len(), 5 + 32);
516        let pt = aead.decrypt(&key(), &nonce(), &ct, Some(b"ad"))?;
517        assert_eq!(pt, b"hello");
518        Ok(())
519    }
520
521    #[test]
522    fn default_matches_new() -> Result<()> {
523        let a = SaturninAeadCtx::default();
524        let b = SaturninAeadCtx::new();
525        let pt = b"compare";
526        assert_eq!(
527            a.encrypt(&key(), &nonce(), pt, None)?,
528            b.encrypt(&key(), &nonce(), pt, None)?
529        );
530        Ok(())
531    }
532
533    #[test]
534    fn encrypt_rejects_wrong_key_size() {
535        let aead = SaturninAeadCtx::new();
536        let err = aead
537            .encrypt_bytes(&[0u8; 10], &[0u8; 16], b"m", None)
538            .expect_err("10-byte key must be rejected");
539        assert!(matches!(
540            err,
541            Error::InvalidKeySize {
542                expected: 32,
543                actual: 10
544            }
545        ));
546    }
547
548    #[test]
549    fn encrypt_rejects_wrong_nonce_size() {
550        let aead = SaturninAeadCtx::new();
551        let err = aead
552            .encrypt_bytes(&[0u8; 32], &[0u8; 5], b"m", None)
553            .expect_err("5-byte nonce must be rejected");
554        assert!(matches!(
555            err,
556            Error::InvalidNonceSize {
557                expected: 16,
558                actual: 5
559            }
560        ));
561    }
562
563    #[test]
564    fn decrypt_rejects_wrong_key_size() {
565        let aead = SaturninAeadCtx::new();
566        let err = aead
567            .decrypt_bytes(&[0u8; 8], &[0u8; 16], &[0u8; 32], None)
568            .expect_err("8-byte key must be rejected");
569        assert!(matches!(
570            err,
571            Error::InvalidKeySize {
572                expected: 32,
573                actual: 8
574            }
575        ));
576    }
577
578    #[test]
579    fn decrypt_rejects_wrong_nonce_size() {
580        let aead = SaturninAeadCtx::new();
581        let err = aead
582            .decrypt_bytes(&[0u8; 32], &[0u8; 3], &[0u8; 32], None)
583            .expect_err("3-byte nonce must be rejected");
584        assert!(matches!(
585            err,
586            Error::InvalidNonceSize {
587                expected: 16,
588                actual: 3
589            }
590        ));
591    }
592
593    #[test]
594    fn decrypt_rejects_ciphertext_shorter_than_tag() {
595        let aead = SaturninAeadCtx::new();
596        let err = aead
597            .decrypt_bytes(&[0u8; 32], &[0u8; 16], &[0u8; 5], None)
598            .expect_err("5-byte ciphertext (shorter than the 32-byte tag) must be rejected");
599        assert!(matches!(err, Error::InvalidCiphertextSize { .. }));
600    }
601
602    #[test]
603    fn decrypt_semantic_reports_authentication_failed_on_tampered_tag() -> Result<()> {
604        use lib_q_core::AeadDecryptSemantic;
605
606        let aead = SaturninAeadCtx::new();
607        let ct = aead.encrypt(&key(), &nonce(), b"payload", Some(b"ad"))?;
608        let mut tampered = ct.clone();
609        *tampered.last_mut().expect("tag byte") ^= 0x01;
610
611        let outcome = aead.decrypt_semantic(&key(), &nonce(), &tampered, Some(b"ad"))?;
612        assert_eq!(outcome, DecryptSemanticOutcome::AuthenticationFailed);
613
614        // Layer A must surface the same failure as a hard error, not silently succeed.
615        assert!(matches!(
616            aead.decrypt(&key(), &nonce(), &tampered, Some(b"ad")),
617            Err(Error::VerificationFailed { .. })
618        ));
619        Ok(())
620    }
621}