Skip to main content

lib_q_saturnin/
aead.rs

1//! Saturnin AEAD implementation
2//!
3//! Saturnin is a lightweight post-quantum symmetric algorithm suite designed
4//! for IoT and constrained devices, providing authenticated encryption and
5//! hashing modes with superior post-quantum security.
6//!
7//! ## Usage Example
8//!
9//! ```rust
10//! use lib_q_saturnin::{
11//!     Aead,
12//!     AeadKey,
13//!     Nonce,
14//!     SaturninAead,
15//! };
16//!
17//! // Create AEAD instance
18//! let aead = SaturninAead::new();
19//!
20//! // Generate key and nonce (in practice, use secure random generation)
21//! let key = AeadKey::new(vec![0u8; 32]);
22//! let nonce = Nonce::new(vec![0u8; 16]);
23//!
24//! let plaintext = b"Secret message";
25//! let associated_data = b"metadata";
26//!
27//! // Encrypt with associated data
28//! let ciphertext = aead
29//!     .encrypt(&key, &nonce, plaintext, Some(associated_data))
30//!     .unwrap();
31//!
32//! // Decrypt and verify authenticity
33//! let decrypted = aead
34//!     .decrypt(&key, &nonce, &ciphertext, Some(associated_data))
35//!     .unwrap();
36//! assert_eq!(decrypted, plaintext);
37//! ```
38//!
39//! ## Performance Notes
40//!
41//! - **Key size**: 256 bits (32 bytes)
42//! - **Nonce size**: 128 bits (16 bytes)  
43//! - **Tag size**: 256 bits (32 bytes)
44//! - **Throughput**: ~100-500 MB/s on modern hardware
45//! - **Memory usage**: Small fixed state (pre-built cipher cores for domains 1–5); per-message
46//!   key/nonce are staged in zeroizing buffers at the `Aead` boundary, and the cascade running tag
47//!   plus per-iteration cascade blocks (`t`, `m`, and SIMD xor staging) are held in `Zeroizing`
48//!   buffers so they are cleared on drop.
49//!
50//! ## Verification timing
51//!
52//! Decrypt computes the expected tag over AAD and ciphertext (cascade), compares it to the
53//! appended tag with [`lib_q_core::Utils::constant_time_compare`](lib_q_core::Utils::constant_time_compare),
54//! then **always** runs full CTR on the ciphertext body. Only after that does the API return
55//! `Ok(plaintext)` versus `Err(Error::VerificationFailed)` (Layer A) for a failed tag after that
56//! schedule, or `Ok(DecryptSemanticOutcome::AuthenticationFailed)` (Layer B). Ciphertext shorter
57//! than the tag is rejected up front as `Err(Error::InvalidCiphertextSize)` (operational). Failed
58//! plaintext buffers are zeroized. This matches the [`lib_q_core::Aead`] contract in
59//! `lib-q-core`: bulk symmetric work is not skipped on auth failure; the public `Result` / outcome
60//! still discriminates at the boundary. For semantic decrypt without plaintext on authentication
61//! failure, see [`lib_q_core::AeadDecryptSemantic`]. See this crate’s
62//! `SECURITY.md` for Saturnin-Short specifics.
63//!
64//! ## Open obligation Q-2 — the spec's IND-qCCA claim for this mode rests on a disproved citation
65//!
66//! This mode's wire format is **frozen** and this note changes nothing about it; it exists so the
67//! "superior post-quantum security" framing above is not read as a settled result. The Saturnin
68//! LWC spec §4.3 says the modes "are intended to provide quantum security against chosen message
69//! superposition attacks and superposition verification queries (IND-qCCA security)", and §4.3.1
70//! supplies the load-bearing step: "Soukharev, Jao and Seshadri have revisited these results
71//! \[SJS16\], and proved that the encrypt-then-MAC composition offers IND-qCCA security, assuming
72//! that the encryption scheme is IND-qCPA, and the MAC is SUF-qCMA." IACR ePrint 2025/387
73//! disproves exactly that claim ("we disprove a claim made by Soukharev et al. at PQCrypto 2016";
74//! "\[SJS16, Theorem 3.6\] … is inconclusive"). The conclusion looks **repairable** — see the
75//! **Q-2** bullet in `src/aead_ctx.rs` for the full statement, the proposed replacement chain
76//! (2025/387 Thm 3 + Thm 4 + Cor 1, which need the MAC to be a *qPRF*, a hypothesis the spec
77//! argues for Cascade in §4.3.3), and what a cryptographer would have to sign. Until then, do not
78//! restate the spec's IND-qCCA claim for this mode without the footnote. Classical AE security is
79//! unaffected; this is about the Q2 claim only. **Q-2 does not apply to `SaturninQcb`**, which is
80//! an integrated TBC mode rather than a generic composition.
81
82#[cfg(feature = "alloc")]
83use alloc::{
84    string::ToString,
85    vec::Vec,
86};
87
88use lib_q_core::{
89    Aead,
90    AeadDecryptSemantic,
91    AeadKey,
92    DecryptSemanticOutcome,
93    Error,
94    Nonce,
95    Result,
96};
97use zeroize::{
98    Zeroize,
99    Zeroizing,
100};
101
102use crate::core::SaturninCore;
103#[cfg(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon"))]
104use crate::simd::{
105    encrypt_blocks8_dispatch,
106    simd_xor,
107};
108
109/// Pre-built Saturnin cores for CTR-Cascade AEAD (10 super-rounds, domains 1–5).
110///
111/// Building these once per [`SaturninAead`] avoids repeated `Vec` allocation of round constants
112/// on every encrypt/decrypt (domains 1–5 cover CTR and all cascade steps).
113struct SaturninAeadCores {
114    d1: SaturninCore,
115    d2: SaturninCore,
116    d3: SaturninCore,
117    d4: SaturninCore,
118    d5: SaturninCore,
119}
120
121impl SaturninAeadCores {
122    fn new() -> Result<Self> {
123        Ok(Self {
124            d1: SaturninCore::new(10, 1)?,
125            d2: SaturninCore::new(10, 2)?,
126            d3: SaturninCore::new(10, 3)?,
127            d4: SaturninCore::new(10, 4)?,
128            d5: SaturninCore::new(10, 5)?,
129        })
130    }
131
132    #[inline]
133    fn domain(&self, d: u8) -> &SaturninCore {
134        match d {
135            1 => &self.d1,
136            2 => &self.d2,
137            3 => &self.d3,
138            4 => &self.d4,
139            5 => &self.d5,
140            _ => unreachable!("AEAD CTR/cascade only uses domains 1–5"),
141        }
142    }
143}
144
145/// Saturnin AEAD implementation
146///
147/// Provides authenticated encryption using the Saturnin CTR-Cascade mode.
148/// This is the full AEAD mode that supports associated data and arbitrary
149/// length plaintexts.
150pub struct SaturninAead {
151    cores: SaturninAeadCores,
152}
153
154impl SaturninAead {
155    /// Create a new Saturnin AEAD instance
156    pub fn new() -> Self {
157        Self {
158            cores: SaturninAeadCores::new().expect("Saturnin AEAD uses fixed valid domains"),
159        }
160    }
161
162    /// Get the key size in bytes (256 bits = 32 bytes)
163    pub const fn key_size() -> usize {
164        32
165    }
166
167    /// Get the nonce size in bytes (128 bits = 16 bytes)
168    pub const fn nonce_size() -> usize {
169        16
170    }
171
172    /// Get the tag size in bytes (256 bits = 32 bytes)
173    pub const fn tag_size() -> usize {
174        32
175    }
176
177    /// Initialize the cascade state
178    fn cascade_init(&self, key: &[u8], nonce: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
179        let key32: &[u8; 32] = key.try_into().map_err(|_| Error::InvalidKeySize {
180            expected: 32,
181            actual: key.len(),
182        })?;
183
184        let mut r = Zeroizing::new([0u8; 32]);
185
186        // Copy nonce to first 16 bytes
187        r[0..16].copy_from_slice(nonce);
188        r[16] = 0x80;
189        // Remaining bytes are already zero
190
191        // Encrypt with cascade parameters: 10 super-rounds, domain 2 (AAD1)
192        self.cores.d2.encrypt_block_32(key32, &mut r)?;
193
194        // XOR with nonce
195        for i in 0..16 {
196            r[i] ^= nonce[i];
197        }
198        r[16] ^= 0x80;
199
200        Ok(r)
201    }
202
203    /// Apply cascade construction to data (optimized)
204    fn cascade(&self, r: &mut [u8; 32], d1: u8, d2: u8, data: &[u8]) -> Result<()> {
205        let core_d1 = self.cores.domain(d1);
206        let core_d2 = self.cores.domain(d2);
207
208        let mut offset = 0;
209
210        loop {
211            let mut t: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
212            let mut m: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
213            let remaining = data.len() - offset;
214
215            if remaining >= 32 {
216                t.copy_from_slice(&data[offset..offset + 32]);
217                offset += 32;
218
219                // Use pre-allocated core for d1
220                m.copy_from_slice(&*t);
221                core_d1.encrypt_block_32(&*r, &mut m)?;
222            } else {
223                t[0..remaining].copy_from_slice(&data[offset..]);
224                t[remaining] = 0x80;
225                // Remaining bytes are already zero
226
227                // Use pre-allocated core for d2
228                m.copy_from_slice(&*t);
229                core_d2.encrypt_block_32(&*r, &mut m)?;
230            }
231
232            #[cfg(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon"))]
233            {
234                let mut out: Zeroizing<[u8; 32]> = Zeroizing::new([0u8; 32]);
235                simd_xor::xor_blocks_32(&m, &t, &mut out);
236                r.copy_from_slice(&*out);
237            }
238
239            #[cfg(not(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon")))]
240            {
241                for i in 0..32 {
242                    r[i] = m[i] ^ t[i];
243                }
244            }
245
246            if remaining < 32 {
247                break;
248            }
249        }
250
251        Ok(())
252    }
253
254    /// Compute the raw CTR-Cascade tag `T` over associated data and a ciphertext body, without
255    /// touching the ciphertext body itself (no CTR pass).
256    ///
257    /// `pub(crate)`: this is a pure extraction of the tag computation already present verbatim in
258    /// [`Self::decrypt_core`] (`cascade_init` + `cascade(2,3,ad)` + `cascade(4,5,ct_body)`) and,
259    /// interleaved with the CTR pass, in [`Self::encrypt_bytes`]. It exists so
260    /// [`crate::aead_ctx::SaturninAeadCtx`] can recompute `T` on its decrypt path without
261    /// duplicating the cascade construction. Adding this method changes no production bytes of
262    /// `SaturninAead` itself — `decrypt_core`/`encrypt_bytes` are left untouched, and
263    /// `tests/aead_kat_pin.rs` pins that `SaturninAead`'s own output is unaffected.
264    ///
265    /// Gated on `hash`: `aead_ctx` (the sole caller) is `all(aead, hash)`, and this method lives
266    /// inside the `aead`-gated module, so `#[cfg(feature = "hash")]` here is exactly
267    /// `all(aead, hash)`. Without the gate, a `--no-default-features --features std,alloc,aead`
268    /// build compiles this method with nothing calling it — OBSERVED as
269    /// `warning: method `base_tag_over` is never used`, which is a hard error under any
270    /// `-D warnings` gate. (`ctr_encrypt` below needs no such gate: `encrypt_bytes` and
271    /// `decrypt_core` in this same module call it regardless of `hash`.)
272    #[cfg(feature = "hash")]
273    pub(crate) fn base_tag_over(
274        &self,
275        key: &[u8],
276        nonce: &[u8],
277        ad: &[u8],
278        ct_body: &[u8],
279    ) -> Result<Zeroizing<[u8; 32]>> {
280        let mut tag = self.cascade_init(key, nonce)?;
281        self.cascade(&mut tag, 2, 3, ad)?;
282        self.cascade(&mut tag, 4, 5, ct_body)?;
283        Ok(tag)
284    }
285
286    /// CTR encryption/decryption (optimized).
287    ///
288    /// `pub(crate)` (widened from private) so [`crate::aead_ctx::SaturninAeadCtx`]'s decrypt path
289    /// can run CTR without re-implementing it — see that module for why this stays deliberately
290    /// pure delegation rather than a refactor of `encrypt_bytes`/`decrypt_core`.
291    pub(crate) fn ctr_encrypt(&self, key: &[u8], nonce: &[u8], data: &mut [u8]) -> Result<()> {
292        let key32: &[u8; 32] = key.try_into().map_err(|_| Error::InvalidKeySize {
293            expected: 32,
294            actual: key.len(),
295        })?;
296
297        let core = &self.cores.d1;
298
299        let mut counter = 1u32; // Counter starts at 1
300        let mut offset = 0;
301
302        while offset < data.len() {
303            #[cfg(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon"))]
304            if data.len() - offset >= 32 * 8 {
305                let mut keystream_blocks = [[0u8; 32]; 8];
306                for (lane, block) in keystream_blocks.iter_mut().enumerate() {
307                    let c = counter.wrapping_add(lane as u32);
308                    block[0..16].copy_from_slice(nonce);
309                    block[16] = 0x80;
310                    block[28] = (c >> 24) as u8;
311                    block[29] = (c >> 16) as u8;
312                    block[30] = (c >> 8) as u8;
313                    block[31] = c as u8;
314                }
315
316                encrypt_blocks8_dispatch(10, 1, key, &mut keystream_blocks, Some(core))?;
317
318                for (lane, ks) in keystream_blocks.iter().enumerate() {
319                    let start = offset + (lane * 32);
320                    let mut input = [0u8; 32];
321                    input.copy_from_slice(&data[start..start + 32]);
322                    let mut out = [0u8; 32];
323                    simd_xor::xor_blocks_32(&input, ks, &mut out);
324                    data[start..start + 32].copy_from_slice(&out);
325                }
326
327                offset += 32 * 8;
328                let (next_counter, overflowed) = counter.overflowing_add(8);
329                if overflowed {
330                    return Err(Error::InvalidMessageSize {
331                        max: usize::MAX,
332                        actual: data.len(),
333                    });
334                }
335                counter = next_counter;
336                continue;
337            }
338
339            let mut keystream = [0u8; 32];
340
341            // Build counter block efficiently
342            keystream[0..16].copy_from_slice(nonce);
343            keystream[16] = 0x80;
344            // Bytes 17-27 are zero
345            keystream[28] = (counter >> 24) as u8;
346            keystream[29] = (counter >> 16) as u8;
347            keystream[30] = (counter >> 8) as u8;
348            keystream[31] = counter as u8;
349
350            // Encrypt to get keystream
351            core.encrypt_block_32(key32, &mut keystream)?;
352
353            let remaining = data.len() - offset;
354            let block_len = remaining.min(32);
355            #[cfg(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon"))]
356            {
357                if block_len == 32 {
358                    let mut input = [0u8; 32];
359                    input.copy_from_slice(&data[offset..offset + 32]);
360                    let mut out = [0u8; 32];
361                    simd_xor::xor_blocks_32(&input, &keystream, &mut out);
362                    data[offset..offset + 32].copy_from_slice(&out);
363                } else {
364                    for i in 0..block_len {
365                        data[offset + i] ^= keystream[i];
366                    }
367                }
368            }
369
370            #[cfg(not(any(feature = "simd", feature = "simd-avx2", feature = "simd-neon")))]
371            {
372                for i in 0..block_len {
373                    data[offset + i] ^= keystream[i];
374                }
375            }
376
377            offset += block_len;
378            counter = counter.wrapping_add(1);
379        }
380
381        Ok(())
382    }
383
384    /// Shared decrypt core for Layer A ([`Aead::decrypt`](lib_q_core::Aead::decrypt)) and Layer B
385    /// ([`AeadDecryptSemantic::decrypt_semantic`](lib_q_core::AeadDecryptSemantic::decrypt_semantic)).
386    ///
387    /// Takes key/nonce as byte slices: the `Aead`/`AeadDecryptSemantic` trait methods forward
388    /// `key.as_bytes()`/`nonce.as_bytes()` here, and the allocation-free [`Self::decrypt_bytes`]
389    /// passes its slices directly — neither path materializes an `AeadKey`/`Nonce` wrapper.
390    fn decrypt_core(
391        &self,
392        key: &[u8],
393        nonce: &[u8],
394        ciphertext: &[u8],
395        associated_data: Option<&[u8]>,
396    ) -> Result<DecryptSemanticOutcome> {
397        if key.len() != Self::key_size() {
398            return Err(Error::InvalidKeySize {
399                expected: Self::key_size(),
400                actual: key.len(),
401            });
402        }
403
404        if nonce.len() != Self::nonce_size() {
405            return Err(Error::InvalidNonceSize {
406                expected: Self::nonce_size(),
407                actual: nonce.len(),
408            });
409        }
410
411        if (ciphertext.len() >> 5) >= 0xFFFFFFFE {
412            return Err(Error::InvalidMessageSize {
413                max: 0xFFFFFFFE << 5,
414                actual: ciphertext.len(),
415            });
416        }
417
418        if ciphertext.len() < Self::tag_size() {
419            return Err(Error::aead_ciphertext_shorter_than_tag(
420                Self::tag_size(),
421                ciphertext.len(),
422            ));
423        }
424
425        let ad = associated_data.unwrap_or(&[]);
426        let plaintext_len = ciphertext.len() - 32;
427        let ciphertext_data = &ciphertext[0..plaintext_len];
428        let received_tag = &ciphertext[plaintext_len..];
429
430        let mut key_staged = Zeroizing::new([0u8; 32]);
431        key_staged.copy_from_slice(key);
432        let mut nonce_staged = Zeroizing::new([0u8; 16]);
433        nonce_staged.copy_from_slice(nonce);
434        let kb = key_staged.as_slice();
435        let nb = nonce_staged.as_slice();
436
437        let mut tag = self.cascade_init(kb, nb)?;
438        self.cascade(&mut tag, 2, 3, ad)?;
439        self.cascade(&mut tag, 4, 5, ciphertext_data)?;
440
441        let tag_valid = lib_q_core::Utils::constant_time_compare(&*tag, received_tag);
442
443        let mut plaintext = ciphertext_data.to_vec();
444        if let Err(e) = self.ctr_encrypt(kb, nb, &mut plaintext) {
445            plaintext.zeroize();
446            return Err(e);
447        }
448
449        if tag_valid {
450            Ok(DecryptSemanticOutcome::Success(Zeroizing::new(plaintext)))
451        } else {
452            plaintext.zeroize();
453            Ok(DecryptSemanticOutcome::AuthenticationFailed)
454        }
455    }
456
457    /// Allocation-free encrypt: takes key/nonce as byte slices, avoiding the `AeadKey`/`Nonce`
458    /// `Vec` wrappers that [`Aead::encrypt`] requires. Per-packet callers (e.g. per-packet record sealing)
459    /// use this to skip two heap allocations on every record; the trait method forwards here.
460    pub fn encrypt_bytes(
461        &self,
462        key: &[u8],
463        nonce: &[u8],
464        plaintext: &[u8],
465        associated_data: Option<&[u8]>,
466    ) -> Result<Vec<u8>> {
467        if key.len() != Self::key_size() {
468            return Err(Error::InvalidKeySize {
469                expected: Self::key_size(),
470                actual: key.len(),
471            });
472        }
473
474        if nonce.len() != Self::nonce_size() {
475            return Err(Error::InvalidNonceSize {
476                expected: Self::nonce_size(),
477                actual: nonce.len(),
478            });
479        }
480
481        // Check length limits (about 137.4 GB)
482        if (plaintext.len() >> 5) >= 0xFFFFFFFD {
483            return Err(Error::InvalidMessageSize {
484                max: 0xFFFFFFFD << 5,
485                actual: plaintext.len(),
486            });
487        }
488
489        let ad = associated_data.unwrap_or(&[]);
490
491        let mut key_staged = Zeroizing::new([0u8; 32]);
492        key_staged.copy_from_slice(key);
493        let mut nonce_staged = Zeroizing::new([0u8; 16]);
494        nonce_staged.copy_from_slice(nonce);
495        let kb = key_staged.as_slice();
496        let nb = nonce_staged.as_slice();
497
498        // Initialize cascade state
499        let mut tag = self.cascade_init(kb, nb)?;
500
501        // Process associated data
502        self.cascade(&mut tag, 2, 3, ad)?;
503
504        // Encrypt plaintext with CTR
505        let mut ciphertext = plaintext.to_vec();
506        if let Err(e) = self.ctr_encrypt(kb, nb, &mut ciphertext) {
507            ciphertext.zeroize();
508            return Err(e);
509        }
510
511        // Continue cascade on ciphertext
512        self.cascade(&mut tag, 4, 5, &ciphertext)?;
513
514        // Append tag
515        ciphertext.extend_from_slice(&*tag);
516
517        Ok(ciphertext)
518    }
519
520    /// Allocation-free Layer A decrypt: byte-slice counterpart to [`Aead::decrypt`]. Returns the
521    /// plaintext on success, or [`Error::VerificationFailed`] on tag mismatch.
522    pub fn decrypt_bytes(
523        &self,
524        key: &[u8],
525        nonce: &[u8],
526        ciphertext: &[u8],
527        associated_data: Option<&[u8]>,
528    ) -> Result<Vec<u8>> {
529        match self.decrypt_core(key, nonce, ciphertext, associated_data) {
530            Ok(DecryptSemanticOutcome::Success(p)) => Ok(Vec::clone(&*p)),
531            Ok(DecryptSemanticOutcome::AuthenticationFailed) => Err(Error::VerificationFailed {
532                operation: "AEAD tag verification".to_string(),
533            }),
534            Err(e) => Err(e),
535        }
536    }
537}
538
539impl Aead for SaturninAead {
540    /// Encrypt data with authentication
541    ///
542    /// # Arguments
543    /// * `key` - 256-bit encryption key
544    /// * `nonce` - 128-bit nonce
545    /// * `plaintext` - Data to encrypt
546    /// * `associated_data` - Additional authenticated data
547    ///
548    /// # Returns
549    /// Encrypted data with authentication tag appended
550    fn encrypt(
551        &self,
552        key: &AeadKey,
553        nonce: &Nonce,
554        plaintext: &[u8],
555        associated_data: Option<&[u8]>,
556    ) -> Result<Vec<u8>> {
557        self.encrypt_bytes(key.as_bytes(), nonce.as_bytes(), plaintext, associated_data)
558    }
559
560    /// Decrypt and verify data (Layer A); shares one decrypt core with [`lib_q_core::AeadDecryptSemantic`].
561    fn decrypt(
562        &self,
563        key: &AeadKey,
564        nonce: &Nonce,
565        ciphertext: &[u8],
566        associated_data: Option<&[u8]>,
567    ) -> Result<Vec<u8>> {
568        self.decrypt_bytes(
569            key.as_bytes(),
570            nonce.as_bytes(),
571            ciphertext,
572            associated_data,
573        )
574    }
575}
576
577impl AeadDecryptSemantic for SaturninAead {
578    /// Layer B semantic decrypt; see `docs/adr/003-aead-decrypt-layers.md`.
579    fn decrypt_semantic(
580        &self,
581        key: &AeadKey,
582        nonce: &Nonce,
583        ciphertext: &[u8],
584        associated_data: Option<&[u8]>,
585    ) -> Result<DecryptSemanticOutcome> {
586        self.decrypt_core(
587            key.as_bytes(),
588            nonce.as_bytes(),
589            ciphertext,
590            associated_data,
591        )
592    }
593}
594
595impl Default for SaturninAead {
596    fn default() -> Self {
597        Self::new()
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    #[cfg(feature = "alloc")]
604    use alloc::vec;
605
606    use super::*;
607
608    #[test]
609    fn test_saturnin_creation() {
610        let _aead = SaturninAead::new();
611        // Saturnin implementation created successfully
612        // Test passes if we reach this point without panicking
613    }
614
615    #[test]
616    fn test_saturnin_constants() {
617        assert_eq!(SaturninAead::key_size(), 32);
618        assert_eq!(SaturninAead::nonce_size(), 16);
619        assert_eq!(SaturninAead::tag_size(), 32);
620    }
621
622    #[test]
623    fn test_saturnin_encrypt_decrypt_round_trip() -> Result<()> {
624        let aead = SaturninAead::new();
625        let key = AeadKey::new(vec![0u8; 32]);
626        let nonce = Nonce::new(vec![0u8; 16]);
627        let plaintext = b"test"; // 4 bytes
628        let ad: Option<&[u8]> = None;
629
630        // Test encryption
631        let ciphertext = aead.encrypt(&key, &nonce, plaintext, ad)?;
632        assert_eq!(ciphertext.len(), plaintext.len() + 32); // plaintext + 32-byte tag
633
634        // Test decryption
635        let decrypted = aead.decrypt(&key, &nonce, &ciphertext, ad)?;
636        assert_eq!(decrypted, plaintext);
637
638        Ok(())
639    }
640
641    #[test]
642    fn test_saturnin_decrypt_semantic_bad_tag() -> Result<()> {
643        use lib_q_core::AeadDecryptSemantic;
644
645        let aead = SaturninAead::new();
646        let key = AeadKey::new(vec![7u8; 32]);
647        let nonce = Nonce::new(vec![8u8; 16]);
648        let ad: Option<&[u8]> = Some(b"ad");
649        let ct = aead.encrypt(&key, &nonce, b"m", ad)?;
650        let mut bad = ct.clone();
651        *bad.last_mut().expect("tag") ^= 0x40;
652        let out = aead.decrypt_semantic(&key, &nonce, &bad, ad)?;
653        assert_eq!(out, DecryptSemanticOutcome::AuthenticationFailed);
654        assert!(matches!(
655            aead.decrypt(&key, &nonce, &bad, ad),
656            Err(Error::VerificationFailed { .. })
657        ));
658        match aead.decrypt_semantic(&key, &nonce, &ct, ad)? {
659            DecryptSemanticOutcome::Success(pt) => assert_eq!(pt.as_slice(), b"m"),
660            DecryptSemanticOutcome::AuthenticationFailed => {
661                panic!("unexpected auth failure on good ciphertext")
662            }
663        }
664        Ok(())
665    }
666
667    #[test]
668    fn test_saturnin_default_matches_new() {
669        // `Default` must produce a functioning instance, not merely compile — round-trip
670        // through it end to end rather than only constructing it.
671        let aead = SaturninAead::default();
672        let key = AeadKey::new(vec![3u8; 32]);
673        let nonce = Nonce::new(vec![4u8; 16]);
674        let ct = aead
675            .encrypt(&key, &nonce, b"via-default", None)
676            .expect("default-constructed AEAD must encrypt");
677        let pt = aead
678            .decrypt(&key, &nonce, &ct, None)
679            .expect("default-constructed AEAD must decrypt its own ciphertext");
680        assert_eq!(pt, b"via-default");
681    }
682
683    #[test]
684    fn test_encrypt_bytes_rejects_wrong_key_size() {
685        let aead = SaturninAead::new();
686        let err = aead
687            .encrypt_bytes(&[0u8; 31], &[0u8; 16], b"m", None)
688            .expect_err("31-byte key must be rejected");
689        assert!(matches!(
690            err,
691            Error::InvalidKeySize {
692                expected: 32,
693                actual: 31
694            }
695        ));
696    }
697
698    #[test]
699    fn test_encrypt_bytes_rejects_wrong_nonce_size() {
700        let aead = SaturninAead::new();
701        let err = aead
702            .encrypt_bytes(&[0u8; 32], &[0u8; 15], b"m", None)
703            .expect_err("15-byte nonce must be rejected");
704        assert!(matches!(
705            err,
706            Error::InvalidNonceSize {
707                expected: 16,
708                actual: 15
709            }
710        ));
711    }
712
713    #[test]
714    fn test_decrypt_bytes_rejects_wrong_key_size() {
715        let aead = SaturninAead::new();
716        let err = aead
717            .decrypt_bytes(&[0u8; 20], &[0u8; 16], &[0u8; 32], None)
718            .expect_err("20-byte key must be rejected");
719        assert!(matches!(
720            err,
721            Error::InvalidKeySize {
722                expected: 32,
723                actual: 20
724            }
725        ));
726    }
727
728    #[test]
729    fn test_decrypt_bytes_rejects_wrong_nonce_size() {
730        let aead = SaturninAead::new();
731        let err = aead
732            .decrypt_bytes(&[0u8; 32], &[0u8; 4], &[0u8; 32], None)
733            .expect_err("4-byte nonce must be rejected");
734        assert!(matches!(
735            err,
736            Error::InvalidNonceSize {
737                expected: 16,
738                actual: 4
739            }
740        ));
741    }
742
743    #[test]
744    fn test_decrypt_bytes_rejects_ciphertext_shorter_than_tag() {
745        let aead = SaturninAead::new();
746        // 10 bytes is shorter than the 32-byte tag alone.
747        let err = aead
748            .decrypt_bytes(&[0u8; 32], &[0u8; 16], &[0u8; 10], None)
749            .expect_err("ciphertext shorter than the tag must be rejected");
750        assert!(matches!(err, Error::InvalidCiphertextSize { .. }));
751    }
752
753    #[test]
754    fn test_round_trip_across_block_boundary_sizes() -> Result<()> {
755        // Exercises the CTR full-block path, the CTR partial-final-block path, and the
756        // cascade's full-block vs. padded-tail branches together, for plaintext/AD sizes at
757        // and around the 32-byte block boundary.
758        let aead = SaturninAead::new();
759        let key = AeadKey::new(vec![9u8; 32]);
760        let nonce = Nonce::new(vec![5u8; 16]);
761        for len in [0usize, 1, 31, 32, 33, 63, 64, 65] {
762            let plaintext = vec![0xAAu8; len];
763            for ad_len in [0usize, 32] {
764                let ad = vec![0x55u8; ad_len];
765                let ad_opt = Some(ad.as_slice());
766                let ct = aead.encrypt(&key, &nonce, &plaintext, ad_opt)?;
767                assert_eq!(ct.len(), len + 32);
768                let pt = aead.decrypt(&key, &nonce, &ct, ad_opt)?;
769                assert_eq!(
770                    pt, plaintext,
771                    "round trip failed for len={len}, ad_len={ad_len}"
772                );
773            }
774        }
775        Ok(())
776    }
777
778    #[test]
779    fn test_round_trip_crosses_avx2_ctr_batch_threshold() -> Result<()> {
780        // `ctr_encrypt`'s 8-lane AVX2 batch path only triggers once the remaining data is
781        // >= 32*8 = 256 bytes; the block-boundary test above only goes up to 65 bytes, so
782        // that batch path (and the loop continuing past more than one batch) is otherwise
783        // never exercised. 600 bytes crosses two full batches plus a non-block-aligned tail.
784        let aead = SaturninAead::new();
785        let key = AeadKey::new(vec![0x71u8; 32]);
786        let nonce = Nonce::new(vec![0x62u8; 16]);
787        let plaintext = vec![0xC3u8; 600];
788        let ad = vec![0x5Au8; 17];
789
790        let ct = aead.encrypt(&key, &nonce, &plaintext, Some(&ad))?;
791        assert_eq!(ct.len(), plaintext.len() + 32);
792        let pt = aead.decrypt(&key, &nonce, &ct, Some(&ad))?;
793        assert_eq!(pt, plaintext);
794        Ok(())
795    }
796}