1use 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
22const SHORT_KEY_ID_SIZE: usize = 5;
24
25const 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, pub shared_key: RspamdNM,
39}
40
41impl RspamdSecretbox {
42 pub fn new(key: RspamdNM, nonce: &[u8; NONCE_SIZE]) -> Self {
44 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 pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
57 self.enc_ctx.apply_keystream(data);
59 self.mac_ctx.compute_unpadded(data)
60 }
61
62 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
84pub struct HttpCryptContext {
91 peer_pk: [u8; 32],
93 key_id_b32: String,
95}
96
97impl HttpCryptContext {
98 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 pub fn key_header(&self, local_pk_b32: &str) -> String {
116 format!("{}={}", self.key_id_b32, local_pk_b32)
117 }
118
119 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 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
161pub(crate) fn rspamd_x25519_scalarmult_raw(
163 remote_pk: &[u8; 32],
164 local_sk: &SecretKey,
165) -> Zeroizing<MontgomeryPoint> {
166 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#[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
186pub(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
193fn 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 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
232pub 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 #[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 #[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 assert!(HttpCryptContext::new("k4nz984k36xmcynm").is_err());
319 }
320}