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