arcana/cipher/chacha20.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! ChaCha20 stream cipher (RFC 8439).
5//!
6//! This is the IETF / TLS 1.3 variant of ChaCha20: 256-bit key,
7//! 96-bit nonce, 32-bit block counter, 64-byte block size, 20 rounds.
8//!
9//! It is the second AEAD primitive shipped by `arcana`
10//! alongside AES-GCM. Used by TLS 1.3, Noise, Signal, WireGuard,
11//! QUIC, OpenSSH, and most modern protocols that prefer a constant-
12//! time stream cipher with no S-box dependencies (no cache-timing
13//! surface, in contrast to table-based AES).
14//!
15//! # Layout
16//!
17//! ```text
18//! state (4x4 u32 little-endian):
19//!
20//! constants constants constants constants "expand 32-byte k"
21//! key key key key
22//! key key key key
23//! counter nonce nonce nonce
24//! ```
25//!
26//! Each 64-byte block is computed as `serialize(rounds(state) + state)`.
27//! Successive blocks increment `counter`.
28//!
29//! # API
30//!
31//! ```rust,ignore
32//! use arcana::cipher::chacha20::ChaCha20;
33//!
34//! let mut cipher = ChaCha20::new(&key, &nonce, 1); // initial counter = 1
35//! let mut buf = b"plaintext".to_vec();
36//! cipher.apply_keystream(&mut buf); // encrypt or decrypt
37//! ```
38//!
39//! Stream ciphers are symmetric: `apply_keystream` does both
40//! encryption and decryption since the keystream is XOR'd with
41//! whatever is passed in.
42//!
43//! # Tests
44//!
45//! Pinned against RFC 8439 §2.3.2 (block test vector) and §2.4.2
46//! (encryption test vector).
47
48// ============================================================================
49// Quarter-round (RFC 8439 §2.1)
50// ============================================================================
51
52/// Single ChaCha20 quarter-round on 4 state words. Operates in place.
53///
54/// ```text
55/// a += b; d ^= a; d <<<= 16
56/// c += d; b ^= c; b <<<= 12
57/// a += b; d ^= a; d <<<= 8
58/// c += d; b ^= c; b <<<= 7
59/// ```
60#[inline(always)]
61pub(crate) fn quarter_round(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) {
62 state[a] = state[a].wrapping_add(state[b]);
63 state[d] ^= state[a];
64 state[d] = state[d].rotate_left(16);
65
66 state[c] = state[c].wrapping_add(state[d]);
67 state[b] ^= state[c];
68 state[b] = state[b].rotate_left(12);
69
70 state[a] = state[a].wrapping_add(state[b]);
71 state[d] ^= state[a];
72 state[d] = state[d].rotate_left(8);
73
74 state[c] = state[c].wrapping_add(state[d]);
75 state[b] ^= state[c];
76 state[b] = state[b].rotate_left(7);
77}
78
79// ============================================================================
80// Block function (RFC 8439 §2.3)
81// ============================================================================
82
83/// Compute one 64-byte ChaCha20 keystream block from the initial state.
84///
85/// 20 rounds = 10 double-rounds; each double-round is 4 column rounds
86/// followed by 4 diagonal rounds. The output is `(working ⊞ initial)`
87/// serialized little-endian, where `⊞` is wrapping word-wise add.
88fn block(state: &[u32; 16]) -> [u8; 64] {
89 let mut working = *state;
90
91 for _ in 0..10 {
92 // Column round.
93 quarter_round(&mut working, 0, 4, 8, 12);
94 quarter_round(&mut working, 1, 5, 9, 13);
95 quarter_round(&mut working, 2, 6, 10, 14);
96 quarter_round(&mut working, 3, 7, 11, 15);
97 // Diagonal round.
98 quarter_round(&mut working, 0, 5, 10, 15);
99 quarter_round(&mut working, 1, 6, 11, 12);
100 quarter_round(&mut working, 2, 7, 8, 13);
101 quarter_round(&mut working, 3, 4, 9, 14);
102 }
103
104 // Output = working + state, serialized little-endian.
105 let mut out = [0u8; 64];
106 for i in 0..16 {
107 let word = working[i].wrapping_add(state[i]);
108 out[4 * i..4 * i + 4].copy_from_slice(&word.to_le_bytes());
109 }
110 out
111}
112
113// ============================================================================
114// Public API
115// ============================================================================
116
117/// ChaCha20 stream cipher state (RFC 8439).
118///
119/// Holds the initial state (key + nonce + counter) and the current
120/// counter value. Buffers the leftover bytes from the previous
121/// keystream block so partial calls to `apply_keystream` work
122/// correctly.
123#[derive(Clone)]
124pub struct ChaCha20 {
125 /// Initial 16-word state. The counter at index 12 is mutated
126 /// in place between blocks.
127 state: [u32; 16],
128 /// Current 64-byte keystream block, possibly partially consumed.
129 buffer: [u8; 64],
130 /// Number of bytes left in `buffer` (0..=64).
131 buf_pos: usize,
132}
133
134impl ChaCha20 {
135 /// Initialise a ChaCha20 cipher with a 32-byte key, a 12-byte
136 /// nonce, and an initial 32-bit block counter.
137 ///
138 /// Per RFC 8439 §2.4 the AEAD construction starts the cipher
139 /// at counter = 1 (counter = 0 produces the one-time Poly1305
140 /// key). Standalone ChaCha20 users typically start at counter
141 /// = 0 or 1 -- whatever their protocol specifies.
142 pub fn new(key: &[u8; 32], nonce: &[u8; 12], counter: u32) -> Self {
143 let mut state = [0u32; 16];
144 // Constants: "expand 32-byte k" as four little-endian u32.
145 state[0] = 0x6170_7865;
146 state[1] = 0x3320_646e;
147 state[2] = 0x7962_2d32;
148 state[3] = 0x6b20_6574;
149 // Key (8 words, LE).
150 for i in 0..8 {
151 state[4 + i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap());
152 }
153 // Counter (1 word).
154 state[12] = counter;
155 // Nonce (3 words, LE).
156 for i in 0..3 {
157 state[13 + i] = u32::from_le_bytes(nonce[4 * i..4 * i + 4].try_into().unwrap());
158 }
159
160 Self {
161 state,
162 buffer: [0u8; 64],
163 buf_pos: 64,
164 } // buf_pos = 64 means "empty"
165 }
166
167 /// XOR the keystream into `data` in place. Encrypts or decrypts
168 /// indifferently (the cipher is symmetric).
169 ///
170 /// Handles arbitrary lengths and partial blocks: the cipher
171 /// remembers leftover keystream bytes between calls, so
172 /// `cipher.apply_keystream(b"hi"); cipher.apply_keystream(b"!")`
173 /// produces the same output as one call with `b"hi!"`.
174 pub fn apply_keystream(&mut self, data: &mut [u8]) {
175 let mut pos = 0;
176 while pos < data.len() {
177 // Refill the buffer if it is empty.
178 if self.buf_pos == 64 {
179 self.buffer = block(&self.state);
180 // Advance the 32-bit block counter (RFC 8439 §2.3).
181 self.state[12] = self.state[12].wrapping_add(1);
182 self.buf_pos = 0;
183 }
184 let take = (64 - self.buf_pos).min(data.len() - pos);
185 for i in 0..take {
186 data[pos + i] ^= self.buffer[self.buf_pos + i];
187 }
188 self.buf_pos += take;
189 pos += take;
190 }
191 }
192}
193
194// ============================================================================
195// Tests (RFC 8439 pinned vectors)
196// ============================================================================
197
198#[cfg(test)]
199mod tests {
200 use super::*;
201
202 fn hex(s: &str) -> Vec<u8> {
203 assert!(s.len() % 2 == 0);
204 (0..s.len())
205 .step_by(2)
206 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
207 .collect()
208 }
209
210 fn hex_arr<const N: usize>(s: &str) -> [u8; N] {
211 let v = hex(s);
212 assert_eq!(v.len(), N);
213 let mut out = [0u8; N];
214 out.copy_from_slice(&v);
215 out
216 }
217
218 /// RFC 8439 §2.1.1 quarter-round example.
219 ///
220 /// Input: a=0x11111111, b=0x01020304, c=0x9b8d6f43, d=0x01234567
221 /// Output: a=0xea2a92f4, b=0xcb1cf8ce, c=0x4581472e, d=0x5881c4bb
222 #[test]
223 fn rfc8439_2_1_1_quarter_round() {
224 let mut s = [0u32; 16];
225 s[0] = 0x11111111;
226 s[1] = 0x01020304;
227 s[2] = 0x9b8d6f43;
228 s[3] = 0x01234567;
229 quarter_round(&mut s, 0, 1, 2, 3);
230 assert_eq!(s[0], 0xea2a92f4);
231 assert_eq!(s[1], 0xcb1cf8ce);
232 assert_eq!(s[2], 0x4581472e);
233 assert_eq!(s[3], 0x5881c4bb);
234 }
235
236 /// RFC 8439 §2.3.2 block-function test vector.
237 ///
238 /// Key: 00:01:02:..:1f
239 /// Nonce: 00:00:00:09:00:00:00:4a:00:00:00:00
240 /// Counter: 1
241 /// Output: 10f1e7e4d13b5915500fdd1fa32071c4
242 /// c7d1f4c733c068030422aa9ac3d46c4e
243 /// d2826446079faa0914c2d705d98b02a2
244 /// b5129cd1de164eb9cbd083e8a2503c4e
245 #[test]
246 fn rfc8439_2_3_2_block_vector() {
247 let key: [u8; 32] = hex_arr("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
248 let nonce: [u8; 12] = hex_arr("000000090000004a00000000");
249 let cipher = ChaCha20::new(&key, &nonce, 1);
250 let out = block(&cipher.state);
251 let expected = hex("10f1e7e4d13b5915500fdd1fa32071c4\
252 c7d1f4c733c068030422aa9ac3d46c4e\
253 d2826446079faa0914c2d705d98b02a2\
254 b5129cd1de164eb9cbd083e8a2503c4e");
255 assert_eq!(out.to_vec(), expected);
256 }
257
258 /// RFC 8439 §2.4.2 encryption test vector.
259 ///
260 /// Key: 00:01:02:..:1f
261 /// Nonce: 00:00:00:00:00:00:00:4a:00:00:00:00
262 /// Counter: 1
263 /// Plaintext: "Ladies and Gentlemen of the class of '99: ...
264 /// ...if I could offer you only one tip for the
265 /// future, sunscreen would be it."
266 /// Ciphertext: 6e2e359a2568f98041ba0728dd0d6981...
267 /// ...874d
268 #[test]
269 fn rfc8439_2_4_2_encryption_vector() {
270 let key: [u8; 32] = hex_arr("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f");
271 let nonce: [u8; 12] = hex_arr("000000000000004a00000000");
272 let plaintext = b"Ladies and Gentlemen of the class of '99: \
273 If I could offer you only one tip for the future, sunscreen \
274 would be it.";
275
276 let mut buf = plaintext.to_vec();
277 let mut cipher = ChaCha20::new(&key, &nonce, 1);
278 cipher.apply_keystream(&mut buf);
279
280 let expected = hex("6e2e359a2568f98041ba0728dd0d6981\
281 e97e7aec1d4360c20a27afccfd9fae0b\
282 f91b65c5524733ab8f593dabcd62b357\
283 1639d624e65152ab8f530c359f0861d8\
284 07ca0dbf500d6a6156a38e088a22b65e\
285 52bc514d16ccf806818ce91ab7793736\
286 5af90bbf74a35be6b40b8eedf2785e42\
287 874d");
288 assert_eq!(buf, expected);
289 }
290
291 /// Decryption is the same operation as encryption (XOR keystream).
292 /// Re-applying the keystream must recover the plaintext.
293 #[test]
294 fn chacha20_encrypt_decrypt_roundtrip() {
295 let key = [0x42u8; 32];
296 let nonce = [0xa5u8; 12];
297 let plaintext = b"the quick brown fox jumps over the lazy dog";
298 let mut buf = plaintext.to_vec();
299
300 let mut enc = ChaCha20::new(&key, &nonce, 1);
301 enc.apply_keystream(&mut buf);
302 assert_ne!(buf.as_slice(), plaintext);
303
304 let mut dec = ChaCha20::new(&key, &nonce, 1);
305 dec.apply_keystream(&mut buf);
306 assert_eq!(buf.as_slice(), plaintext);
307 }
308
309 /// Calling `apply_keystream` in chunks must give the same result
310 /// as one big call (proves the buffered partial-block path is
311 /// stateful and correct).
312 #[test]
313 fn chacha20_chunked_apply_matches_single() {
314 let key = [0x77u8; 32];
315 let nonce = [0x11u8; 12];
316 let plaintext: Vec<u8> = (0..200).map(|i| i as u8).collect();
317
318 // Single call.
319 let mut single = plaintext.clone();
320 ChaCha20::new(&key, &nonce, 1).apply_keystream(&mut single);
321
322 // Chunked at boundaries that span block edges (64).
323 let mut chunked = plaintext.clone();
324 let mut c = ChaCha20::new(&key, &nonce, 1);
325 c.apply_keystream(&mut chunked[..30]);
326 c.apply_keystream(&mut chunked[30..70]); // crosses block boundary
327 c.apply_keystream(&mut chunked[70..130]); // crosses again
328 c.apply_keystream(&mut chunked[130..]);
329
330 assert_eq!(chunked, single);
331 }
332
333 /// All-zero key + all-zero nonce + counter 0 yields a known
334 /// fixed first block (RFC 8439 §2.3.2 sanity).
335 #[test]
336 fn chacha20_zero_key_zero_nonce_block_zero() {
337 let key = [0u8; 32];
338 let nonce = [0u8; 12];
339 let cipher = ChaCha20::new(&key, &nonce, 0);
340 let out = block(&cipher.state);
341 // First 32 bytes from RFC 8439 Appendix A.1 test vector 1.
342 let expected_prefix = hex("76b8e0ada0f13d90405d6ae55386bd28\
343 bdd219b8a08ded1aa836efcc8b770dc7");
344 assert_eq!(&out[..32], expected_prefix.as_slice());
345 }
346}