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#[derive(Debug)]
23pub struct SodiumXChaCha20Encryptor {
24    key: Key<32>,
25}
26
27impl SodiumXChaCha20Encryptor {
28    pub const IDENTIFIER: &'static str = "sodium-xchacha20";
29
30    pub fn new(key: [u8; 32]) -> Self {
31        Self { key: Key::new(key) }
32    }
33
34    pub fn from_slice(key: &[u8]) -> Result<Self> {
35        Ok(Self {
36            key: Key::from_slice(key, LABEL)?,
37        })
38    }
39}
40
41impl Identified for SodiumXChaCha20Encryptor {
42    fn identifier(&self) -> &str {
43        Self::IDENTIFIER
44    }
45}
46
47impl SymmetricCipher for SodiumXChaCha20Encryptor {
48    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
49        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_bytes())
50            .expect("key length is fixed at 32");
51
52        let mut nonce = [0u8; NONCE_LEN];
53        OsRng.fill_bytes(&mut nonce);
54
55        let mut buffer = plaintext.to_vec();
56        let tag = cipher
57            .encrypt_in_place_detached(XNonce::from_slice(&nonce), b"", &mut buffer)
58            .map_err(|_| Error::EncryptionFailed { label: LABEL })?;
59
60        // libsodium 布局:密文在前、tag 在后。
61        let mut blob = Vec::with_capacity(PREFIX.len() + NONCE_LEN + buffer.len() + TAG_LEN);
62        blob.extend_from_slice(PREFIX);
63        blob.extend_from_slice(&nonce);
64        blob.extend_from_slice(&buffer);
65        blob.extend_from_slice(&tag);
66        Ok(blob)
67    }
68
69    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
70        let body = ciphertext
71            .strip_prefix(PREFIX)
72            .ok_or(Error::InvalidPrefix { label: LABEL })?;
73        if body.len() < NONCE_LEN + TAG_LEN {
74            return Err(Error::TooShort { label: LABEL });
75        }
76
77        let (nonce, rest) = body.split_at(NONCE_LEN);
78        let (ct, tag) = rest.split_at(rest.len() - TAG_LEN);
79
80        let cipher = XChaCha20Poly1305::new_from_slice(self.key.as_bytes())
81            .expect("key length is fixed at 32");
82        let mut buffer = ct.to_vec();
83        cipher
84            .decrypt_in_place_detached(
85                XNonce::from_slice(nonce),
86                b"",
87                &mut buffer,
88                Tag::from_slice(tag),
89            )
90            .map_err(|_| Error::DecryptionFailed { label: LABEL })?;
91        Ok(buffer)
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98
99    #[test]
100    fn roundtrip_and_tamper_detection() {
101        let encryptor = SodiumXChaCha20Encryptor::new([9u8; 32]);
102        let blob = encryptor.encrypt(b"data").unwrap();
103        assert_eq!(blob.len(), 2 + NONCE_LEN + 4 + TAG_LEN);
104        assert_eq!(encryptor.decrypt(&blob).unwrap(), b"data");
105
106        let mut tampered = blob.clone();
107        let tag_start = tampered.len() - TAG_LEN;
108        tampered[tag_start] ^= 0x01;
109        assert_eq!(
110            encryptor.decrypt(&tampered).unwrap_err(),
111            Error::DecryptionFailed { label: LABEL }
112        );
113    }
114
115    #[test]
116    fn wrong_key_fails() {
117        let blob = SodiumXChaCha20Encryptor::new([9u8; 32])
118            .encrypt(b"x")
119            .unwrap();
120        assert_eq!(
121            SodiumXChaCha20Encryptor::new([1u8; 32])
122                .decrypt(&blob)
123                .unwrap_err(),
124            Error::DecryptionFailed { label: LABEL }
125        );
126    }
127
128    #[test]
129    fn bad_prefix_and_short_blob() {
130        let encryptor = SodiumXChaCha20Encryptor::new([9u8; 32]);
131        assert_eq!(
132            encryptor.decrypt(b"nope").unwrap_err(),
133            Error::InvalidPrefix { label: LABEL }
134        );
135        assert_eq!(
136            encryptor.decrypt(b"v1tiny").unwrap_err(),
137            Error::TooShort { label: LABEL }
138        );
139    }
140}