Skip to main content

crypt_io/aead/
mod.rs

1//! Authenticated encryption with associated data (AEAD).
2//!
3//! This module exposes the high-level [`Crypt`] handle and the [`Algorithm`]
4//! enum. The default algorithm is **ChaCha20-Poly1305** ([RFC 8439]): it is
5//! fast in software, post-quantum-safe at 256-bit symmetric strength, and the
6//! recommended choice when hardware AES acceleration is not available.
7//!
8//! 0.3.0 adds **AES-256-GCM** ([NIST SP 800-38D]) as a peer. Both algorithms
9//! share the same `Crypt::encrypt` / `Crypt::decrypt` surface, the same
10//! 32-byte key length, the same 12-byte nonce length, and the same 16-byte
11//! tag length — the only thing that changes is the underlying primitive
12//! (and the hardware-acceleration profile: AES-NI on x86, ARMv8 crypto
13//! extensions on AArch64).
14//!
15//! [RFC 8439]: https://datatracker.ietf.org/doc/html/rfc8439
16//! [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
17//!
18//! # Wire format
19//!
20//! The ciphertext returned by [`Crypt::encrypt`] / [`Crypt::encrypt_with_aad`]
21//! is the concatenation `nonce || ciphertext || tag`, where:
22//!
23//! - `nonce` is a 12-byte CSPRNG-generated value (mod-rand Tier 3, backed by
24//!   the OS — `getrandom` on Linux, `getentropy` on macOS,
25//!   `BCryptGenRandom` on Windows).
26//! - `ciphertext` is the encryption of the plaintext under the supplied key
27//!   and generated nonce.
28//! - `tag` is the 16-byte authentication tag (Poly1305 for
29//!   ChaCha20-Poly1305, GHASH for AES-256-GCM), covering both the ciphertext
30//!   and any associated data passed to the AAD variants.
31//!
32//! [`Crypt::decrypt`] / [`Crypt::decrypt_with_aad`] split this layout,
33//! verify the tag in constant time (provided by upstream RustCrypto), and
34//! return the decrypted plaintext.
35//!
36//! # Algorithm choice
37//!
38//! Pick **ChaCha20-Poly1305** unless you have a reason not to. It is fast
39//! in software, has no timing-side-channel risk on platforms without
40//! constant-time hardware AES, and is the post-quantum-safe default at the
41//! 256-bit symmetric strength the crate ships.
42//!
43//! Pick **AES-256-GCM** when:
44//!
45//! - You're on a server-class x86 CPU with AES-NI + CLMUL (every Intel /
46//!   AMD chip since ~2010), or an ARMv8 CPU with the crypto extensions
47//!   (modern Apple Silicon, AWS Graviton, recent mobile SoCs). The
48//!   `aes-gcm` crate detects these at runtime and dispatches to the
49//!   hardware-accelerated path automatically — typically a 2–5× throughput
50//!   win over the software path.
51//! - You have an interop requirement (TLS records, JWE A256GCM, anything
52//!   spec'd to AES-GCM).
53//!
54//! # Nonce policy
55//!
56//! Nonces are generated fresh for every call. The 96-bit nonce space has a
57//! birthday bound of ~`2^48` — well beyond any realistic message volume for
58//! a single key. Callers that need a specific nonce (interop with another
59//! implementation, deterministic test vectors) are out of scope for the
60//! 0.2.0 API; that surface will arrive in a later milestone with explicit
61//! "I understand the risk" naming.
62//!
63//! # Example
64//!
65//! ```
66//! # #[cfg(feature = "aead-chacha20")] {
67//! use crypt_io::Crypt;
68//!
69//! let key = [0x42u8; 32];
70//! let plaintext = b"attack at dawn";
71//!
72//! let crypt = Crypt::new();
73//! let ciphertext = crypt.encrypt(&key, plaintext).expect("encrypt");
74//! let recovered = crypt.decrypt(&key, &ciphertext).expect("decrypt");
75//!
76//! assert_eq!(&*recovered, plaintext);
77//! # }
78//! ```
79
80use alloc::vec::Vec;
81
82#[cfg_attr(
83    any(feature = "aead-chacha20", feature = "aead-aes-gcm"),
84    allow(unused_imports)
85)]
86use crate::error::{Error, Result};
87
88#[cfg(feature = "aead-aes-gcm")]
89mod aes_gcm;
90#[cfg(feature = "aead-chacha20")]
91mod chacha20;
92
93/// Length of a ChaCha20-Poly1305 nonce, in bytes. Equal to `12`.
94pub const CHACHA20_NONCE_LEN: usize = 12;
95
96/// Length of a ChaCha20-Poly1305 authentication tag, in bytes. Equal to `16`.
97pub const CHACHA20_TAG_LEN: usize = 16;
98
99/// Length of an AES-256-GCM nonce, in bytes. Equal to `12` (96 bits — the
100/// length NIST SP 800-38D specifies as the GCM default).
101pub const AES_GCM_NONCE_LEN: usize = 12;
102
103/// Length of an AES-256-GCM authentication tag, in bytes. Equal to `16`.
104pub const AES_GCM_TAG_LEN: usize = 16;
105
106/// Length of a symmetric key for any algorithm shipped in this version,
107/// in bytes. Equal to `32` (256-bit keys).
108pub const KEY_LEN: usize = 32;
109
110/// Supported AEAD algorithms.
111///
112/// The enum is `#[non_exhaustive]`. New algorithms are added in minor
113/// releases; downstream `match` sites must include a wildcard arm.
114#[non_exhaustive]
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
116pub enum Algorithm {
117    /// ChaCha20-Poly1305 ([RFC 8439]). The default. Fast in software,
118    /// post-quantum-safe at 256-bit symmetric strength, no timing-side-channel
119    /// risk on platforms without constant-time hardware AES.
120    ///
121    /// [RFC 8439]: https://datatracker.ietf.org/doc/html/rfc8439
122    #[default]
123    ChaCha20Poly1305,
124    /// AES-256-GCM ([NIST SP 800-38D]). Hardware-accelerated on every modern
125    /// x86 CPU (AES-NI + CLMUL) and on ARMv8 with the crypto extensions.
126    /// Pick this when you need interop with TLS / JWE / spec'd protocols
127    /// or when running on AES-accelerated hardware.
128    ///
129    /// [NIST SP 800-38D]: https://nvlpubs.nist.gov/nistpubs/Legacy/SP/nistspecialpublication800-38d.pdf
130    Aes256Gcm,
131}
132
133impl Algorithm {
134    /// Human-readable name of the algorithm.
135    #[must_use]
136    pub const fn name(self) -> &'static str {
137        match self {
138            Self::ChaCha20Poly1305 => "ChaCha20-Poly1305",
139            Self::Aes256Gcm => "AES-256-GCM",
140        }
141    }
142
143    /// Required key length in bytes for this algorithm.
144    #[must_use]
145    pub const fn key_len(self) -> usize {
146        match self {
147            Self::ChaCha20Poly1305 | Self::Aes256Gcm => KEY_LEN,
148        }
149    }
150
151    /// Nonce length in bytes that this algorithm uses.
152    #[must_use]
153    pub const fn nonce_len(self) -> usize {
154        match self {
155            Self::ChaCha20Poly1305 => CHACHA20_NONCE_LEN,
156            Self::Aes256Gcm => AES_GCM_NONCE_LEN,
157        }
158    }
159
160    /// Authentication-tag length in bytes that this algorithm produces.
161    #[must_use]
162    pub const fn tag_len(self) -> usize {
163        match self {
164            Self::ChaCha20Poly1305 => CHACHA20_TAG_LEN,
165            Self::Aes256Gcm => AES_GCM_TAG_LEN,
166        }
167    }
168}
169
170/// High-level encryption handle.
171///
172/// `Crypt` is cheap to construct and to clone — it carries only the
173/// algorithm choice, not any key material. Keys are passed per-call to
174/// [`encrypt`](Self::encrypt) and [`decrypt`](Self::decrypt), and never
175/// stored inside `Crypt` itself.
176///
177/// # Defaults
178///
179/// `Crypt::new()` returns a handle configured for
180/// [`Algorithm::ChaCha20Poly1305`]. Use [`Crypt::with_algorithm`] to pick
181/// a different algorithm.
182#[derive(Debug, Clone, Copy, PartialEq, Eq)]
183pub struct Crypt {
184    algorithm: Algorithm,
185}
186
187impl Crypt {
188    /// Construct a `Crypt` with the default algorithm
189    /// ([`Algorithm::ChaCha20Poly1305`]).
190    #[must_use]
191    pub const fn new() -> Self {
192        Self {
193            algorithm: Algorithm::ChaCha20Poly1305,
194        }
195    }
196
197    /// Construct a `Crypt` with an explicit algorithm.
198    #[must_use]
199    pub const fn with_algorithm(algorithm: Algorithm) -> Self {
200        Self { algorithm }
201    }
202
203    /// Convenience constructor for [`Algorithm::Aes256Gcm`]. Available only
204    /// when the `aead-aes-gcm` Cargo feature is enabled.
205    ///
206    /// Equivalent to `Crypt::with_algorithm(Algorithm::Aes256Gcm)`. Provided
207    /// because picking AES-GCM is an explicit, deliberate choice — usually
208    /// driven by an interop requirement or by a target platform with
209    /// AES-NI / ARMv8 crypto extensions — and the call site reads cleaner
210    /// when it says so.
211    #[cfg(feature = "aead-aes-gcm")]
212    #[must_use]
213    pub const fn aes_256_gcm() -> Self {
214        Self {
215            algorithm: Algorithm::Aes256Gcm,
216        }
217    }
218
219    /// The algorithm this handle is configured to use.
220    #[must_use]
221    pub const fn algorithm(&self) -> Algorithm {
222        self.algorithm
223    }
224
225    /// Encrypt `plaintext` under `key` and return `nonce || ciphertext || tag`.
226    ///
227    /// A fresh 12-byte nonce is generated for every call via OS-backed
228    /// CSPRNG (`mod_rand::tier3::fill_bytes`). The nonce is prepended to
229    /// the returned buffer so the corresponding [`decrypt`](Self::decrypt)
230    /// call needs only the key and the buffer.
231    ///
232    /// # Errors
233    ///
234    /// - [`Error::InvalidKey`] if `key` is not 32 bytes.
235    /// - [`Error::RandomFailure`] if the OS random source could not
236    ///   produce a nonce.
237    /// - [`Error::AlgorithmNotEnabled`] if the algorithm was disabled
238    ///   at compile time (a feature-flag gate).
239    ///
240    /// # Example
241    ///
242    /// ```
243    /// # #[cfg(feature = "aead-chacha20")] {
244    /// use crypt_io::Crypt;
245    /// let crypt = Crypt::new();
246    /// let key = [0u8; 32];
247    /// let ciphertext = crypt.encrypt(&key, b"hello").expect("encrypt");
248    /// assert!(ciphertext.len() > 5);
249    /// # }
250    /// ```
251    pub fn encrypt(&self, key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
252        self.encrypt_with_aad(key, plaintext, &[])
253    }
254
255    /// Encrypt `plaintext` under `key` with additional authenticated data.
256    ///
257    /// `aad` is authenticated alongside the ciphertext but is **not**
258    /// encrypted and is **not** included in the returned buffer. Callers
259    /// must supply identical `aad` to [`decrypt_with_aad`](Self::decrypt_with_aad)
260    /// — otherwise authentication will fail.
261    ///
262    /// Pass `&[]` for `aad` to encrypt without associated data, or call
263    /// the convenience method [`encrypt`](Self::encrypt) which does so.
264    ///
265    /// # Errors
266    ///
267    /// Same as [`encrypt`](Self::encrypt).
268    pub fn encrypt_with_aad(&self, key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
269        match self.algorithm {
270            Algorithm::ChaCha20Poly1305 => {
271                #[cfg(feature = "aead-chacha20")]
272                {
273                    chacha20::encrypt(key, plaintext, aad)
274                }
275                #[cfg(not(feature = "aead-chacha20"))]
276                {
277                    let _ = (key, plaintext, aad);
278                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
279                }
280            }
281            Algorithm::Aes256Gcm => {
282                #[cfg(feature = "aead-aes-gcm")]
283                {
284                    aes_gcm::encrypt(key, plaintext, aad)
285                }
286                #[cfg(not(feature = "aead-aes-gcm"))]
287                {
288                    let _ = (key, plaintext, aad);
289                    Err(Error::AlgorithmNotEnabled("aead-aes-gcm"))
290                }
291            }
292        }
293    }
294
295    /// Decrypt a buffer produced by [`encrypt`](Self::encrypt) and return
296    /// the plaintext.
297    ///
298    /// The buffer is expected to be `nonce || ciphertext || tag` — exactly
299    /// the layout [`encrypt`](Self::encrypt) returns. The tag is verified
300    /// in constant time; any tampering, wrong key, or wrong length results
301    /// in [`Error::AuthenticationFailed`].
302    ///
303    /// The returned `Vec<u8>` does **not** auto-zeroize. Callers handling
304    /// long-lived keys should move the bytes into a `Zeroizing<Vec<u8>>`
305    /// (`zeroize` crate) or — for production use cases — keep the
306    /// plaintext in a `key-vault` handle and never let it touch a raw
307    /// `Vec`.
308    ///
309    /// # Errors
310    ///
311    /// - [`Error::InvalidKey`] if `key` is not 32 bytes.
312    /// - [`Error::InvalidCiphertext`] if the buffer is too short to
313    ///   contain a nonce + tag.
314    /// - [`Error::AuthenticationFailed`] for any cryptographic failure —
315    ///   wrong key, tampered ciphertext, or wrong associated data.
316    /// - [`Error::AlgorithmNotEnabled`] if the algorithm was disabled
317    ///   at compile time.
318    ///
319    /// # Example
320    ///
321    /// ```
322    /// # #[cfg(feature = "aead-chacha20")] {
323    /// use crypt_io::Crypt;
324    /// let crypt = Crypt::new();
325    /// let key = [0u8; 32];
326    /// let ciphertext = crypt.encrypt(&key, b"hello").expect("encrypt");
327    /// let recovered = crypt.decrypt(&key, &ciphertext).expect("decrypt");
328    /// assert_eq!(&*recovered, b"hello");
329    /// # }
330    /// ```
331    pub fn decrypt(&self, key: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
332        self.decrypt_with_aad(key, ciphertext, &[])
333    }
334
335    /// Decrypt with associated data. `aad` must match what was passed to
336    /// [`encrypt_with_aad`](Self::encrypt_with_aad).
337    ///
338    /// # Errors
339    ///
340    /// Same as [`decrypt`](Self::decrypt).
341    pub fn decrypt_with_aad(&self, key: &[u8], ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
342        match self.algorithm {
343            Algorithm::ChaCha20Poly1305 => {
344                #[cfg(feature = "aead-chacha20")]
345                {
346                    chacha20::decrypt(key, ciphertext, aad)
347                }
348                #[cfg(not(feature = "aead-chacha20"))]
349                {
350                    let _ = (key, ciphertext, aad);
351                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
352                }
353            }
354            Algorithm::Aes256Gcm => {
355                #[cfg(feature = "aead-aes-gcm")]
356                {
357                    aes_gcm::decrypt(key, ciphertext, aad)
358                }
359                #[cfg(not(feature = "aead-aes-gcm"))]
360                {
361                    let _ = (key, ciphertext, aad);
362                    Err(Error::AlgorithmNotEnabled("aead-aes-gcm"))
363                }
364            }
365        }
366    }
367
368    /// Zero-allocation encrypt — writes `nonce || ciphertext || tag`
369    /// into the caller-supplied `out` buffer. The buffer is cleared
370    /// first and then grown as needed. Reusing the same buffer across
371    /// calls amortises the allocation cost away entirely.
372    ///
373    /// Equivalent to [`encrypt`](Self::encrypt) but does not allocate
374    /// a fresh `Vec` per call. New in 0.10.0.
375    ///
376    /// # Errors
377    ///
378    /// Same as [`encrypt`](Self::encrypt).
379    ///
380    /// # Example
381    ///
382    /// ```
383    /// # #[cfg(feature = "aead-chacha20")] {
384    /// use crypt_io::Crypt;
385    /// let crypt = Crypt::new();
386    /// let key = [0u8; 32];
387    /// let mut out = Vec::new();
388    ///
389    /// // First call grows `out` to capacity.
390    /// crypt.encrypt_into(&key, b"hello", &mut out)?;
391    ///
392    /// // Subsequent calls reuse the capacity — no allocation.
393    /// crypt.encrypt_into(&key, b"world", &mut out)?;
394    /// # }
395    /// # Ok::<(), crypt_io::Error>(())
396    /// ```
397    pub fn encrypt_into(&self, key: &[u8], plaintext: &[u8], out: &mut Vec<u8>) -> Result<()> {
398        self.encrypt_with_aad_into(key, plaintext, &[], out)
399    }
400
401    /// Zero-allocation encrypt with associated data. See
402    /// [`encrypt_into`](Self::encrypt_into).
403    ///
404    /// # Errors
405    ///
406    /// Same as [`encrypt`](Self::encrypt).
407    pub fn encrypt_with_aad_into(
408        &self,
409        key: &[u8],
410        plaintext: &[u8],
411        aad: &[u8],
412        out: &mut Vec<u8>,
413    ) -> Result<()> {
414        match self.algorithm {
415            Algorithm::ChaCha20Poly1305 => {
416                #[cfg(feature = "aead-chacha20")]
417                {
418                    chacha20::encrypt_into(key, plaintext, aad, out)
419                }
420                #[cfg(not(feature = "aead-chacha20"))]
421                {
422                    let _ = (key, plaintext, aad, out);
423                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
424                }
425            }
426            Algorithm::Aes256Gcm => {
427                #[cfg(feature = "aead-aes-gcm")]
428                {
429                    aes_gcm::encrypt_into(key, plaintext, aad, out)
430                }
431                #[cfg(not(feature = "aead-aes-gcm"))]
432                {
433                    let _ = (key, plaintext, aad, out);
434                    Err(Error::AlgorithmNotEnabled("aead-aes-gcm"))
435                }
436            }
437        }
438    }
439
440    /// Zero-allocation decrypt — writes the recovered plaintext into
441    /// the caller-supplied `out` buffer. The buffer is cleared first
442    /// and then grown as needed.
443    ///
444    /// On authentication failure the buffer is cleared (any
445    /// partially-decrypted bytes are scrubbed before returning) so
446    /// callers can't accidentally observe unverified plaintext.
447    ///
448    /// Equivalent to [`decrypt`](Self::decrypt) but does not allocate
449    /// a fresh `Vec` per call. New in 0.10.0.
450    ///
451    /// # Errors
452    ///
453    /// Same as [`decrypt`](Self::decrypt).
454    ///
455    /// # Example
456    ///
457    /// ```
458    /// # #[cfg(feature = "aead-chacha20")] {
459    /// use crypt_io::Crypt;
460    /// let crypt = Crypt::new();
461    /// let key = [0u8; 32];
462    ///
463    /// let mut ciphertext = Vec::new();
464    /// crypt.encrypt_into(&key, b"hello", &mut ciphertext)?;
465    ///
466    /// let mut plaintext = Vec::new();
467    /// crypt.decrypt_into(&key, &ciphertext, &mut plaintext)?;
468    /// assert_eq!(&plaintext[..], b"hello");
469    /// # }
470    /// # Ok::<(), crypt_io::Error>(())
471    /// ```
472    pub fn decrypt_into(&self, key: &[u8], ciphertext: &[u8], out: &mut Vec<u8>) -> Result<()> {
473        self.decrypt_with_aad_into(key, ciphertext, &[], out)
474    }
475
476    /// Zero-allocation decrypt with associated data. See
477    /// [`decrypt_into`](Self::decrypt_into).
478    ///
479    /// # Errors
480    ///
481    /// Same as [`decrypt`](Self::decrypt).
482    pub fn decrypt_with_aad_into(
483        &self,
484        key: &[u8],
485        ciphertext: &[u8],
486        aad: &[u8],
487        out: &mut Vec<u8>,
488    ) -> Result<()> {
489        match self.algorithm {
490            Algorithm::ChaCha20Poly1305 => {
491                #[cfg(feature = "aead-chacha20")]
492                {
493                    chacha20::decrypt_into(key, ciphertext, aad, out)
494                }
495                #[cfg(not(feature = "aead-chacha20"))]
496                {
497                    let _ = (key, ciphertext, aad, out);
498                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
499                }
500            }
501            Algorithm::Aes256Gcm => {
502                #[cfg(feature = "aead-aes-gcm")]
503                {
504                    aes_gcm::decrypt_into(key, ciphertext, aad, out)
505                }
506                #[cfg(not(feature = "aead-aes-gcm"))]
507                {
508                    let _ = (key, ciphertext, aad, out);
509                    Err(Error::AlgorithmNotEnabled("aead-aes-gcm"))
510                }
511            }
512        }
513    }
514}
515
516impl Default for Crypt {
517    fn default() -> Self {
518        Self::new()
519    }
520}
521
522#[cfg(all(test, feature = "aead-chacha20"))]
523#[allow(clippy::unwrap_used, clippy::expect_used)]
524mod tests {
525    use super::*;
526    use alloc::vec;
527
528    #[test]
529    fn algorithm_metadata_matches_constants() {
530        let a = Algorithm::default();
531        assert_eq!(a, Algorithm::ChaCha20Poly1305);
532        assert_eq!(a.key_len(), KEY_LEN);
533        assert_eq!(a.nonce_len(), CHACHA20_NONCE_LEN);
534        assert_eq!(a.tag_len(), CHACHA20_TAG_LEN);
535        assert_eq!(a.name(), "ChaCha20-Poly1305");
536    }
537
538    #[test]
539    fn crypt_defaults_to_chacha20() {
540        let c = Crypt::new();
541        assert_eq!(c.algorithm(), Algorithm::ChaCha20Poly1305);
542        let d = Crypt::default();
543        assert_eq!(d.algorithm(), Algorithm::ChaCha20Poly1305);
544    }
545
546    #[test]
547    fn round_trip_empty_plaintext() {
548        let crypt = Crypt::new();
549        let key = [0x11u8; 32];
550        let ciphertext = crypt.encrypt(&key, b"").unwrap();
551        // Layout: 12-byte nonce + 0-byte body + 16-byte tag.
552        assert_eq!(ciphertext.len(), CHACHA20_NONCE_LEN + CHACHA20_TAG_LEN);
553        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
554        assert!(recovered.is_empty());
555    }
556
557    #[test]
558    fn round_trip_short_plaintext() {
559        let crypt = Crypt::new();
560        let key = [0x22u8; 32];
561        let plaintext = b"hello, world!";
562        let ciphertext = crypt.encrypt(&key, plaintext).unwrap();
563        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
564        assert_eq!(&*recovered, plaintext);
565    }
566
567    #[test]
568    fn round_trip_one_megabyte() {
569        let crypt = Crypt::new();
570        let key = [0x33u8; 32];
571        let plaintext = vec![0xa5u8; 1024 * 1024];
572        let ciphertext = crypt.encrypt(&key, &plaintext).unwrap();
573        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
574        assert_eq!(recovered, plaintext);
575    }
576
577    #[test]
578    fn two_encryptions_of_same_plaintext_differ() {
579        let crypt = Crypt::new();
580        let key = [0u8; 32];
581        let plaintext = b"deterministic? no.";
582        let a = crypt.encrypt(&key, plaintext).unwrap();
583        let b = crypt.encrypt(&key, plaintext).unwrap();
584        assert_ne!(a, b, "nonce-prepended outputs must differ across calls");
585    }
586
587    #[test]
588    fn wrong_key_fails_authentication() {
589        let crypt = Crypt::new();
590        let key = [0x44u8; 32];
591        let wrong = [0x55u8; 32];
592        let ciphertext = crypt.encrypt(&key, b"secret").unwrap();
593        let err = crypt.decrypt(&wrong, &ciphertext).unwrap_err();
594        assert_eq!(err, Error::AuthenticationFailed);
595    }
596
597    #[test]
598    fn tampered_ciphertext_fails_authentication() {
599        let crypt = Crypt::new();
600        let key = [0x66u8; 32];
601        let mut ciphertext = crypt.encrypt(&key, b"hands off").unwrap();
602        // Flip one byte in the body (avoid the nonce so we exercise tag verification).
603        let i = ciphertext.len() / 2;
604        ciphertext[i] ^= 0x01;
605        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
606        assert_eq!(err, Error::AuthenticationFailed);
607    }
608
609    #[test]
610    fn tampered_tag_fails_authentication() {
611        let crypt = Crypt::new();
612        let key = [0x77u8; 32];
613        let mut ciphertext = crypt.encrypt(&key, b"sign me").unwrap();
614        let last = ciphertext.len() - 1;
615        ciphertext[last] ^= 0xff;
616        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
617        assert_eq!(err, Error::AuthenticationFailed);
618    }
619
620    #[test]
621    fn truncated_ciphertext_is_rejected() {
622        let crypt = Crypt::new();
623        let key = [0u8; 32];
624        // Anything shorter than nonce_len + tag_len cannot be a valid frame.
625        for len in 0..(CHACHA20_NONCE_LEN + CHACHA20_TAG_LEN) {
626            let err = crypt.decrypt(&key, &vec![0u8; len]).unwrap_err();
627            assert!(
628                matches!(err, Error::InvalidCiphertext(_)),
629                "len={len} should error"
630            );
631        }
632    }
633
634    #[test]
635    fn aad_round_trip() {
636        let crypt = Crypt::new();
637        let key = [0x88u8; 32];
638        let plaintext = b"plaintext";
639        let aad = b"associated";
640        let ciphertext = crypt.encrypt_with_aad(&key, plaintext, aad).unwrap();
641        let recovered = crypt.decrypt_with_aad(&key, &ciphertext, aad).unwrap();
642        assert_eq!(&*recovered, plaintext);
643    }
644
645    #[test]
646    fn aad_mismatch_fails_authentication() {
647        let crypt = Crypt::new();
648        let key = [0x99u8; 32];
649        let ciphertext = crypt
650            .encrypt_with_aad(&key, b"body", b"original-aad")
651            .unwrap();
652        let err = crypt
653            .decrypt_with_aad(&key, &ciphertext, b"tampered-aad")
654            .unwrap_err();
655        assert_eq!(err, Error::AuthenticationFailed);
656    }
657
658    #[test]
659    fn encrypt_with_aad_then_decrypt_without_aad_fails() {
660        let crypt = Crypt::new();
661        let key = [0xaau8; 32];
662        let ciphertext = crypt.encrypt_with_aad(&key, b"body", b"required").unwrap();
663        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
664        assert_eq!(err, Error::AuthenticationFailed);
665    }
666
667    #[test]
668    fn invalid_key_length_rejected_on_encrypt() {
669        let crypt = Crypt::new();
670        let err = crypt.encrypt(&[0u8; 16], b"x").unwrap_err();
671        assert_eq!(
672            err,
673            Error::InvalidKey {
674                expected: 32,
675                actual: 16
676            }
677        );
678    }
679
680    #[test]
681    fn invalid_key_length_rejected_on_decrypt() {
682        let crypt = Crypt::new();
683        // First encrypt a real ciphertext so the length-check is the
684        // reason decrypt rejects.
685        let ciphertext = crypt.encrypt(&[0u8; 32], b"x").unwrap();
686        let err = crypt.decrypt(&[0u8; 16], &ciphertext).unwrap_err();
687        assert_eq!(
688            err,
689            Error::InvalidKey {
690                expected: 32,
691                actual: 16
692            }
693        );
694    }
695}
696
697// AES-256-GCM end-to-end tests exercised through the `Crypt` surface.
698// Mirrors the ChaCha20 test suite above so the cross-algorithm contract
699// is verified at the public API layer (not just the backend module).
700#[cfg(all(test, feature = "aead-aes-gcm"))]
701#[allow(clippy::unwrap_used, clippy::expect_used)]
702mod aes_gcm_tests {
703    use super::*;
704    use alloc::vec;
705
706    fn aes() -> Crypt {
707        Crypt::aes_256_gcm()
708    }
709
710    #[test]
711    fn algorithm_metadata_matches_constants() {
712        let a = Algorithm::Aes256Gcm;
713        assert_eq!(a.key_len(), KEY_LEN);
714        assert_eq!(a.nonce_len(), AES_GCM_NONCE_LEN);
715        assert_eq!(a.tag_len(), AES_GCM_TAG_LEN);
716        assert_eq!(a.name(), "AES-256-GCM");
717    }
718
719    #[test]
720    fn aes_256_gcm_constructor_selects_algorithm() {
721        let c = aes();
722        assert_eq!(c.algorithm(), Algorithm::Aes256Gcm);
723        let alt = Crypt::with_algorithm(Algorithm::Aes256Gcm);
724        assert_eq!(c, alt);
725    }
726
727    #[test]
728    fn round_trip_empty_plaintext() {
729        let crypt = aes();
730        let key = [0x11u8; 32];
731        let ciphertext = crypt.encrypt(&key, b"").unwrap();
732        assert_eq!(ciphertext.len(), AES_GCM_NONCE_LEN + AES_GCM_TAG_LEN);
733        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
734        assert!(recovered.is_empty());
735    }
736
737    #[test]
738    fn round_trip_short_plaintext() {
739        let crypt = aes();
740        let key = [0x22u8; 32];
741        let plaintext = b"hello, world!";
742        let ciphertext = crypt.encrypt(&key, plaintext).unwrap();
743        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
744        assert_eq!(&*recovered, plaintext);
745    }
746
747    #[test]
748    fn round_trip_one_megabyte() {
749        let crypt = aes();
750        let key = [0x33u8; 32];
751        let plaintext = vec![0xa5u8; 1024 * 1024];
752        let ciphertext = crypt.encrypt(&key, &plaintext).unwrap();
753        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
754        assert_eq!(recovered, plaintext);
755    }
756
757    #[test]
758    fn two_encryptions_of_same_plaintext_differ() {
759        let crypt = aes();
760        let key = [0u8; 32];
761        let plaintext = b"deterministic? no.";
762        let a = crypt.encrypt(&key, plaintext).unwrap();
763        let b = crypt.encrypt(&key, plaintext).unwrap();
764        assert_ne!(a, b, "nonce-prepended outputs must differ across calls");
765    }
766
767    #[test]
768    fn wrong_key_fails_authentication() {
769        let crypt = aes();
770        let key = [0x44u8; 32];
771        let wrong = [0x55u8; 32];
772        let ciphertext = crypt.encrypt(&key, b"secret").unwrap();
773        let err = crypt.decrypt(&wrong, &ciphertext).unwrap_err();
774        assert_eq!(err, Error::AuthenticationFailed);
775    }
776
777    #[test]
778    fn tampered_ciphertext_fails_authentication() {
779        let crypt = aes();
780        let key = [0x66u8; 32];
781        let mut ciphertext = crypt.encrypt(&key, b"hands off").unwrap();
782        let i = ciphertext.len() / 2;
783        ciphertext[i] ^= 0x01;
784        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
785        assert_eq!(err, Error::AuthenticationFailed);
786    }
787
788    #[test]
789    fn tampered_tag_fails_authentication() {
790        let crypt = aes();
791        let key = [0x77u8; 32];
792        let mut ciphertext = crypt.encrypt(&key, b"sign me").unwrap();
793        let last = ciphertext.len() - 1;
794        ciphertext[last] ^= 0xff;
795        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
796        assert_eq!(err, Error::AuthenticationFailed);
797    }
798
799    #[test]
800    fn truncated_ciphertext_is_rejected() {
801        let crypt = aes();
802        let key = [0u8; 32];
803        for len in 0..(AES_GCM_NONCE_LEN + AES_GCM_TAG_LEN) {
804            let err = crypt.decrypt(&key, &vec![0u8; len]).unwrap_err();
805            assert!(
806                matches!(err, Error::InvalidCiphertext(_)),
807                "len={len} should error"
808            );
809        }
810    }
811
812    #[test]
813    fn aad_round_trip() {
814        let crypt = aes();
815        let key = [0x88u8; 32];
816        let plaintext = b"plaintext";
817        let aad = b"associated";
818        let ciphertext = crypt.encrypt_with_aad(&key, plaintext, aad).unwrap();
819        let recovered = crypt.decrypt_with_aad(&key, &ciphertext, aad).unwrap();
820        assert_eq!(&*recovered, plaintext);
821    }
822
823    #[test]
824    fn aad_mismatch_fails_authentication() {
825        let crypt = aes();
826        let key = [0x99u8; 32];
827        let ciphertext = crypt
828            .encrypt_with_aad(&key, b"body", b"original-aad")
829            .unwrap();
830        let err = crypt
831            .decrypt_with_aad(&key, &ciphertext, b"tampered-aad")
832            .unwrap_err();
833        assert_eq!(err, Error::AuthenticationFailed);
834    }
835
836    #[test]
837    fn invalid_key_length_rejected_on_encrypt() {
838        let crypt = aes();
839        let err = crypt.encrypt(&[0u8; 16], b"x").unwrap_err();
840        assert_eq!(
841            err,
842            Error::InvalidKey {
843                expected: 32,
844                actual: 16
845            }
846        );
847    }
848}
849
850// Cross-algorithm integration tests: confirm that ciphertext produced by
851// one algorithm cannot be decrypted by the other. This is the contract
852// callers depend on when they store ciphertexts they later need to route
853// to the correct decryption path.
854#[cfg(all(test, feature = "aead-chacha20", feature = "aead-aes-gcm"))]
855#[allow(clippy::unwrap_used, clippy::expect_used)]
856mod cross_algorithm_tests {
857    use super::*;
858
859    #[test]
860    fn chacha_ciphertext_does_not_decrypt_as_aes() {
861        let key = [0xcdu8; 32];
862        let ct = Crypt::new().encrypt(&key, b"message").unwrap();
863        let err = Crypt::aes_256_gcm().decrypt(&key, &ct).unwrap_err();
864        assert_eq!(err, Error::AuthenticationFailed);
865    }
866
867    #[test]
868    fn aes_ciphertext_does_not_decrypt_as_chacha() {
869        let key = [0xefu8; 32];
870        let ct = Crypt::aes_256_gcm().encrypt(&key, b"message").unwrap();
871        let err = Crypt::new().decrypt(&key, &ct).unwrap_err();
872        assert_eq!(err, Error::AuthenticationFailed);
873    }
874
875    #[test]
876    fn algorithm_name_table_is_unique() {
877        let names = [
878            Algorithm::ChaCha20Poly1305.name(),
879            Algorithm::Aes256Gcm.name(),
880        ];
881        for (i, a) in names.iter().enumerate() {
882            for (j, b) in names.iter().enumerate() {
883                if i != j {
884                    assert_ne!(a, b, "algorithm names must be distinct");
885                }
886            }
887        }
888    }
889}