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//! [RFC 8439]: https://datatracker.ietf.org/doc/html/rfc8439
9//!
10//! # Wire format
11//!
12//! The ciphertext returned by [`Crypt::encrypt`] / [`Crypt::encrypt_with_aad`]
13//! is the concatenation `nonce || ciphertext || tag`, where:
14//!
15//! - `nonce` is a 12-byte CSPRNG-generated value (mod-rand Tier 3, backed by
16//!   the OS — `getrandom` on Linux, `getentropy` on macOS,
17//!   `BCryptGenRandom` on Windows).
18//! - `ciphertext` is the encryption of the plaintext under the supplied key
19//!   and generated nonce.
20//! - `tag` is the 16-byte Poly1305 authentication tag, covering both the
21//!   ciphertext and any associated data passed to the AAD variants.
22//!
23//! [`Crypt::decrypt`] / [`Crypt::decrypt_with_aad`] split this layout,
24//! verify the tag in constant time (provided by upstream RustCrypto), and
25//! return the decrypted plaintext.
26//!
27//! # Nonce policy
28//!
29//! Nonces are generated fresh for every call. The 96-bit nonce space has a
30//! birthday bound of ~`2^48` — well beyond any realistic message volume for
31//! a single key. Callers that need a specific nonce (interop with another
32//! implementation, deterministic test vectors) are out of scope for the
33//! 0.2.0 API; that surface will arrive in a later milestone with explicit
34//! "I understand the risk" naming.
35//!
36//! # Example
37//!
38//! ```
39//! # #[cfg(feature = "aead-chacha20")] {
40//! use crypt_io::Crypt;
41//!
42//! let key = [0x42u8; 32];
43//! let plaintext = b"attack at dawn";
44//!
45//! let crypt = Crypt::new();
46//! let ciphertext = crypt.encrypt(&key, plaintext).expect("encrypt");
47//! let recovered = crypt.decrypt(&key, &ciphertext).expect("decrypt");
48//!
49//! assert_eq!(&*recovered, plaintext);
50//! # }
51//! ```
52
53use alloc::vec::Vec;
54
55#[cfg_attr(feature = "aead-chacha20", allow(unused_imports))]
56use crate::error::{Error, Result};
57
58#[cfg(feature = "aead-chacha20")]
59mod chacha20;
60
61/// Length of a ChaCha20-Poly1305 nonce, in bytes. Equal to `12`.
62pub const CHACHA20_NONCE_LEN: usize = 12;
63
64/// Length of a ChaCha20-Poly1305 authentication tag, in bytes. Equal to `16`.
65pub const CHACHA20_TAG_LEN: usize = 16;
66
67/// Length of a symmetric key for any algorithm shipped in this version,
68/// in bytes. Equal to `32` (256-bit keys).
69pub const KEY_LEN: usize = 32;
70
71/// Supported AEAD algorithms.
72///
73/// The enum is `#[non_exhaustive]`. Additional algorithms (e.g. AES-256-GCM
74/// in 0.3.0) will be added in minor releases; downstream `match` sites must
75/// include a wildcard arm.
76#[non_exhaustive]
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
78pub enum Algorithm {
79    /// ChaCha20-Poly1305 ([RFC 8439]). The default. Fast in software,
80    /// post-quantum-safe at 256-bit symmetric strength.
81    ///
82    /// [RFC 8439]: https://datatracker.ietf.org/doc/html/rfc8439
83    #[default]
84    ChaCha20Poly1305,
85}
86
87impl Algorithm {
88    /// Human-readable name of the algorithm.
89    #[must_use]
90    pub const fn name(self) -> &'static str {
91        match self {
92            Self::ChaCha20Poly1305 => "ChaCha20-Poly1305",
93        }
94    }
95
96    /// Required key length in bytes for this algorithm.
97    #[must_use]
98    pub const fn key_len(self) -> usize {
99        match self {
100            Self::ChaCha20Poly1305 => KEY_LEN,
101        }
102    }
103
104    /// Nonce length in bytes that this algorithm uses.
105    #[must_use]
106    pub const fn nonce_len(self) -> usize {
107        match self {
108            Self::ChaCha20Poly1305 => CHACHA20_NONCE_LEN,
109        }
110    }
111
112    /// Authentication-tag length in bytes that this algorithm produces.
113    #[must_use]
114    pub const fn tag_len(self) -> usize {
115        match self {
116            Self::ChaCha20Poly1305 => CHACHA20_TAG_LEN,
117        }
118    }
119}
120
121/// High-level encryption handle.
122///
123/// `Crypt` is cheap to construct and to clone — it carries only the
124/// algorithm choice, not any key material. Keys are passed per-call to
125/// [`encrypt`](Self::encrypt) and [`decrypt`](Self::decrypt), and never
126/// stored inside `Crypt` itself.
127///
128/// # Defaults
129///
130/// `Crypt::new()` returns a handle configured for
131/// [`Algorithm::ChaCha20Poly1305`]. Use [`Crypt::with_algorithm`] to pick
132/// a different algorithm.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub struct Crypt {
135    algorithm: Algorithm,
136}
137
138impl Crypt {
139    /// Construct a `Crypt` with the default algorithm
140    /// ([`Algorithm::ChaCha20Poly1305`]).
141    #[must_use]
142    pub const fn new() -> Self {
143        Self {
144            algorithm: Algorithm::ChaCha20Poly1305,
145        }
146    }
147
148    /// Construct a `Crypt` with an explicit algorithm.
149    #[must_use]
150    pub const fn with_algorithm(algorithm: Algorithm) -> Self {
151        Self { algorithm }
152    }
153
154    /// The algorithm this handle is configured to use.
155    #[must_use]
156    pub const fn algorithm(&self) -> Algorithm {
157        self.algorithm
158    }
159
160    /// Encrypt `plaintext` under `key` and return `nonce || ciphertext || tag`.
161    ///
162    /// A fresh 12-byte nonce is generated for every call via OS-backed
163    /// CSPRNG (`mod_rand::tier3::fill_bytes`). The nonce is prepended to
164    /// the returned buffer so the corresponding [`decrypt`](Self::decrypt)
165    /// call needs only the key and the buffer.
166    ///
167    /// # Errors
168    ///
169    /// - [`Error::InvalidKey`] if `key` is not 32 bytes.
170    /// - [`Error::RandomFailure`] if the OS random source could not
171    ///   produce a nonce.
172    /// - [`Error::AlgorithmNotEnabled`] if the algorithm was disabled
173    ///   at compile time (a feature-flag gate).
174    ///
175    /// # Example
176    ///
177    /// ```
178    /// # #[cfg(feature = "aead-chacha20")] {
179    /// use crypt_io::Crypt;
180    /// let crypt = Crypt::new();
181    /// let key = [0u8; 32];
182    /// let ciphertext = crypt.encrypt(&key, b"hello").expect("encrypt");
183    /// assert!(ciphertext.len() > 5);
184    /// # }
185    /// ```
186    pub fn encrypt(&self, key: &[u8], plaintext: &[u8]) -> Result<Vec<u8>> {
187        self.encrypt_with_aad(key, plaintext, &[])
188    }
189
190    /// Encrypt `plaintext` under `key` with additional authenticated data.
191    ///
192    /// `aad` is authenticated alongside the ciphertext but is **not**
193    /// encrypted and is **not** included in the returned buffer. Callers
194    /// must supply identical `aad` to [`decrypt_with_aad`](Self::decrypt_with_aad)
195    /// — otherwise authentication will fail.
196    ///
197    /// Pass `&[]` for `aad` to encrypt without associated data, or call
198    /// the convenience method [`encrypt`](Self::encrypt) which does so.
199    ///
200    /// # Errors
201    ///
202    /// Same as [`encrypt`](Self::encrypt).
203    pub fn encrypt_with_aad(&self, key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
204        match self.algorithm {
205            Algorithm::ChaCha20Poly1305 => {
206                #[cfg(feature = "aead-chacha20")]
207                {
208                    chacha20::encrypt(key, plaintext, aad)
209                }
210                #[cfg(not(feature = "aead-chacha20"))]
211                {
212                    let _ = (key, plaintext, aad);
213                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
214                }
215            }
216        }
217    }
218
219    /// Decrypt a buffer produced by [`encrypt`](Self::encrypt) and return
220    /// the plaintext.
221    ///
222    /// The buffer is expected to be `nonce || ciphertext || tag` — exactly
223    /// the layout [`encrypt`](Self::encrypt) returns. The tag is verified
224    /// in constant time; any tampering, wrong key, or wrong length results
225    /// in [`Error::AuthenticationFailed`].
226    ///
227    /// The returned `Vec<u8>` does **not** auto-zeroize. Callers handling
228    /// long-lived keys should move the bytes into a `Zeroizing<Vec<u8>>`
229    /// (`zeroize` crate) or — for production use cases — keep the
230    /// plaintext in a `key-vault` handle and never let it touch a raw
231    /// `Vec`.
232    ///
233    /// # Errors
234    ///
235    /// - [`Error::InvalidKey`] if `key` is not 32 bytes.
236    /// - [`Error::InvalidCiphertext`] if the buffer is too short to
237    ///   contain a nonce + tag.
238    /// - [`Error::AuthenticationFailed`] for any cryptographic failure —
239    ///   wrong key, tampered ciphertext, or wrong associated data.
240    /// - [`Error::AlgorithmNotEnabled`] if the algorithm was disabled
241    ///   at compile time.
242    ///
243    /// # Example
244    ///
245    /// ```
246    /// # #[cfg(feature = "aead-chacha20")] {
247    /// use crypt_io::Crypt;
248    /// let crypt = Crypt::new();
249    /// let key = [0u8; 32];
250    /// let ciphertext = crypt.encrypt(&key, b"hello").expect("encrypt");
251    /// let recovered = crypt.decrypt(&key, &ciphertext).expect("decrypt");
252    /// assert_eq!(&*recovered, b"hello");
253    /// # }
254    /// ```
255    pub fn decrypt(&self, key: &[u8], ciphertext: &[u8]) -> Result<Vec<u8>> {
256        self.decrypt_with_aad(key, ciphertext, &[])
257    }
258
259    /// Decrypt with associated data. `aad` must match what was passed to
260    /// [`encrypt_with_aad`](Self::encrypt_with_aad).
261    ///
262    /// # Errors
263    ///
264    /// Same as [`decrypt`](Self::decrypt).
265    pub fn decrypt_with_aad(&self, key: &[u8], ciphertext: &[u8], aad: &[u8]) -> Result<Vec<u8>> {
266        match self.algorithm {
267            Algorithm::ChaCha20Poly1305 => {
268                #[cfg(feature = "aead-chacha20")]
269                {
270                    chacha20::decrypt(key, ciphertext, aad)
271                }
272                #[cfg(not(feature = "aead-chacha20"))]
273                {
274                    let _ = (key, ciphertext, aad);
275                    Err(Error::AlgorithmNotEnabled("aead-chacha20"))
276                }
277            }
278        }
279    }
280}
281
282impl Default for Crypt {
283    fn default() -> Self {
284        Self::new()
285    }
286}
287
288#[cfg(all(test, feature = "aead-chacha20"))]
289#[allow(clippy::unwrap_used, clippy::expect_used)]
290mod tests {
291    use super::*;
292    use alloc::vec;
293
294    #[test]
295    fn algorithm_metadata_matches_constants() {
296        let a = Algorithm::default();
297        assert_eq!(a, Algorithm::ChaCha20Poly1305);
298        assert_eq!(a.key_len(), KEY_LEN);
299        assert_eq!(a.nonce_len(), CHACHA20_NONCE_LEN);
300        assert_eq!(a.tag_len(), CHACHA20_TAG_LEN);
301        assert_eq!(a.name(), "ChaCha20-Poly1305");
302    }
303
304    #[test]
305    fn crypt_defaults_to_chacha20() {
306        let c = Crypt::new();
307        assert_eq!(c.algorithm(), Algorithm::ChaCha20Poly1305);
308        let d = Crypt::default();
309        assert_eq!(d.algorithm(), Algorithm::ChaCha20Poly1305);
310    }
311
312    #[test]
313    fn round_trip_empty_plaintext() {
314        let crypt = Crypt::new();
315        let key = [0x11u8; 32];
316        let ciphertext = crypt.encrypt(&key, b"").unwrap();
317        // Layout: 12-byte nonce + 0-byte body + 16-byte tag.
318        assert_eq!(ciphertext.len(), CHACHA20_NONCE_LEN + CHACHA20_TAG_LEN);
319        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
320        assert!(recovered.is_empty());
321    }
322
323    #[test]
324    fn round_trip_short_plaintext() {
325        let crypt = Crypt::new();
326        let key = [0x22u8; 32];
327        let plaintext = b"hello, world!";
328        let ciphertext = crypt.encrypt(&key, plaintext).unwrap();
329        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
330        assert_eq!(&*recovered, plaintext);
331    }
332
333    #[test]
334    fn round_trip_one_megabyte() {
335        let crypt = Crypt::new();
336        let key = [0x33u8; 32];
337        let plaintext = vec![0xa5u8; 1024 * 1024];
338        let ciphertext = crypt.encrypt(&key, &plaintext).unwrap();
339        let recovered = crypt.decrypt(&key, &ciphertext).unwrap();
340        assert_eq!(recovered, plaintext);
341    }
342
343    #[test]
344    fn two_encryptions_of_same_plaintext_differ() {
345        let crypt = Crypt::new();
346        let key = [0u8; 32];
347        let plaintext = b"deterministic? no.";
348        let a = crypt.encrypt(&key, plaintext).unwrap();
349        let b = crypt.encrypt(&key, plaintext).unwrap();
350        assert_ne!(a, b, "nonce-prepended outputs must differ across calls");
351    }
352
353    #[test]
354    fn wrong_key_fails_authentication() {
355        let crypt = Crypt::new();
356        let key = [0x44u8; 32];
357        let wrong = [0x55u8; 32];
358        let ciphertext = crypt.encrypt(&key, b"secret").unwrap();
359        let err = crypt.decrypt(&wrong, &ciphertext).unwrap_err();
360        assert_eq!(err, Error::AuthenticationFailed);
361    }
362
363    #[test]
364    fn tampered_ciphertext_fails_authentication() {
365        let crypt = Crypt::new();
366        let key = [0x66u8; 32];
367        let mut ciphertext = crypt.encrypt(&key, b"hands off").unwrap();
368        // Flip one byte in the body (avoid the nonce so we exercise tag verification).
369        let i = ciphertext.len() / 2;
370        ciphertext[i] ^= 0x01;
371        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
372        assert_eq!(err, Error::AuthenticationFailed);
373    }
374
375    #[test]
376    fn tampered_tag_fails_authentication() {
377        let crypt = Crypt::new();
378        let key = [0x77u8; 32];
379        let mut ciphertext = crypt.encrypt(&key, b"sign me").unwrap();
380        let last = ciphertext.len() - 1;
381        ciphertext[last] ^= 0xff;
382        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
383        assert_eq!(err, Error::AuthenticationFailed);
384    }
385
386    #[test]
387    fn truncated_ciphertext_is_rejected() {
388        let crypt = Crypt::new();
389        let key = [0u8; 32];
390        // Anything shorter than nonce_len + tag_len cannot be a valid frame.
391        for len in 0..(CHACHA20_NONCE_LEN + CHACHA20_TAG_LEN) {
392            let err = crypt.decrypt(&key, &vec![0u8; len]).unwrap_err();
393            assert!(
394                matches!(err, Error::InvalidCiphertext(_)),
395                "len={len} should error"
396            );
397        }
398    }
399
400    #[test]
401    fn aad_round_trip() {
402        let crypt = Crypt::new();
403        let key = [0x88u8; 32];
404        let plaintext = b"plaintext";
405        let aad = b"associated";
406        let ciphertext = crypt.encrypt_with_aad(&key, plaintext, aad).unwrap();
407        let recovered = crypt.decrypt_with_aad(&key, &ciphertext, aad).unwrap();
408        assert_eq!(&*recovered, plaintext);
409    }
410
411    #[test]
412    fn aad_mismatch_fails_authentication() {
413        let crypt = Crypt::new();
414        let key = [0x99u8; 32];
415        let ciphertext = crypt
416            .encrypt_with_aad(&key, b"body", b"original-aad")
417            .unwrap();
418        let err = crypt
419            .decrypt_with_aad(&key, &ciphertext, b"tampered-aad")
420            .unwrap_err();
421        assert_eq!(err, Error::AuthenticationFailed);
422    }
423
424    #[test]
425    fn encrypt_with_aad_then_decrypt_without_aad_fails() {
426        let crypt = Crypt::new();
427        let key = [0xaau8; 32];
428        let ciphertext = crypt.encrypt_with_aad(&key, b"body", b"required").unwrap();
429        let err = crypt.decrypt(&key, &ciphertext).unwrap_err();
430        assert_eq!(err, Error::AuthenticationFailed);
431    }
432
433    #[test]
434    fn invalid_key_length_rejected_on_encrypt() {
435        let crypt = Crypt::new();
436        let err = crypt.encrypt(&[0u8; 16], b"x").unwrap_err();
437        assert_eq!(
438            err,
439            Error::InvalidKey {
440                expected: 32,
441                actual: 16
442            }
443        );
444    }
445
446    #[test]
447    fn invalid_key_length_rejected_on_decrypt() {
448        let crypt = Crypt::new();
449        // First encrypt a real ciphertext so the length-check is the
450        // reason decrypt rejects.
451        let ciphertext = crypt.encrypt(&[0u8; 32], b"x").unwrap();
452        let err = crypt.decrypt(&[0u8; 16], &ciphertext).unwrap_err();
453        assert_eq!(
454            err,
455            Error::InvalidKey {
456                expected: 32,
457                actual: 16
458            }
459        );
460    }
461}