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}