Skip to main content

encryption/encryptor/
sodium_xchacha20.rs

1// Copyright (c) 2026 erik <erik@erik.xyz> — https://erik.xyz
2
3//! XChaCha20-Poly1305(对应 PHP 版 `SodiumXChaCha20Encryptor`,libsodium 布局);
4//! 载荷:`v1 | Nonce(24) | 密文 ‖ Tag(16)`。
5//!
6//! 纯 Rust 实现,无需任何系统扩展(原版需要 `ext-sodium`)。
7
8use chacha20poly1305::aead::AeadInPlace;
9use chacha20poly1305::{KeyInit, Tag, XChaCha20Poly1305, XNonce};
10use rand_core::{OsRng, RngCore};
11
12use crate::contract::{Identified, SymmetricCipher};
13use crate::error::{Error, Result};
14use crate::key::Key;
15
16const LABEL: &str = "XChaCha20-Poly1305";
17const PREFIX: &[u8] = b"v1";
18const NONCE_LEN: usize = 24;
19const TAG_LEN: usize = 16;
20
21/// XChaCha20-Poly1305 加密器。
22///
23/// 密钥实例在构造时就建立,加密与解密都复用它;密钥字节随 `ChaChaPoly1305`
24/// 自身的 `Drop` 清零。192 位 nonce 随机生成,单密钥下可安全处理海量消息
25/// (`salsa20` 的 64 位 nonce 达不到这一点,见其模块文档)。
26pub struct SodiumXChaCha20Encryptor {
27    cipher: XChaCha20Poly1305,
28}
29
30impl SodiumXChaCha20Encryptor {
31    pub const IDENTIFIER: &'static str = "sodium-xchacha20";
32
33    pub fn new(key: [u8; 32]) -> Self {
34        Self {
35            cipher: XChaCha20Poly1305::new_from_slice(&key).expect("key length is fixed at 32"),
36        }
37    }
38
39    pub fn from_slice(key: &[u8]) -> Result<Self> {
40        // 借 Key<N> 做长度校验;临时副本随其 Drop 清零。
41        let key = Key::<32>::from_slice(key, LABEL)?;
42        Ok(Self {
43            cipher: XChaCha20Poly1305::new_from_slice(key.as_bytes())
44                .expect("key length is fixed at 32"),
45        })
46    }
47}
48
49/// 只打印标识符——密钥材质不出现在 `Debug` 里
50/// (`XChaCha20Poly1305` 未实现 `Debug`,此类型无法 derive)。
51impl std::fmt::Debug for SodiumXChaCha20Encryptor {
52    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53        f.debug_struct("SodiumXChaCha20Encryptor")
54            .field("identifier", &Self::IDENTIFIER)
55            .finish_non_exhaustive()
56    }
57}
58
59impl Identified for SodiumXChaCha20Encryptor {
60    fn identifier(&self) -> &str {
61        Self::IDENTIFIER
62    }
63}
64
65impl SymmetricCipher for SodiumXChaCha20Encryptor {
66    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
67        let mut nonce = [0u8; NONCE_LEN];
68        OsRng.fill_bytes(&mut nonce);
69
70        let mut buffer = plaintext.to_vec();
71        let tag = self
72            .cipher
73            .encrypt_in_place_detached(XNonce::from_slice(&nonce), b"", &mut buffer)
74            .map_err(|_| Error::EncryptionFailed { label: LABEL })?;
75
76        // libsodium 布局:密文在前、tag 在后。
77        let mut blob = Vec::with_capacity(PREFIX.len() + NONCE_LEN + buffer.len() + TAG_LEN);
78        blob.extend_from_slice(PREFIX);
79        blob.extend_from_slice(&nonce);
80        blob.extend_from_slice(&buffer);
81        blob.extend_from_slice(&tag);
82        Ok(blob)
83    }
84
85    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
86        let body = ciphertext
87            .strip_prefix(PREFIX)
88            .ok_or(Error::InvalidPrefix { label: LABEL })?;
89        if body.len() < NONCE_LEN + TAG_LEN {
90            return Err(Error::TooShort { label: LABEL });
91        }
92
93        let (nonce, rest) = body.split_at(NONCE_LEN);
94        let (ct, tag) = rest.split_at(rest.len() - TAG_LEN);
95
96        let mut buffer = ct.to_vec();
97        self.cipher
98            .decrypt_in_place_detached(
99                XNonce::from_slice(nonce),
100                b"",
101                &mut buffer,
102                Tag::from_slice(tag),
103            )
104            .map_err(|_| Error::DecryptionFailed { label: LABEL })?;
105        Ok(buffer)
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn roundtrip_and_tamper_detection() {
115        let encryptor = SodiumXChaCha20Encryptor::new([9u8; 32]);
116        let blob = encryptor.encrypt(b"data").unwrap();
117        assert_eq!(blob.len(), 2 + NONCE_LEN + 4 + TAG_LEN);
118        assert_eq!(encryptor.decrypt(&blob).unwrap(), b"data");
119
120        let mut tampered = blob.clone();
121        let tag_start = tampered.len() - TAG_LEN;
122        tampered[tag_start] ^= 0x01;
123        assert_eq!(
124            encryptor.decrypt(&tampered).unwrap_err(),
125            Error::DecryptionFailed { label: LABEL }
126        );
127    }
128
129    #[test]
130    fn wrong_key_fails() {
131        let blob = SodiumXChaCha20Encryptor::new([9u8; 32])
132            .encrypt(b"x")
133            .unwrap();
134        assert_eq!(
135            SodiumXChaCha20Encryptor::new([1u8; 32])
136                .decrypt(&blob)
137                .unwrap_err(),
138            Error::DecryptionFailed { label: LABEL }
139        );
140    }
141
142    #[test]
143    fn bad_prefix_and_short_blob() {
144        let encryptor = SodiumXChaCha20Encryptor::new([9u8; 32]);
145        assert_eq!(
146            encryptor.decrypt(b"nope").unwrap_err(),
147            Error::InvalidPrefix { label: LABEL }
148        );
149        assert_eq!(
150            encryptor.decrypt(b"v1tiny").unwrap_err(),
151            Error::TooShort { label: LABEL }
152        );
153    }
154}