Skip to main content

arcana/cipher/
salsa20.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Cédric Mesnil <cslashm@pm.me>
3
4//! Salsa20 stream cipher (Bernstein, eSTREAM / DJB spec).
5//!
6//! The original ARX stream cipher from the eSTREAM portfolio, and the
7//! direct ancestor of ChaCha20 ([`super::chacha20`]). Salsa20 uses the
8//! same "expand 32-byte k" / "expand 16-byte k" constants, a 4×4 u32
9//! state, and 20 rounds (10 double-rounds), but with a different
10//! quarter-round and a different word layout (constants on the
11//! diagonal, key on two rows, counter+nonce on a row).
12//!
13//! This is the **DJB / eSTREAM variant**, not the IETF one: the nonce
14//! is 8 bytes and the block counter is a 64-bit little-endian value
15//! stored in the two words that follow the nonce. The keystream block
16//! is 64 bytes. Both the 256-bit and 128-bit key sizes are supported
17//! (matching the two sets of expansion constants); see [`Salsa20::new`]
18//! and [`Salsa20::new_128`].
19//!
20//! Salsa20 is still used, mostly through XSalsa20 ([`super::xsalsa20`])
21//! and the NaCl / libsodium `secretbox` construction, which pairs
22//! XSalsa20 with Poly1305.
23//!
24//! # Layout
25//!
26//! ```text
27//! state (4x4 u32 little-endian):
28//!
29//!   const  key    key    key
30//!   key    const  nonce  nonce
31//!   ctr    ctr    const  key
32//!   key    key    key    const
33//! ```
34//!
35//! The four constant words sit on the diagonal (indices 0, 5, 10, 15);
36//! the key fills the eight off-diagonal border words; the 8-byte nonce
37//! is at words 6, 7 and the 64-bit block counter at words 8, 9.
38//!
39//! Each 64-byte block is computed as `serialize(rounds(state) ⊞
40//! state)`, exactly the structure of ChaCha20 but with the Salsa20
41//! quarter-round and column/row ordering. Successive blocks increment
42//! the 64-bit counter.
43//!
44//! # Side-channel posture
45//!
46//! Salsa20 is a pure ARX design: every operation is a 32-bit add,
47//! XOR, or constant-distance rotate on public-position words. There
48//! are **no table lookups, no secret-dependent branches, and no
49//! secret-dependent memory addressing**. The key and counter only
50//! ever flow through add/xor/rotate, so the core and the keystream
51//! application are constant-time (data-oblivious) by construction —
52//! the same posture as ChaCha20 and the reason both are preferred
53//! over table-based AES on shared-cache targets.
54//!
55//! The block-counter loop bound in [`Salsa20::apply_keystream`]
56//! depends only on the (public) message length, not on any secret.
57//!
58//! # References
59//!
60//! - D. J. Bernstein, "The Salsa20 family of stream ciphers", in *New
61//!   Stream Cipher Designs* (2008); "Salsa20 specification"
62//!   (eSTREAM, 2005). <https://cr.yp.to/snuffle/spec.pdf>
63//! - eSTREAM "verified.test-vectors" ECRYPT test suite.
64
65// ============================================================================
66// Quarter-round and core (Salsa20 specification)
67// ============================================================================
68
69/// Salsa20 quarter-round on four state words, in place.
70///
71/// Unlike ChaCha20's quarter-round (four staged updates of all four
72/// words), Salsa20's quarter-round updates `b, c, d, a` in turn, each
73/// as `x ^= (y ⊞ z) <<< r`:
74///
75/// ```text
76///   b ^= (a ⊞ d) <<<  7
77///   c ^= (b ⊞ a) <<<  9
78///   d ^= (c ⊞ b) <<< 13
79///   a ^= (d ⊞ c) <<< 18
80/// ```
81#[inline(always)]
82pub(crate) fn quarter_round(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize) {
83    state[b] ^= state[a].wrapping_add(state[d]).rotate_left(7);
84    state[c] ^= state[b].wrapping_add(state[a]).rotate_left(9);
85    state[d] ^= state[c].wrapping_add(state[b]).rotate_left(13);
86    state[a] ^= state[d].wrapping_add(state[c]).rotate_left(18);
87}
88
89/// The 20-round Salsa20 core, without the final `⊞ input` and
90/// serialization. Operates on a working copy; used by both the block
91/// function and by HSalsa20 ([`super::xsalsa20`]).
92///
93/// 20 rounds = 10 double-rounds; each double-round is 4 column
94/// quarter-rounds followed by 4 row quarter-rounds.
95#[inline]
96pub(crate) fn rounds(working: &mut [u32; 16]) {
97    for _ in 0..10 {
98        // Column round.
99        quarter_round(working, 0, 4, 8, 12);
100        quarter_round(working, 5, 9, 13, 1);
101        quarter_round(working, 10, 14, 2, 6);
102        quarter_round(working, 15, 3, 7, 11);
103        // Row round.
104        quarter_round(working, 0, 1, 2, 3);
105        quarter_round(working, 5, 6, 7, 4);
106        quarter_round(working, 10, 11, 8, 9);
107        quarter_round(working, 15, 12, 13, 14);
108    }
109}
110
111/// Compute one 64-byte Salsa20 keystream block from the initial state.
112///
113/// The output is `serialize(rounds(state) ⊞ state)`, little-endian
114/// per 32-bit word — the same "add the untouched input" step that
115/// ChaCha20 uses.
116fn block(state: &[u32; 16]) -> [u8; 64] {
117    let mut working = *state;
118    rounds(&mut working);
119
120    let mut out = [0u8; 64];
121    for i in 0..16 {
122        let word = working[i].wrapping_add(state[i]);
123        out[4 * i..4 * i + 4].copy_from_slice(&word.to_le_bytes());
124    }
125    out
126}
127
128// ============================================================================
129// State construction
130// ============================================================================
131
132/// "expand 32-byte k" — the four 256-bit-key constant words (sigma).
133const SIGMA: [u32; 4] = [0x6170_7865, 0x3320_646e, 0x7962_2d32, 0x6b20_6574];
134/// "expand 16-byte k" — the four 128-bit-key constant words (tau).
135const TAU: [u32; 4] = [0x6170_7865, 0x3120_646e, 0x7962_2d36, 0x6b20_6574];
136
137/// Build the initial Salsa20 state from a 256-bit key, an 8-byte
138/// nonce, and a 64-bit block counter.
139///
140/// The layout places the four constant words on the diagonal (0, 5,
141/// 10, 15), the eight key words in the border, the nonce in words
142/// 6, 7 and the counter in words 8, 9 (all little-endian).
143fn state_256(key: &[u8; 32], nonce: &[u8; 8], counter: u64) -> [u32; 16] {
144    let mut s = [0u32; 16];
145    let k = |i: usize| u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap());
146
147    s[0] = SIGMA[0];
148    s[1] = k(0);
149    s[2] = k(1);
150    s[3] = k(2);
151    s[4] = k(3);
152    s[5] = SIGMA[1];
153    s[6] = u32::from_le_bytes(nonce[0..4].try_into().unwrap());
154    s[7] = u32::from_le_bytes(nonce[4..8].try_into().unwrap());
155    s[8] = counter as u32;
156    s[9] = (counter >> 32) as u32;
157    s[10] = SIGMA[2];
158    s[11] = k(4);
159    s[12] = k(5);
160    s[13] = k(6);
161    s[14] = k(7);
162    s[15] = SIGMA[3];
163    s
164}
165
166/// Build the initial Salsa20 state from a 128-bit key (the key is used
167/// twice, once per half of the border), an 8-byte nonce, and a 64-bit
168/// block counter. Uses the `tau` constants.
169fn state_128(key: &[u8; 16], nonce: &[u8; 8], counter: u64) -> [u32; 16] {
170    let mut s = [0u32; 16];
171    let k = |i: usize| u32::from_le_bytes(key[4 * i..4 * i + 4].try_into().unwrap());
172
173    s[0] = TAU[0];
174    s[1] = k(0);
175    s[2] = k(1);
176    s[3] = k(2);
177    s[4] = k(3);
178    s[5] = TAU[1];
179    s[6] = u32::from_le_bytes(nonce[0..4].try_into().unwrap());
180    s[7] = u32::from_le_bytes(nonce[4..8].try_into().unwrap());
181    s[8] = counter as u32;
182    s[9] = (counter >> 32) as u32;
183    s[10] = TAU[2];
184    // 128-bit key repeats: words 11..15 reuse k(0..4).
185    s[11] = k(0);
186    s[12] = k(1);
187    s[13] = k(2);
188    s[14] = k(3);
189    s[15] = TAU[3];
190    s
191}
192
193// ============================================================================
194// Public API
195// ============================================================================
196
197/// Salsa20 stream cipher state (DJB / eSTREAM variant).
198///
199/// Holds the initial state (key + nonce + 64-bit counter) and buffers
200/// the leftover bytes of the current keystream block so partial calls
201/// to [`Self::apply_keystream`] behave like one contiguous stream.
202#[derive(Clone)]
203pub struct Salsa20 {
204    /// Initial 16-word state. The 64-bit counter (words 8, 9) is
205    /// advanced in place between blocks.
206    state: [u32; 16],
207    /// Current 64-byte keystream block, possibly partially consumed.
208    buffer: [u8; 64],
209    /// Number of bytes already consumed from `buffer` (0..=64);
210    /// 64 means "empty, refill on next byte".
211    buf_pos: usize,
212}
213
214impl Salsa20 {
215    /// Initialise Salsa20 with a 256-bit key, an 8-byte nonce, and an
216    /// initial 64-bit block counter (usually 0).
217    pub fn new(key: &[u8; 32], nonce: &[u8; 8], counter: u64) -> Self {
218        Self {
219            state: state_256(key, nonce, counter),
220            buffer: [0u8; 64],
221            buf_pos: 64,
222        }
223    }
224
225    /// Initialise Salsa20 with a 128-bit key, an 8-byte nonce, and an
226    /// initial 64-bit block counter. Uses the `tau` expansion
227    /// constants and repeats the key across the state border.
228    pub fn new_128(key: &[u8; 16], nonce: &[u8; 8], counter: u64) -> Self {
229        Self {
230            state: state_128(key, nonce, counter),
231            buffer: [0u8; 64],
232            buf_pos: 64,
233        }
234    }
235
236    /// XOR the keystream into `data` in place. Encrypts or decrypts
237    /// indifferently (the cipher is symmetric).
238    ///
239    /// Handles arbitrary lengths and partial blocks: leftover
240    /// keystream bytes are remembered between calls, so feeding data in
241    /// chunks produces the same output as one contiguous call.
242    pub fn apply_keystream(&mut self, data: &mut [u8]) {
243        let mut pos = 0;
244        while pos < data.len() {
245            if self.buf_pos == 64 {
246                self.buffer = block(&self.state);
247                // Advance the 64-bit little-endian block counter
248                // (words 8, 9).
249                let ctr = (self.state[8] as u64) | ((self.state[9] as u64) << 32);
250                let ctr = ctr.wrapping_add(1);
251                self.state[8] = ctr as u32;
252                self.state[9] = (ctr >> 32) as u32;
253                self.buf_pos = 0;
254            }
255            let take = (64 - self.buf_pos).min(data.len() - pos);
256            for i in 0..take {
257                data[pos + i] ^= self.buffer[self.buf_pos + i];
258            }
259            self.buf_pos += take;
260            pos += take;
261        }
262    }
263}
264
265// ============================================================================
266// Tests (DJB / eSTREAM pinned vectors)
267// ============================================================================
268
269#[cfg(test)]
270mod tests {
271    use super::*;
272
273    fn hex(s: &str) -> Vec<u8> {
274        let s: String = s.chars().filter(|c| !c.is_whitespace()).collect();
275        assert!(s.len() % 2 == 0);
276        (0..s.len())
277            .step_by(2)
278            .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap())
279            .collect()
280    }
281
282    fn hex_arr<const N: usize>(s: &str) -> [u8; N] {
283        let v = hex(s);
284        assert_eq!(v.len(), N);
285        let mut out = [0u8; N];
286        out.copy_from_slice(&v);
287        out
288    }
289
290    /// eSTREAM `verified.test-vectors`, Salsa20 **256-bit Set 1,
291    /// vector# 0** (key = 0x80 in byte 0, all else zero, IV = 0).
292    ///
293    /// Keystream bytes 0..63 and 192..255 are pinned. The XOR-digest
294    /// form of the suite folds all 512 keystream bytes; here we pin the
295    /// two directly-listed 64-byte stream segments, which is sufficient
296    /// to catch any core / layout / counter error.
297    #[test]
298    fn estream_256_set1_vector0() {
299        let key: [u8; 32] = hex_arr(
300            "80000000000000000000000000000000\
301             00000000000000000000000000000000",
302        );
303        let nonce = [0u8; 8];
304        let mut cipher = Salsa20::new(&key, &nonce, 0);
305        let mut stream = [0u8; 256];
306        cipher.apply_keystream(&mut stream);
307
308        // stream[0..64]
309        let expected0 = hex("E3BE8FDD8BECA2E3EA8EF9475B29A6E7\
310             003951E1097A5C38D23B7A5FAD9F6844\
311             B22C97559E2723C7CBBD3FE4FC8D9A07\
312             44652A83E72A9C461876AF4D7EF1A117");
313        assert_eq!(&stream[0..64], expected0.as_slice(), "block 0");
314
315        // stream[192..256]
316        let expected192 = hex("57BE81F47B17D9AE7C4FF15429A73E10\
317             ACF250ED3A90A93C711308A74C6216A9\
318             ED84CD126DA7F28E8ABF8BB63517E1CA\
319             98E712F4FB2E1A6AED9FDC73291FAA17");
320        assert_eq!(&stream[192..256], expected192.as_slice(), "block 3");
321    }
322
323    /// eSTREAM `verified.test-vectors`, Salsa20 **128-bit Set 1,
324    /// vector# 0** (key = 0x80 in byte 0, IV = 0), 128-bit key path.
325    #[test]
326    fn estream_128_set1_vector0() {
327        let key: [u8; 16] = hex_arr("80000000000000000000000000000000");
328        let nonce = [0u8; 8];
329        let mut cipher = Salsa20::new_128(&key, &nonce, 0);
330        let mut stream = [0u8; 256];
331        cipher.apply_keystream(&mut stream);
332
333        let expected0 = hex("4DFA5E481DA23EA09A31022050859936\
334             DA52FCEE218005164F267CB65F5CFD7F\
335             2B4F97E0FF16924A52DF269515110A07\
336             F9E460BC65EF95DA58F740B7D1DBB0AA");
337        assert_eq!(&stream[0..64], expected0.as_slice(), "block 0");
338
339        let expected192 = hex("DA9C1581F429E0A00F7D67E23B730676\
340             783B262E8EB43A25F55FB90B3E753AEF\
341             8C6713EC66C51881111593CCB3E8CB8F\
342             8DE124080501EEEB389C4BCB6977CF95");
343        assert_eq!(&stream[192..256], expected192.as_slice(), "block 3");
344    }
345
346    /// Decryption is the same operation as encryption (XOR keystream).
347    /// Re-applying the keystream must recover the plaintext.
348    #[test]
349    fn salsa20_encrypt_decrypt_roundtrip() {
350        let key = [0x42u8; 32];
351        let nonce = [0xa5u8; 8];
352        let plaintext = b"the quick brown fox jumps over the lazy dog";
353        let mut buf = plaintext.to_vec();
354
355        let mut enc = Salsa20::new(&key, &nonce, 0);
356        enc.apply_keystream(&mut buf);
357        assert_ne!(buf.as_slice(), plaintext);
358
359        let mut dec = Salsa20::new(&key, &nonce, 0);
360        dec.apply_keystream(&mut buf);
361        assert_eq!(buf.as_slice(), plaintext);
362    }
363
364    /// Chunked application must match a single call (buffered
365    /// partial-block path is stateful and correct across block edges).
366    #[test]
367    fn salsa20_chunked_apply_matches_single() {
368        let key = [0x77u8; 32];
369        let nonce = [0x11u8; 8];
370        let plaintext: Vec<u8> = (0..200).map(|i| i as u8).collect();
371
372        let mut single = plaintext.clone();
373        Salsa20::new(&key, &nonce, 0).apply_keystream(&mut single);
374
375        let mut chunked = plaintext.clone();
376        let mut c = Salsa20::new(&key, &nonce, 0);
377        c.apply_keystream(&mut chunked[..30]);
378        c.apply_keystream(&mut chunked[30..70]); // crosses block boundary
379        c.apply_keystream(&mut chunked[70..130]); // crosses again
380        c.apply_keystream(&mut chunked[130..]);
381
382        assert_eq!(chunked, single);
383    }
384}