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}