Skip to main content

arcana/cipher/
chacha20.rs

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