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}