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}