arcana/cipher/xsalsa20.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! XSalsa20 stream cipher (DJB, "Extending the Salsa20 nonce").
5//!
6//! Extension of Salsa20 ([`super::salsa20`]) to a **24-byte nonce**
7//! via the HSalsa20 subkey derivation — the Salsa20 analogue of
8//! XChaCha20 ([`super::xchacha20poly1305`]). The larger nonce makes it
9//! safe to pick nonces at random without a counter, and it is the
10//! stream cipher underlying NaCl / libsodium's `crypto_secretbox`
11//! (XSalsa20-Poly1305).
12//!
13//! # Construction
14//!
15//! Given a 32-byte key `K` and a 24-byte nonce `N`:
16//!
17//! 1. `subkey = HSalsa20(K, N[0..16])` — a 32-byte derived key.
18//! 2. Run `Salsa20(subkey, N[16..24], counter)` — an 8-byte nonce.
19//!
20//! HSalsa20 runs the 20-round Salsa20 core over a state built exactly
21//! like Salsa20's (constants on the diagonal, key in the border, the
22//! 16-byte input in the counter+nonce words) but **does not add the
23//! input back** and serializes only the eight "invariant" words
24//! (indices 0, 5, 10, 15 and 6, 7, 8, 9).
25//!
26//! # Side-channel posture
27//!
28//! XSalsa20 inherits Salsa20's posture: pure ARX, no tables, no
29//! secret-dependent branches or memory addressing. HSalsa20 uses the
30//! same constant-time core (`super::salsa20::rounds`); the only
31//! extra work is a fixed-position gather of eight words, which is
32//! data-oblivious. Constant-time by construction.
33//!
34//! # References
35//!
36//! - D. J. Bernstein, "Extending the Salsa20 nonce" (2011).
37//! <https://cr.yp.to/snuffle/xsalsa-20110204.pdf>
38//! - NaCl / libsodium `crypto_stream_xsalsa20`.
39
40use super::salsa20::{Salsa20, rounds};
41
42// ============================================================================
43// HSalsa20 (Bernstein, "Extending the Salsa20 nonce")
44// ============================================================================
45
46/// "expand 32-byte k" — the four Salsa20 256-bit-key constant words.
47const SIGMA: [u32; 4] = [0x6170_7865, 0x3320_646e, 0x7962_2d32, 0x6b20_6574];
48
49/// HSalsa20 subkey derivation: 32-byte key + 16-byte input → 32-byte
50/// output.
51///
52/// Builds the standard Salsa20 state (constants on the diagonal, key
53/// in the border, the 16-byte `input` in words 6, 7, 8, 9), runs the
54/// 20-round core, then serializes the eight diagonal/invariant words
55/// `state[0], state[5], state[10], state[15], state[6], state[7],
56/// state[8], state[9]` little-endian — **without** the final
57/// `⊞ input` add.
58fn hsalsa20(key: &[u8; 32], input: &[u8; 16]) -> [u8; 32] {
59 let k = |i: usize| u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap());
60 let n = |i: usize| u32::from_le_bytes(input[4 * i..4 * i + 4].try_into().unwrap());
61
62 let mut s = [0u32; 16];
63 s[0] = SIGMA[0];
64 s[1] = k(0);
65 s[2] = k(1);
66 s[3] = k(2);
67 s[4] = k(3);
68 s[5] = SIGMA[1];
69 s[6] = n(0);
70 s[7] = n(1);
71 s[8] = n(2);
72 s[9] = n(3);
73 s[10] = SIGMA[2];
74 s[11] = k(4);
75 s[12] = k(5);
76 s[13] = k(6);
77 s[14] = k(7);
78 s[15] = SIGMA[3];
79
80 rounds(&mut s);
81
82 // Serialize the invariant words (no input add).
83 let idx = [0usize, 5, 10, 15, 6, 7, 8, 9];
84 let mut out = [0u8; 32];
85 for (j, &w) in idx.iter().enumerate() {
86 out[4 * j..4 * j + 4].copy_from_slice(&s[w].to_le_bytes());
87 }
88 out
89}
90
91// ============================================================================
92// Public API
93// ============================================================================
94
95/// XSalsa20 stream cipher with a 24-byte nonce.
96///
97/// A thin wrapper: it derives a subkey with HSalsa20 from the first 16
98/// nonce bytes and runs [`Salsa20`] with the derived key and the
99/// remaining 8 nonce bytes.
100#[derive(Clone)]
101pub struct XSalsa20 {
102 inner: Salsa20,
103}
104
105impl XSalsa20 {
106 /// Initialise XSalsa20 with a 256-bit key, a 24-byte nonce, and an
107 /// initial 64-bit block counter (usually 0).
108 pub fn new(key: &[u8; 32], nonce: &[u8; 24], counter: u64) -> Self {
109 let mut h_in = [0u8; 16];
110 h_in.copy_from_slice(&nonce[..16]);
111 let subkey = hsalsa20(key, &h_in);
112
113 let mut n8 = [0u8; 8];
114 n8.copy_from_slice(&nonce[16..24]);
115
116 // Salsa20 under the derived key with the trailing 8 nonce bytes.
117 Self {
118 inner: Salsa20::new(&subkey, &n8, counter),
119 }
120 }
121
122 /// XOR the keystream into `data` in place (symmetric: encrypt or
123 /// decrypt). Partial calls are buffered like [`Salsa20`].
124 pub fn apply_keystream(&mut self, data: &mut [u8]) {
125 self.inner.apply_keystream(data);
126 }
127}
128
129// ============================================================================
130// Tests (NaCl / libsodium pinned vectors)
131// ============================================================================
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 fn hex(s: &str) -> Vec<u8> {
138 let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();
139 assert!(s.len() % 2 == 0);
140 (0..s.len())
141 .step_by(2)
142 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
143 .collect()
144 }
145
146 fn hex_arr<const N: usize>(s: &str) -> [u8; N] {
147 let v = hex(s);
148 assert_eq!(v.len(), N);
149 let mut out = [0u8; N];
150 out.copy_from_slice(&v);
151 out
152 }
153
154 /// HSalsa20 test vector from Bernstein, "Extending the Salsa20
155 /// nonce", §7 (the worked example also used by NaCl's tests).
156 ///
157 /// ```text
158 /// k = 1b27556473e985d462cd51197a9a46c7 6009549eac6474f206c4ee0844f68389
159 /// n = 69696ee955b62b73cd62bda875fc73d6 8219e0036b7a0b37
160 /// (first 16 bytes → HSalsa20 input)
161 /// HSalsa20(k, n[0..16]) =
162 /// dc908dda0b9344a953629b733820778880f3ceb421bb61b91cbd4c3e66256ce4
163 /// ```
164 #[test]
165 fn hsalsa20_djb_vector() {
166 let key: [u8; 32] = hex_arr(
167 "1b27556473e985d462cd51197a9a46c7\
168 6009549eac6474f206c4ee0844f68389",
169 );
170 let input: [u8; 16] = hex_arr("69696ee955b62b73cd62bda875fc73d6");
171 let out = hsalsa20(&key, &input);
172 let expected = hex("dc908dda0b9344a953629b733820778880f3ceb421bb61b91cbd4c3e66256ce4");
173 assert_eq!(out.to_vec(), expected);
174 }
175
176 /// XSalsa20 keystream test vector (NaCl / libsodium
177 /// `crypto_stream_xsalsa20`). Encrypting an all-zero buffer yields
178 /// the raw keystream, whose first 32 bytes are the well-known
179 /// NaCl `secretbox` sub-derivation for this (key, nonce).
180 ///
181 /// ```text
182 /// k = 1b27556473e985d462cd51197a9a46c7 6009549eac6474f206c4ee0844f68389
183 /// n = 69696ee955b62b73cd62bda875fc73d6 8219e0036b7a0b37 (24 bytes)
184 /// keystream[0..32] =
185 /// eea6a7251c1e72916d11c2cb214d3c25 2539121d8e234e652d651fa4c8cff880
186 /// ```
187 #[test]
188 fn xsalsa20_nacl_keystream_vector() {
189 let key: [u8; 32] = hex_arr(
190 "1b27556473e985d462cd51197a9a46c7\
191 6009549eac6474f206c4ee0844f68389",
192 );
193 let nonce: [u8; 24] = hex_arr("69696ee955b62b73cd62bda875fc73d68219e0036b7a0b37");
194
195 let mut ks = [0u8; 32];
196 XSalsa20::new(&key, &nonce, 0).apply_keystream(&mut ks);
197 let expected = hex("eea6a7251c1e72916d11c2cb214d3c25\
198 2539121d8e234e652d651fa4c8cff880");
199 assert_eq!(ks.to_vec(), expected);
200 }
201
202 /// Round-trip: XSalsa20 is symmetric.
203 #[test]
204 fn xsalsa20_roundtrip() {
205 let key = [0x24u8; 32];
206 let nonce = [0x91u8; 24];
207 let pt = b"XSalsa20 round-trip test message spanning multiple 64-byte blocks !!";
208 let mut buf = pt.to_vec();
209
210 XSalsa20::new(&key, &nonce, 0).apply_keystream(&mut buf);
211 assert_ne!(buf.as_slice(), pt.as_slice());
212 XSalsa20::new(&key, &nonce, 0).apply_keystream(&mut buf);
213 assert_eq!(buf.as_slice(), pt.as_slice());
214 }
215}