Skip to main content

arcana/cipher/
xchacha20poly1305.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! XChaCha20-Poly1305 AEAD (draft-irtf-cfrg-xchacha).
5//!
6//! Extension of ChaCha20-Poly1305 (RFC 8439) to a **24-byte nonce**
7//! via the HChaCha20 subkey derivation. The larger nonce makes it
8//! safe to pick nonces randomly without tracking a counter — the
9//! birthday bound becomes `2^96` instead of `2^48` for the 12-byte
10//! IETF nonce.
11//!
12//! # Construction
13//!
14//! Given a 32-byte key `K` and a 24-byte nonce `N`:
15//!
16//! 1. Split `N` into `N[0..16]` (for HChaCha20) and `N[16..24]`.
17//! 2. `subkey = HChaCha20(K, N[0..16])` — a 32-byte derived key.
18//! 3. `nonce' = 0x00000000 || N[16..24]` — a 12-byte IETF nonce.
19//! 4. Run `ChaCha20-Poly1305(subkey, nonce', aad, plaintext)`.
20//!
21//! Used by libsodium (`crypto_aead_xchacha20poly1305_ietf_*`),
22//! Signal, Age, WireGuard handshake, and many modern protocols
23//! that want random nonces without the 2^48 cap.
24
25use crate::cipher::chacha20::quarter_round;
26use crate::cipher::chacha20poly1305::ChaCha20Poly1305;
27
28// ============================================================================
29// HChaCha20 (draft-irtf-cfrg-xchacha §2.2)
30// ============================================================================
31
32/// HChaCha20 subkey derivation: 32-byte key + 16-byte input → 32-byte output.
33///
34/// Unlike ChaCha20 block, HChaCha20 does **not** add the initial
35/// state to the output: it serializes the post-round state directly.
36fn hchacha20(key: &[u8; 32], input: &[u8; 16]) -> [u8; 32] {
37    let mut state = [0u32; 16];
38
39    // Constants: "expand 32-byte k" as four little-endian u32.
40    state[0] = 0x6170_7865;
41    state[1] = 0x3320_646e;
42    state[2] = 0x7962_2d32;
43    state[3] = 0x6b20_6574;
44
45    // Key (8 words, LE).
46    for i in 0..8 {
47        state[4 + i] = u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap());
48    }
49
50    // 16-byte input occupies positions [12..16] (where ChaCha20 has
51    // counter + 12-byte nonce).
52    for i in 0..4 {
53        state[12 + i] = u32::from_le_bytes(input[4 * i..4 * i + 4].try_into().unwrap());
54    }
55
56    // 20 rounds = 10 double-rounds (same as ChaCha20).
57    for _ in 0..10 {
58        quarter_round(&mut state, 0, 4, 8, 12);
59        quarter_round(&mut state, 1, 5, 9, 13);
60        quarter_round(&mut state, 2, 6, 10, 14);
61        quarter_round(&mut state, 3, 7, 11, 15);
62        quarter_round(&mut state, 0, 5, 10, 15);
63        quarter_round(&mut state, 1, 6, 11, 12);
64        quarter_round(&mut state, 2, 7, 8, 13);
65        quarter_round(&mut state, 3, 4, 9, 14);
66    }
67
68    // Output = state[0..4] || state[12..16] (no initial-state add).
69    let mut out = [0u8; 32];
70    for i in 0..4 {
71        out[4 * i..4 * i + 4].copy_from_slice(&state[i].to_le_bytes());
72    }
73    for i in 0..4 {
74        out[16 + 4 * i..16 + 4 * i + 4].copy_from_slice(&state[12 + i].to_le_bytes());
75    }
76    out
77}
78
79// ============================================================================
80// XChaCha20-Poly1305 AEAD
81// ============================================================================
82
83/// XChaCha20-Poly1305 AEAD with 24-byte nonce.
84///
85/// Same shape as [`ChaCha20Poly1305`]:
86/// a unit struct with associated `encrypt` / `decrypt` functions.
87/// The only difference is the 24-byte nonce.
88pub struct XChaCha20Poly1305;
89
90impl XChaCha20Poly1305 {
91    /// Encrypt and authenticate a message.
92    ///
93    /// Returns `(ciphertext, tag)` where `ciphertext.len() ==
94    /// plaintext.len()` and `tag` is exactly 16 bytes.
95    pub fn encrypt(key: &[u8; 32], nonce: &[u8; 24], aad: &[u8], plaintext: &[u8]) -> (Vec<u8>, [u8; 16]) {
96        let (subkey, nonce12) = derive(key, nonce);
97        ChaCha20Poly1305::encrypt(&subkey, &nonce12, aad, plaintext)
98    }
99
100    /// Decrypt and verify a ciphertext. Returns `None` on tag mismatch.
101    pub fn decrypt(key: &[u8; 32], nonce: &[u8; 24], aad: &[u8], ciphertext: &[u8], tag: &[u8; 16]) -> Option<Vec<u8>> {
102        let (subkey, nonce12) = derive(key, nonce);
103        ChaCha20Poly1305::decrypt(&subkey, &nonce12, aad, ciphertext, tag)
104    }
105}
106
107/// Derive the inner (subkey, 12-byte nonce) pair from the 24-byte nonce.
108fn derive(key: &[u8; 32], nonce: &[u8; 24]) -> ([u8; 32], [u8; 12]) {
109    let mut hchacha_in = [0u8; 16];
110    hchacha_in.copy_from_slice(&nonce[..16]);
111    let subkey = hchacha20(key, &hchacha_in);
112
113    let mut nonce12 = [0u8; 12];
114    // Prefix of 4 zero bytes + last 8 bytes of the 24-byte nonce.
115    nonce12[4..].copy_from_slice(&nonce[16..24]);
116
117    (subkey, nonce12)
118}
119
120// ============================================================================
121// Tests
122// ============================================================================
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127
128    fn hex(s: &str) -> Vec<u8> {
129        let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();
130        (0..s.len())
131            .step_by(2)
132            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
133            .collect()
134    }
135
136    /// HChaCha20 test vector from draft-irtf-cfrg-xchacha §2.2.1.
137    #[test]
138    fn hchacha20_test_vector() {
139        let key = hex("00 01 02 03 04 05 06 07 08 09 0a 0b 0c 0d 0e 0f
140             10 11 12 13 14 15 16 17 18 19 1a 1b 1c 1d 1e 1f");
141        let input = hex("00 00 00 09 00 00 00 4a 00 00 00 00 31 41 59 27");
142        let expected = hex("82 41 3b 42 27 b2 7b fe d3 0e 42 50 8a 87 7d 73
143             a0 f9 e4 d5 8a 74 a8 53 c1 2e c4 13 26 d3 ec dc");
144        let k: [u8; 32] = key.try_into().unwrap();
145        let n: [u8; 16] = input.try_into().unwrap();
146        let out = hchacha20(&k, &n);
147        assert_eq!(out.to_vec(), expected);
148    }
149
150    /// XChaCha20-Poly1305 test vector from draft-irtf-cfrg-xchacha §A.3.1.
151    #[test]
152    fn xchacha20poly1305_test_vector() {
153        let plaintext = hex("4c 61 64 69 65 73 20 61 6e 64 20 47 65 6e 74 6c
154             65 6d 65 6e 20 6f 66 20 74 68 65 20 63 6c 61 73
155             73 20 6f 66 20 27 39 39 3a 20 49 66 20 49 20 63
156             6f 75 6c 64 20 6f 66 66 65 72 20 79 6f 75 20 6f
157             6e 6c 79 20 6f 6e 65 20 74 69 70 20 66 6f 72 20
158             74 68 65 20 66 75 74 75 72 65 2c 20 73 75 6e 73
159             63 72 65 65 6e 20 77 6f 75 6c 64 20 62 65 20 69
160             74 2e");
161        let aad = hex("50 51 52 53 c0 c1 c2 c3 c4 c5 c6 c7");
162        let key = hex("80 81 82 83 84 85 86 87 88 89 8a 8b 8c 8d 8e 8f
163             90 91 92 93 94 95 96 97 98 99 9a 9b 9c 9d 9e 9f");
164        let nonce = hex("40 41 42 43 44 45 46 47 48 49 4a 4b 4c 4d 4e 4f
165             50 51 52 53 54 55 56 57");
166        let expected_ct = hex("bd 6d 17 9d 3e 83 d4 3b 95 76 57 94 93 c0 e9 39
167             57 2a 17 00 25 2b fa cc be d2 90 2c 21 39 6c bb
168             73 1c 7f 1b 0b 4a a6 44 0b f3 a8 2f 4e da 7e 39
169             ae 64 c6 70 8c 54 c2 16 cb 96 b7 2e 12 13 b4 52
170             2f 8c 9b a4 0d b5 d9 45 b1 1b 69 b9 82 c1 bb 9e
171             3f 3f ac 2b c3 69 48 8f 76 b2 38 35 65 d3 ff f9
172             21 f9 66 4c 97 63 7d a9 76 88 12 f6 15 c6 8b 13
173             b5 2e");
174        let expected_tag = hex("c0 87 59 24 c1 c7 98 79 47 de af d8 78 0a cf 49");
175
176        let k: [u8; 32] = key.try_into().unwrap();
177        let n: [u8; 24] = nonce.try_into().unwrap();
178
179        let (ct, tag) = XChaCha20Poly1305::encrypt(&k, &n, &aad, &plaintext);
180        assert_eq!(ct, expected_ct, "ciphertext mismatch");
181        assert_eq!(tag.to_vec(), expected_tag, "tag mismatch");
182
183        let pt = XChaCha20Poly1305::decrypt(&k, &n, &aad, &ct, &tag).expect("decrypt must succeed");
184        assert_eq!(pt, plaintext);
185    }
186
187    #[test]
188    fn xchacha20poly1305_tamper_rejected() {
189        let key = [0x42u8; 32];
190        let nonce = [0x77u8; 24];
191        let aad = b"header";
192        let pt = b"secret payload";
193
194        let (mut ct, tag) = XChaCha20Poly1305::encrypt(&key, &nonce, aad, pt);
195        // Flip one ciphertext byte → decrypt must reject.
196        ct[0] ^= 0xFF;
197        assert!(XChaCha20Poly1305::decrypt(&key, &nonce, aad, &ct, &tag).is_none());
198    }
199
200    #[test]
201    fn xchacha20poly1305_empty_plaintext() {
202        let key = [0u8; 32];
203        let nonce = [0u8; 24];
204        let (ct, tag) = XChaCha20Poly1305::encrypt(&key, &nonce, b"", b"");
205        assert_eq!(ct.len(), 0);
206        let pt = XChaCha20Poly1305::decrypt(&key, &nonce, b"", &ct, &tag).unwrap();
207        assert_eq!(pt.len(), 0);
208    }
209}