Skip to main content

encryption/encryptor/
salsa20.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3//! Salsa20/20 流密码(D. J. Bernstein)+ HMAC-SHA256(encrypt-then-MAC);
4//! 载荷:`v1 | nonce(8) | MAC(32) | 密文(与密钥流异或)`。密钥 32 字节。
5//! 与国密 ZUC(`src/guomi/zuc.rs`)同一套帧与 etm 约定。
6//!
7//! # 单密钥消息上限:64 位 nonce(使用前必读)
8//!
9//! Salsa20 的 nonce 按规范固定为 8 字节(64 位),本库每次加密随机生成一个。
10//! 同一把密钥下的 nonce 空间只有 2^64,随机取值在消息量达到 2^32(约 43 亿)条时,
11//! 按生日界已有约 **39%** 的概率至少重复一次。
12//!
13//! nonce 在 `v1` 帧头里**明文**传输:攻击者拿到一批密文,按 nonce 分组即可直接
14//! 看出重复——不需要任何解密预言机。而一旦两条消息 nonce 相同,它们的密钥流完全
15//! 一致,两条密文异或即得 `m1 XOR m2`,机密性当场失效(两条消息的明文统计信息
16//! 随之泄露,且可被利用做 crib-dragging)。
17//!
18//! 因此:单密钥下的消息量必须远低于 2^32(建议 ≤ 2^24 条,重复概率约 2^-17 量级),
19//! 密钥轮换应作为常规运维手段。需要单密钥处理海量消息时,改用
20//! [`SodiumXChaCha20Encryptor`](crate::encryptor::SodiumXChaCha20Encryptor)
21//! (同库同族原语,nonce 192 位,随机生成下 2^64 条消息仍无碰撞之忧)。
22//!
23//! 这是「64 位 nonce + 随机生成」的组合性质,本层无法修复;不要试图绕过它,
24//! 要么控制消息量,要么换 192 位 nonce 的算法。
25
26use cbc::cipher::{KeyIvInit, StreamCipher};
27use rand_core::{OsRng, RngCore};
28use salsa20::Salsa20;
29
30use crate::contract::{Identified, SymmetricCipher};
31use crate::error::Result;
32use crate::internal::etm::{self, EtmKeys};
33
34const LABEL: &str = "Salsa20";
35/// Salsa20 的 nonce 是 64 位(8 字节);即名称里的 20 之外的变体参数都在密钥里。
36const NONCE_LEN: usize = 8;
37
38/// Salsa20/20 加密器。
39///
40/// 直接持有 `EtmKeys`(密文密钥 + 派生好的 MAC 密钥,均 Drop 清零),
41/// 加密与解密路径不再每次重跑一遍 MAC 密钥派生。
42#[derive(Debug)]
43pub struct Salsa20Encryptor {
44    keys: EtmKeys<32>,
45}
46
47impl Salsa20Encryptor {
48    pub const IDENTIFIER: &'static str = "salsa20";
49
50    pub fn new(key: [u8; 32]) -> Self {
51        Self {
52            keys: EtmKeys::new(key),
53        }
54    }
55
56    pub fn from_slice(key: &[u8]) -> Result<Self> {
57        Ok(Self {
58            keys: EtmKeys::from_slice(key, LABEL)?,
59        })
60    }
61}
62
63impl Identified for Salsa20Encryptor {
64    fn identifier(&self) -> &str {
65        Self::IDENTIFIER
66    }
67}
68
69impl SymmetricCipher for Salsa20Encryptor {
70    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
71        let mut nonce = [0u8; NONCE_LEN];
72        OsRng.fill_bytes(&mut nonce);
73
74        let mut buffer = plaintext.to_vec();
75        Salsa20::new_from_slices(self.keys.cipher(), &nonce)
76            .expect("key and nonce lengths are fixed")
77            .apply_keystream(&mut buffer);
78
79        Ok(etm::pack(&nonce, &buffer, self.keys.mac()))
80    }
81
82    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
83        let (nonce, body) = etm::unpack_and_verify(ciphertext, NONCE_LEN, self.keys.mac(), LABEL)?;
84
85        let mut buffer = body.to_vec();
86        Salsa20::new_from_slices(self.keys.cipher(), nonce)
87            .expect("key and nonce lengths were verified")
88            .apply_keystream(&mut buffer);
89        Ok(buffer)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::error::Error;
97
98    /// 十六进制字符串 → 字节:KAT 向量照抄 eSTREAM 验证向量文本,避免手工转写。
99    fn unhex(s: &str) -> Vec<u8> {
100        (0..s.len() / 2)
101            .map(|i| u8::from_str_radix(&s[2 * i..2 * i + 2], 16).unwrap())
102            .collect()
103    }
104
105    /// Salsa20/20(256 位密钥、8 字节 nonce)官方向量:直接调用裸密钥流 ——
106    /// 封装层的往返测试抓不出「密钥流整体错位」。第一条 = eSTREAM/ECRYPT 验证
107    /// 向量(256-bit Set 1 vector 0,只覆盖计数块 0);第二条 = Bernstein
108    /// 《The Salsa20 family of stream ciphers》§4.1 规范向量(256 字节跨 4 个
109    /// 计数块,覆盖计数器的字节序与跨块递增)。两条均与 salsa20 crate 自带
110    /// 测试一致,第二条数值另经独立 Python 复算核对。
111    #[test]
112    fn bare_keystream_matches_estream_vectors() {
113        let key = unhex("8000000000000000000000000000000000000000000000000000000000000000");
114        let nonce = unhex("0000000000000000");
115        let mut keystream = [0u8; 64];
116        Salsa20::new_from_slices(&key, &nonce)
117            .unwrap()
118            .apply_keystream(&mut keystream);
119        assert_eq!(
120            keystream.as_slice(),
121            unhex(concat!(
122                "e3be8fdd8beca2e3ea8ef9475b29a6e7",
123                "003951e1097a5c38d23b7a5fad9f6844",
124                "b22c97559e2723c7cbbd3fe4fc8d9a07",
125                "44652a83e72a9c461876af4d7ef1a117"
126            ))
127            .as_slice()
128        );
129
130        let key = unhex("0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20");
131        let nonce = unhex("0301040105090206");
132        let mut keystream = [0u8; 256];
133        Salsa20::new_from_slices(&key, &nonce)
134            .unwrap()
135            .apply_keystream(&mut keystream);
136        assert_eq!(
137            keystream.as_slice(),
138            unhex(concat!(
139                "6ebcbdbf76fccc64ab05542bee8a67cb",
140                "c28fa2e141fbefbb3a2f9b221909c8d7",
141                "d4295258cb539770dd24d7ac3443769f",
142                "fa27a50e60644264dc8b6b612683372e",
143                "085d0a12bf240b189ce2b78289862b56",
144                "fdc9fcffc33bef9325a2e81b98fb3fb9",
145                "aa04cf434615ceffeb985c1cb08d8440",
146                "e90b1d56ddeaea16d9e15affff1f698c",
147                "483c7a466af1fe062574adfd2b06a62b",
148                "4d98440719ea776385c470349a7ed696",
149                "9583463ed5d26b8fefccb205da0f5bfa",
150                "98c77812fe756b09eacc282aa42f4baf",
151                "a79633189046e2b20f35b3e0e54aa3b9",
152                "29e23c0f47dc7bcd4f928b2a9764be7d",
153                "4b8a50f980a50b35ad8087375e0c556e",
154                "cbe6a7161e8653ce9391e1e6710ed4f1"
155            ))
156            .as_slice()
157        );
158    }
159
160    #[test]
161    fn roundtrip_including_multi_block_and_empty() {
162        let encryptor = Salsa20Encryptor::new([3u8; 32]);
163        // 200 字节跨 4 个计数块(每块 64 字节)。
164        for plaintext in [b"".as_slice(), b"a", b"abc", b"abcd", &[0xffu8; 200]] {
165            let blob = encryptor.encrypt(plaintext).unwrap();
166            assert_eq!(encryptor.decrypt(&blob).unwrap(), plaintext);
167        }
168    }
169
170    #[test]
171    fn ciphertext_differs_from_plaintext_and_tamper_fails() {
172        let encryptor = Salsa20Encryptor::new([3u8; 32]);
173        let blob = encryptor.encrypt(b"0123456789").unwrap();
174        // 帧头 2 + nonce 8 + MAC 32 = 42。
175        assert_ne!(&blob[42..], b"0123456789");
176
177        let mut tampered = blob.clone();
178        let last = tampered.len() - 1;
179        tampered[last] ^= 0x01;
180        assert_eq!(
181            encryptor.decrypt(&tampered).unwrap_err(),
182            Error::MacVerificationFailed { label: LABEL }
183        );
184    }
185
186    #[test]
187    fn wrong_key_fails_mac() {
188        let blob = Salsa20Encryptor::new([3u8; 32]).encrypt(b"secret").unwrap();
189        let other = Salsa20Encryptor::new([4u8; 32]);
190        assert_eq!(
191            other.decrypt(&blob).unwrap_err(),
192            Error::MacVerificationFailed { label: LABEL }
193        );
194    }
195
196    #[test]
197    fn nonce_is_fresh_per_encryption() {
198        let encryptor = Salsa20Encryptor::new([3u8; 32]);
199        let first = encryptor.encrypt(b"same plaintext").unwrap();
200        let second = encryptor.encrypt(b"same plaintext").unwrap();
201        assert_ne!(first, second);
202    }
203
204    #[test]
205    fn invalid_prefix_and_too_short_are_rejected() {
206        let encryptor = Salsa20Encryptor::new([3u8; 32]);
207        assert_eq!(
208            encryptor.decrypt(b"v9 not a blob").unwrap_err(),
209            Error::InvalidPrefix { label: LABEL }
210        );
211        assert_eq!(
212            encryptor.decrypt(b"v1 short").unwrap_err(),
213            Error::TooShort { label: LABEL }
214        );
215    }
216
217    #[test]
218    fn key_length_is_checked() {
219        assert_eq!(
220            Salsa20Encryptor::from_slice(&[0u8; 16]).unwrap_err(),
221            Error::InvalidKeyLength {
222                label: LABEL,
223                expected: 32,
224                got: 16
225            }
226        );
227    }
228}