Skip to main content

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}