Skip to main content

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}