Skip to main content

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}