Skip to main content

rspamd_client/protocol/
encryption.rs

1//! This module contains encryption functions used by the HTTPCrypt protocol.
2//! This encryption is common to x25519 and chacha20-poly1305 as defined in RFC 8439.
3//! However, since HTTPCrypt has been designed before RFC being published, it uses
4//! a different way to do KEX and to derive shared keys.
5//! In general, it relies on hchacha20 for kdf and x25519 for key exchange.
6
7use crate::error::RspamdError;
8use blake2b_simd::blake2b;
9use chacha20::cipher::{KeyIvInit, StreamCipher};
10use chacha20::{hchacha, XChaCha20, R20};
11use crypto_box::{
12    aead::{AeadCore, OsRng},
13    ChaChaBox, SecretKey,
14};
15use curve25519_dalek::scalar::clamp_integer;
16use curve25519_dalek::{MontgomeryPoint, Scalar};
17use poly1305::universal_hash::KeyInit;
18use poly1305::{Poly1305, Tag};
19use rspamd_base32::{decode, encode};
20use zeroize::Zeroizing;
21
22/// It must be the same as Rspamd one, that is currently 5
23const SHORT_KEY_ID_SIZE: usize = 5;
24
25/// XChaCha20 nonce size in bytes
26const NONCE_SIZE: usize = 24;
27
28pub(crate) type RspamdNM = Zeroizing<[u8; 32]>;
29
30pub struct RspamdSecretbox {
31    enc_ctx: XChaCha20,
32    mac_ctx: Poly1305,
33}
34
35pub struct HTTPCryptEncrypted {
36    pub body: Vec<u8>,
37    pub peer_key: String, // Encoded as base32
38    pub shared_key: RspamdNM,
39}
40
41impl RspamdSecretbox {
42    /// Construct new secretbox following Rspamd conventions
43    pub fn new(key: RspamdNM, nonce: &[u8; NONCE_SIZE]) -> Self {
44        // Rspamd does it in a different way, doing full chacha20 round on the extended mac key
45        let mut chacha = XChaCha20::new_from_slices(key.as_slice(), nonce.as_slice()).unwrap();
46        let mut mac_key = Zeroizing::new([0u8; 64]);
47        chacha.apply_keystream(mac_key.as_mut());
48        let poly = Poly1305::new_from_slice(&mac_key[..32]).unwrap();
49        RspamdSecretbox {
50            enc_ctx: chacha,
51            mac_ctx: poly,
52        }
53    }
54
55    /// Encrypts data in place and returns a tag
56    pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
57        // Encrypt-then-mac
58        self.enc_ctx.apply_keystream(data);
59        self.mac_ctx.compute_unpadded(data)
60    }
61
62    /// Decrypts in place if auth tag is correct
63    pub fn decrypt_in_place(&mut self, data: &mut [u8], tag: &Tag) -> Result<usize, RspamdError> {
64        let computed = self.mac_ctx.clone().compute_unpadded(data);
65        if computed != *tag {
66            return Err(RspamdError::EncryptionError(
67                "Authentication failed".to_string(),
68            ));
69        }
70        self.enc_ctx.apply_keystream(&mut data[..]);
71
72        Ok(computed.len())
73    }
74}
75
76pub fn make_key_header(remote_pk: &str, local_pk: &str) -> Result<String, RspamdError> {
77    let remote_pk = decode(remote_pk)
78        .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?;
79    let hash = blake2b(remote_pk.as_slice());
80    let hash_b32 = encode(&hash.as_bytes()[0..SHORT_KEY_ID_SIZE]);
81    Ok(format!("{}={}", hash_b32.as_str(), local_pk))
82}
83
84/// Precomputed HTTPCrypt state for a fixed destination keypair.
85///
86/// Decoding the base32 peer public key and deriving the short key id used in
87/// the `Key` header depend only on the destination key, so they are done once
88/// at construction and shared by every request. Everything derived from
89/// ephemeral keys (scalarmult, shared secret, nonce) stays per-request.
90pub struct HttpCryptContext {
91    /// Decoded remote public key
92    peer_pk: [u8; 32],
93    /// Base32-encoded blake2b short id of the peer key, the `Key` header prefix
94    key_id_b32: String,
95}
96
97impl HttpCryptContext {
98    /// Parse a base32-encoded remote public key into a reusable context.
99    pub fn new(peer_key_b32: &str) -> Result<Self, RspamdError> {
100        let decoded = decode(peer_key_b32)
101            .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?;
102        let peer_pk: [u8; 32] = decoded
103            .as_slice()
104            .try_into()
105            .map_err(|_| RspamdError::EncryptionError("Invalid public key length".to_string()))?;
106        let hash = blake2b(peer_pk.as_slice());
107        let key_id_b32 = encode(&hash.as_bytes()[0..SHORT_KEY_ID_SIZE]);
108        Ok(Self {
109            peer_pk,
110            key_id_b32,
111        })
112    }
113
114    /// Build the `Key` header value for an ephemeral local public key.
115    pub fn key_header(&self, local_pk_b32: &str) -> String {
116        format!("{}={}", self.key_id_b32, local_pk_b32)
117    }
118
119    /// Encrypt a request for the destination this context was built for,
120    /// generating a fresh ephemeral keypair.
121    pub fn encrypt<T, HN, HV>(
122        &self,
123        url: &str,
124        body: &[u8],
125        headers: T,
126    ) -> Result<HTTPCryptEncrypted, RspamdError>
127    where
128        T: IntoIterator<Item = (HN, HV)>,
129        HN: AsRef<[u8]>,
130        HV: AsRef<[u8]>,
131    {
132        let local_sk = SecretKey::generate(&mut OsRng);
133        let local_pk = local_sk.public_key();
134        let extra_size = std::mem::size_of::<<ChaChaBox as AeadCore>::NonceSize>()
135            + std::mem::size_of::<<ChaChaBox as AeadCore>::TagSize>();
136        let mut dest = Vec::with_capacity(body.len() + 128 + extra_size);
137
138        // Fill the inner headers
139        dest.extend_from_slice(b"POST ");
140        dest.extend_from_slice(url.as_bytes());
141        dest.extend_from_slice(b" HTTP/1.1\n");
142        for (k, v) in headers {
143            dest.extend_from_slice(k.as_ref());
144            dest.extend_from_slice(b": ");
145            dest.extend_from_slice(v.as_ref());
146            dest.push(b'\n');
147        }
148        dest.extend_from_slice(format!("Content-Length: {}\n\n", body.len()).as_bytes());
149        dest.extend_from_slice(body.as_ref());
150
151        let (encrypted, nm) = encrypt_inplace(dest.as_slice(), &self.peer_pk, &local_sk)?;
152
153        Ok(HTTPCryptEncrypted {
154            body: encrypted,
155            peer_key: rspamd_base32::encode(local_pk.as_ref()),
156            shared_key: nm,
157        })
158    }
159}
160
161/// Scalar multiplication with an already decoded remote public key.
162pub(crate) fn rspamd_x25519_scalarmult_raw(
163    remote_pk: &[u8; 32],
164    local_sk: &SecretKey,
165) -> Zeroizing<MontgomeryPoint> {
166    // Do manual scalarmult as Rspamd is using it's own way there
167    let e = Scalar::from_bytes_mod_order(clamp_integer(local_sk.to_bytes()));
168    let p = MontgomeryPoint(*remote_pk);
169    Zeroizing::new(e * p)
170}
171
172/// Perform a scalar multiplication with a base32-encoded remote public key and a local secret key.
173#[cfg(test)]
174pub(crate) fn rspamd_x25519_scalarmult(
175    remote_pk: &[u8],
176    local_sk: &SecretKey,
177) -> Result<Zeroizing<MontgomeryPoint>, RspamdError> {
178    let remote_pk: [u8; 32] = decode(remote_pk)
179        .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?
180        .as_slice()
181        .try_into()
182        .map_err(|_| RspamdError::EncryptionError("Invalid public key length".to_string()))?;
183    Ok(rspamd_x25519_scalarmult_raw(&remote_pk, local_sk))
184}
185
186/// Unlike IETF version, Rspamd uses an old suggested way to derive a shared secret - it performs
187/// hchacha iteration on the point and a zeroed nonce.
188pub(crate) fn rspamd_x25519_ecdh(point: Zeroizing<MontgomeryPoint>) -> RspamdNM {
189    let n0 = Default::default();
190    Zeroizing::new(hchacha::<R20>(&point.to_bytes().into(), &n0).into())
191}
192
193/// Encrypt a plaintext with a decoded peer public key and a local secret key.
194fn encrypt_inplace(
195    plaintext: &[u8],
196    recipient_public_key: &[u8; 32],
197    local_sk: &SecretKey,
198) -> Result<(Vec<u8>, RspamdNM), RspamdError> {
199    let mut dest = Vec::with_capacity(plaintext.len() + NONCE_SIZE + poly1305::BLOCK_SIZE);
200    let ec_point = rspamd_x25519_scalarmult_raw(recipient_public_key, local_sk);
201    let nm = rspamd_x25519_ecdh(ec_point);
202
203    let nonce: [u8; NONCE_SIZE] = ChaChaBox::generate_nonce(&mut OsRng).into();
204    let cbox = RspamdSecretbox::new(nm.clone(), &nonce);
205    dest.extend_from_slice(nonce.as_slice());
206    // Make room in the buffer for the tag. It needs to be prepended.
207    dest.extend_from_slice(Tag::default().as_slice());
208    let offset = dest.len();
209    dest.extend_from_slice(plaintext);
210    let tag = cbox.encrypt_in_place(&mut dest.as_mut_slice()[offset..]);
211    let tag_dest = &mut <Vec<u8> as AsMut<Vec<u8>>>::as_mut(&mut dest)
212        [nonce.len()..(nonce.len() + poly1305::BLOCK_SIZE)];
213    tag_dest.copy_from_slice(tag.as_slice());
214    Ok((dest, nm))
215}
216
217pub fn httpcrypt_encrypt<T, HN, HV>(
218    url: &str,
219    body: &[u8],
220    headers: T,
221    peer_key: &[u8],
222) -> Result<HTTPCryptEncrypted, RspamdError>
223where
224    T: IntoIterator<Item = (HN, HV)>,
225    HN: AsRef<[u8]>,
226    HV: AsRef<[u8]>,
227{
228    let ctx = HttpCryptContext::new(std::str::from_utf8(peer_key)?)?;
229    ctx.encrypt(url, body, headers)
230}
231
232/// Decrypts body using HTTPCrypt algorithm
233pub fn httpcrypt_decrypt(body: &mut [u8], nm: RspamdNM) -> Result<usize, RspamdError> {
234    if body.len() < NONCE_SIZE + poly1305::BLOCK_SIZE {
235        return Err(RspamdError::EncryptionError(
236            "Invalid body size".to_string(),
237        ));
238    }
239
240    let (nonce, remain) = body.split_at_mut(NONCE_SIZE);
241    let (tag, decrypted_dest) = remain.split_at_mut(poly1305::BLOCK_SIZE);
242    let tag = Tag::try_from(&*tag)
243        .map_err(|_| RspamdError::EncryptionError("Invalid tag size".to_string()))?;
244    let mut offset = nonce.len();
245    let nonce: &[u8; NONCE_SIZE] = (&*nonce).try_into().unwrap();
246    let mut sbox = RspamdSecretbox::new(nm, nonce);
247    offset += sbox.decrypt_in_place(decrypted_dest, &tag)?;
248    Ok(offset)
249}
250
251#[cfg(test)]
252mod tests {
253    use crate::protocol::encryption::*;
254    const EXPECTED_POINT: [u8; 32] = [
255        95, 76, 225, 188, 0, 26, 146, 94, 70, 249, 90, 189, 35, 51, 1, 42, 9, 37, 94, 254, 204, 55,
256        198, 91, 180, 90, 46, 217, 140, 226, 211, 90,
257    ];
258
259    #[cfg(test)]
260    #[test]
261    fn test_scalarmult() {
262        use crypto_box::SecretKey;
263        let sk = SecretKey::from_slice(&[0u8; 32]).unwrap();
264        let pk = "k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay";
265        let point = rspamd_x25519_scalarmult(pk.as_bytes(), &sk).unwrap();
266        assert_eq!(point.to_bytes().as_slice(), EXPECTED_POINT);
267    }
268
269    #[cfg(test)]
270    #[test]
271    fn test_ecdh() {
272        const EXPECTED_NM: [u8; 32] = [
273            61, 109, 220, 195, 100, 174, 127, 237, 148, 122, 154, 61, 165, 83, 93, 105, 127, 166,
274            153, 112, 103, 224, 2, 200, 136, 243, 73, 51, 8, 163, 150, 7,
275        ];
276        let point = Zeroizing::new(MontgomeryPoint(EXPECTED_POINT));
277        let nm = rspamd_x25519_ecdh(point);
278        assert_eq!(nm.as_slice(), &EXPECTED_NM);
279    }
280
281    const PEER_PK_B32: &str = "k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay";
282
283    /// The precomputed context must produce the same `Key` header as the
284    /// legacy per-request helper.
285    #[test]
286    fn test_context_key_header_matches_legacy() {
287        let ctx = HttpCryptContext::new(PEER_PK_B32).unwrap();
288        let local_pk = "oqqm9kkt7c1ws638cyf41apar3in1wuyx647gzrx88hhd94ehm3y";
289        assert_eq!(
290            ctx.key_header(local_pk),
291            make_key_header(PEER_PK_B32, local_pk).unwrap()
292        );
293    }
294
295    /// Encrypting through the context and decrypting with the returned shared
296    /// key must round-trip the inner request.
297    #[test]
298    fn test_context_encrypt_decrypt_roundtrip() {
299        let ctx = HttpCryptContext::new(PEER_PK_B32).unwrap();
300        let body = b"test message body";
301        let headers = [("From", "user@example.com")];
302        let encrypted = ctx.encrypt("/checkv2", body, headers).unwrap();
303
304        let mut encrypted_body = encrypted.body;
305        let offset = httpcrypt_decrypt(encrypted_body.as_mut_slice(), encrypted.shared_key)
306            .expect("decryption with the shared key must succeed");
307        let plaintext = &encrypted_body[offset..];
308        let plaintext_str = std::str::from_utf8(plaintext).unwrap();
309        assert!(plaintext_str.starts_with("POST /checkv2 HTTP/1.1\n"));
310        assert!(plaintext_str.contains("From: user@example.com\n"));
311        assert!(plaintext_str.ends_with("test message body"));
312    }
313
314    #[test]
315    fn test_context_rejects_bad_key() {
316        assert!(HttpCryptContext::new("not-base32!").is_err());
317        // Valid base32 but wrong length
318        assert!(HttpCryptContext::new("k4nz984k36xmcynm").is_err());
319    }
320}