Skip to main content

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}