arcana/cipher/chacha20poly1305.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! ChaCha20-Poly1305 AEAD (RFC 8439).
5//!
6//! Authenticated encryption with associated data, built from
7//! ChaCha20 (the stream cipher) and Poly1305 (the one-time MAC).
8//! This is the AEAD used by TLS 1.3, Noise, Signal, WireGuard,
9//! QUIC, OpenSSH, and most modern protocols where AES-GCM is not
10//! the right choice -- typically because the platform lacks
11//! AES-NI and a constant-time AES would be too slow.
12//!
13//! # Construction (RFC 8439 §2.8)
14//!
15//! 1. Initialise ChaCha20 with `(key, nonce, counter = 0)` and
16//! take the first 32 bytes of the resulting keystream as the
17//! one-time Poly1305 key.
18//! 2. Re-initialise ChaCha20 with `(key, nonce, counter = 1)` and
19//! XOR the plaintext with the keystream to produce the
20//! ciphertext.
21//! 3. Compute the Poly1305 tag over:
22//!
23//! ```text
24//! AAD ‖ pad16(AAD)
25//! ciphertext ‖ pad16(ciphertext)
26//! len(AAD) as u64 little-endian
27//! len(ciphertext) as u64 little-endian
28//! ```
29//!
30//! Where `pad16(x)` is `0..(16 - len(x) mod 16) mod 16` zero bytes.
31//!
32//! 4. Output `(ciphertext, tag)`. The tag is 16 bytes.
33//!
34//! Decryption reverses the process and verifies the tag in
35//! constant time before returning the plaintext.
36//!
37//! # API
38//!
39//! ```rust,ignore
40//! use arcana::cipher::chacha20poly1305::ChaCha20Poly1305;
41//!
42//! let key: [u8; 32] = /* shared secret */;
43//! let nonce: [u8; 12] = /* MUST be unique per (key, message) */;
44//! let aad = b"associated header";
45//! let pt = b"top secret message";
46//!
47//! let (ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, aad, pt);
48//! let pt_back = ChaCha20Poly1305::decrypt(&key, &nonce, aad, &ct, &tag)
49//! .expect("authentic");
50//! assert_eq!(pt_back, pt);
51//! ```
52//!
53//! # Nonce reuse warning
54//!
55//! Reusing a nonce with the same key on a different `(plaintext,
56//! aad)` is **catastrophic**: it leaks the XOR of the two plaintexts
57//! AND lets the attacker forge arbitrary messages under that key.
58//! Always draw a fresh random 12-byte nonce or use a counter that
59//! is guaranteed unique for the lifetime of the key.
60//!
61//! # Tests
62//!
63//! Pinned against RFC 8439 §2.8.2 (the canonical "Ladies and
64//! Gentlemen" AEAD test vector) byte-for-byte.
65
66use super::chacha20::ChaCha20;
67use super::poly1305::Poly1305;
68
69// ============================================================================
70// Public API
71// ============================================================================
72
73/// ChaCha20-Poly1305 AEAD per RFC 8439.
74///
75/// Stateless tag struct -- the per-message state lives in the
76/// ChaCha20 / Poly1305 instances. Exposed as a unit struct so the
77/// API matches the AES-GCM `Gcm` struct in `cipher::modes`.
78pub struct ChaCha20Poly1305;
79
80impl ChaCha20Poly1305 {
81 /// Encrypt and authenticate a message.
82 ///
83 /// Returns `(ciphertext, tag)` where `ciphertext.len() ==
84 /// plaintext.len()` and `tag` is exactly 16 bytes. Both must
85 /// be transmitted to the receiver alongside the nonce and AAD.
86 pub fn encrypt(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], plaintext: &[u8]) -> (Vec<u8>, [u8; 16]) {
87 // Step 1: derive the one-time Poly1305 key from ChaCha20
88 // block 0 (counter = 0).
89 let poly_key = poly_key_gen(key, nonce);
90
91 // Step 2: encrypt with ChaCha20 starting at counter = 1.
92 let mut ct = plaintext.to_vec();
93 let mut cipher = ChaCha20::new(key, nonce, 1);
94 cipher.apply_keystream(&mut ct);
95
96 // Step 3: compute the Poly1305 tag over the AEAD layout.
97 let tag = compute_tag(&poly_key, aad, &ct);
98
99 (ct, tag)
100 }
101
102 /// Decrypt and verify a ciphertext.
103 ///
104 /// Returns `Some(plaintext)` only if `tag` is the correct MAC
105 /// for `(aad, ciphertext)` under `(key, nonce)`. The tag is
106 /// compared in constant time -- the function execution time
107 /// does not leak which byte of the tag was wrong.
108 ///
109 /// Returns `None` if the tag does not verify. **Callers MUST
110 /// NOT use the returned plaintext if `None` is returned**, and
111 /// in particular must not log it, hash it, or branch on its
112 /// contents -- the only correct response to a bad tag is to
113 /// abort the protocol.
114 pub fn decrypt(key: &[u8; 32], nonce: &[u8; 12], aad: &[u8], ciphertext: &[u8], tag: &[u8; 16]) -> Option<Vec<u8>> {
115 // Recompute the expected tag and compare in constant time.
116 let poly_key = poly_key_gen(key, nonce);
117 let expected_tag = compute_tag(&poly_key, aad, ciphertext);
118
119 let mut diff = 0u8;
120 for i in 0..16 {
121 diff |= expected_tag[i] ^ tag[i];
122 }
123 if diff != 0 {
124 return None;
125 }
126
127 // Tag is good -- decrypt and return.
128 let mut pt = ciphertext.to_vec();
129 let mut cipher = ChaCha20::new(key, nonce, 1);
130 cipher.apply_keystream(&mut pt);
131 Some(pt)
132 }
133}
134
135// ============================================================================
136// Internal helpers
137// ============================================================================
138
139/// RFC 8439 §2.6 `poly1305_key_gen`: take the first 32 bytes of
140/// `ChaCha20(key, nonce, counter = 0)` as the one-time Poly1305 key.
141fn poly_key_gen(key: &[u8; 32], nonce: &[u8; 12]) -> [u8; 32] {
142 // Apply 32 zero bytes through the keystream to extract the
143 // first 32 bytes of block 0.
144 let mut buf = [0u8; 32];
145 let mut cipher = ChaCha20::new(key, nonce, 0);
146 cipher.apply_keystream(&mut buf);
147 buf
148}
149
150/// Compute the Poly1305 tag over the AEAD layout (RFC 8439 §2.8):
151///
152/// ```text
153/// AAD || pad16(AAD)
154/// CT || pad16(CT)
155/// len(AAD) as u64 LE
156/// len(CT) as u64 LE
157/// ```
158///
159/// Returns the 16-byte tag.
160fn compute_tag(poly_key: &[u8; 32], aad: &[u8], ct: &[u8]) -> [u8; 16] {
161 let mut poly = Poly1305::new(poly_key);
162 poly.update(aad);
163 poly.update(&zero_pad_to_16(aad.len()));
164 poly.update(ct);
165 poly.update(&zero_pad_to_16(ct.len()));
166 let mut len_bytes = [0u8; 16];
167 len_bytes[0..8].copy_from_slice(&(aad.len() as u64).to_le_bytes());
168 len_bytes[8..16].copy_from_slice(&(ct.len() as u64).to_le_bytes());
169 poly.update(&len_bytes);
170 poly.finalize()
171}
172
173/// Returns the zero pad needed to round `n` up to a multiple of 16.
174/// Length is in `0..16`.
175fn zero_pad_to_16(n: usize) -> Vec<u8> {
176 let pad_len = (16 - (n % 16)) % 16;
177 vec![0u8; pad_len]
178}
179
180// ============================================================================
181// Tests (RFC 8439 pinned vectors)
182// ============================================================================
183
184#[cfg(test)]
185mod tests {
186 use super::*;
187
188 fn hex(s: &str) -> Vec<u8> {
189 let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();
190 assert!(s.len() % 2 == 0, "odd hex length: {}", s.len());
191 (0..s.len())
192 .step_by(2)
193 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
194 .collect()
195 }
196
197 fn hex_arr<const N: usize>(s: &str) -> [u8; N] {
198 let v = hex(s);
199 assert_eq!(v.len(), N);
200 let mut out = [0u8; N];
201 out.copy_from_slice(&v);
202 out
203 }
204
205 /// RFC 8439 §2.6.2 `poly1305_key_gen` test vector.
206 ///
207 /// Key: 80:81:82:..:9f
208 /// Nonce: 00:00:00:00:00:01:02:03:04:05:06:07
209 /// Out: 8ad5a08b905f81cc815040274ab29471
210 /// a833b637e3fd0da508dbb8e2fdd1a646
211 #[test]
212 fn rfc8439_2_6_2_poly_key_gen() {
213 let key: [u8; 32] = hex_arr("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");
214 let nonce: [u8; 12] = hex_arr("000000000001020304050607");
215 let pk = poly_key_gen(&key, &nonce);
216 let expected = hex("8ad5a08b905f81cc815040274ab29471\
217 a833b637e3fd0da508dbb8e2fdd1a646");
218 assert_eq!(pk.to_vec(), expected);
219 }
220
221 /// RFC 8439 §2.8.2 full AEAD test vector ("Ladies and Gentlemen").
222 ///
223 /// Key: 80:81:82:..:9f
224 /// Nonce: 07:00:00:00:40:41:42:43:44:45:46:47
225 /// AAD: 50:51:52:53:c0:c1:c2:c3:c4:c5:c6:c7
226 /// Plaintext: "Ladies and Gentlemen of the class of '99: ..."
227 /// Expected ciphertext + tag pinned below.
228 #[test]
229 fn rfc8439_2_8_2_aead_vector() {
230 let key: [u8; 32] = hex_arr("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f");
231 let nonce: [u8; 12] = hex_arr("070000004041424344454647");
232 let aad = hex("50515253c0c1c2c3c4c5c6c7");
233 let plaintext = b"Ladies and Gentlemen of the class of '99: \
234 If I could offer you only one tip for the future, sunscreen \
235 would be it.";
236
237 let (ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, &aad, plaintext);
238
239 let expected_ct = hex("d31a8d34648e60db7b86afbc53ef7ec2
240 a4aded51296e08fea9e2b5a736ee62d6
241 3dbea45e8ca9671282fafb69da92728b
242 1a71de0a9e060b2905d6a5b67ecd3b36
243 92ddbd7f2d778b8c9803aee328091b58
244 fab324e4fad675945585808b4831d7bc
245 3ff4def08e4b7a9de576d26586cec64b
246 6116");
247 let expected_tag = hex("1ae10b594f09e26a7e902ecbd0600691");
248
249 assert_eq!(ct, expected_ct);
250 assert_eq!(tag.to_vec(), expected_tag);
251
252 // Decrypt round-trip with the pinned tag.
253 let mut tag_arr = [0u8; 16];
254 tag_arr.copy_from_slice(&expected_tag);
255 let pt = ChaCha20Poly1305::decrypt(&key, &nonce, &aad, &expected_ct, &tag_arr).expect("authentic");
256 assert_eq!(pt, plaintext);
257 }
258
259 /// Encrypt then decrypt round-trip on an arbitrary message.
260 #[test]
261 fn aead_roundtrip_random_inputs() {
262 let key = [0x42u8; 32];
263 let nonce = [0xa5u8; 12];
264 let aad = b"some context";
265 let pt = b"hello world; this is a test of moderate length to span more than one ChaCha20 block.";
266
267 let (ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, aad, pt);
268 assert_ne!(ct.as_slice(), pt.as_slice());
269 let back = ChaCha20Poly1305::decrypt(&key, &nonce, aad, &ct, &tag).expect("authentic");
270 assert_eq!(back.as_slice(), pt.as_slice());
271 }
272
273 /// Decrypt must reject a tampered ciphertext byte.
274 #[test]
275 fn aead_rejects_tampered_ciphertext() {
276 let key = [0x01u8; 32];
277 let nonce = [0x02u8; 12];
278 let pt = b"do not modify";
279
280 let (mut ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, b"", pt);
281 ct[0] ^= 0x01;
282 assert!(ChaCha20Poly1305::decrypt(&key, &nonce, b"", &ct, &tag).is_none());
283 }
284
285 /// Decrypt must reject a tampered tag byte.
286 #[test]
287 fn aead_rejects_tampered_tag() {
288 let key = [0x01u8; 32];
289 let nonce = [0x02u8; 12];
290 let pt = b"do not modify";
291
292 let (ct, mut tag) = ChaCha20Poly1305::encrypt(&key, &nonce, b"", pt);
293 tag[0] ^= 0x01;
294 assert!(ChaCha20Poly1305::decrypt(&key, &nonce, b"", &ct, &tag).is_none());
295 }
296
297 /// Decrypt must reject if the AAD changes -- AAD is part of the
298 /// MAC input. This is the property that makes "associated data"
299 /// authenticated even though it is not encrypted.
300 #[test]
301 fn aead_rejects_modified_aad() {
302 let key = [0xffu8; 32];
303 let nonce = [0x10u8; 12];
304 let aad = b"context-A";
305 let pt = b"shared payload";
306
307 let (ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, aad, pt);
308 assert!(ChaCha20Poly1305::decrypt(&key, &nonce, b"context-B", &ct, &tag).is_none());
309 }
310
311 /// Decrypt must reject if the wrong key is used.
312 #[test]
313 fn aead_rejects_wrong_key() {
314 let key1 = [0x33u8; 32];
315 let mut key2 = key1;
316 key2[0] ^= 0x01;
317 let nonce = [0x44u8; 12];
318 let pt = b"sensitive";
319
320 let (ct, tag) = ChaCha20Poly1305::encrypt(&key1, &nonce, b"", pt);
321 assert!(ChaCha20Poly1305::decrypt(&key2, &nonce, b"", &ct, &tag).is_none());
322 }
323
324 /// Empty plaintext is a valid input -- the ciphertext is also
325 /// empty and the tag still authenticates the AAD and the
326 /// length fields.
327 #[test]
328 fn aead_empty_plaintext() {
329 let key = [0x55u8; 32];
330 let nonce = [0x66u8; 12];
331 let aad = b"only-context";
332 let (ct, tag) = ChaCha20Poly1305::encrypt(&key, &nonce, aad, b"");
333 assert!(ct.is_empty());
334 let back = ChaCha20Poly1305::decrypt(&key, &nonce, aad, &ct, &tag).unwrap();
335 assert!(back.is_empty());
336
337 // Modifying the AAD must still be detected on empty plaintext.
338 assert!(ChaCha20Poly1305::decrypt(&key, &nonce, b"other", &ct, &tag).is_none());
339 }
340}