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 crypto_box::{SecretKey, aead::{OsRng, AeadCore}, ChaChaBox};
8use crate::error::RspamdError;
9use rspamd_base32::{decode, encode};
10use blake2b_simd::blake2b;
11use chacha20::{cipher::consts::U10, hchacha, cipher::zeroize::Zeroizing, XChaCha20};
12use chacha20::cipher::consts::U64;
13use chacha20::cipher::{KeyIvInit, StreamCipher};
14use poly1305::{Poly1305, Tag};
15use crypto_box::aead::generic_array::{arr, GenericArray, typenum::U32};
16use curve25519_dalek::{MontgomeryPoint, Scalar};
17use curve25519_dalek::scalar::clamp_integer;
18use poly1305::universal_hash::{KeyInit};
19
20/// It must be the same as Rspamd one, that is currently 5
21const SHORT_KEY_ID_SIZE : usize = 5;
22
23pub(crate) type RspamdNM = Zeroizing<GenericArray<u8, U32>>;
24
25pub struct RspamdSecretbox {
26	enc_ctx: XChaCha20,
27	mac_ctx: Poly1305,
28}
29
30pub struct HTTPCryptEncrypted {
31	pub body: Vec<u8>,
32	pub peer_key: String, // Encoded as base32
33	pub shared_key: RspamdNM,
34}
35
36impl RspamdSecretbox {
37	/// Construct new secretbox following Rspamd conventions
38	pub fn new(key: RspamdNM, nonce: chacha20::XNonce) -> Self {
39		// Rspamd does it in a different way, doing full chacha20 round on the extended mac key
40		let mut chacha = XChaCha20::new_from_slices(key.as_slice(),
41													nonce.as_slice()).unwrap();
42		let mut mac_key : GenericArray<u8, U64> = GenericArray::default();
43		chacha.apply_keystream(mac_key.as_mut());
44		let poly = Poly1305::new_from_slice(mac_key.split_at(32).0).unwrap();
45		RspamdSecretbox {
46			enc_ctx: chacha,
47			mac_ctx: poly,
48		}
49	}
50
51	/// Encrypts data in place and returns a tag
52	pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
53		// Encrypt-then-mac
54		self.enc_ctx.apply_keystream(data);
55		self.mac_ctx.compute_unpadded(data)
56	}
57
58	/// Decrypts in place if auth tag is correct
59	pub fn decrypt_in_place(&mut self, data: &mut [u8], tag: &Tag) -> Result<usize, RspamdError> {
60		let computed = self.mac_ctx.clone().compute_unpadded(data);
61		if computed != *tag {
62			return Err(RspamdError::EncryptionError("Authentication failed".to_string()));
63		}
64		self.enc_ctx.apply_keystream(&mut data[..]);
65
66		Ok(computed.len())
67	}
68}
69
70pub fn make_key_header(remote_pk: &str, local_pk: &str) -> Result<String, RspamdError> {
71	let remote_pk = decode(remote_pk)
72		.map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?;
73	let hash = blake2b(remote_pk.as_slice());
74	let hash_b32 = encode(&hash.as_bytes()[0..SHORT_KEY_ID_SIZE]);
75	Ok(format!("{}={}", hash_b32.as_str(), local_pk))
76}
77
78/// Perform a scalar multiplication with a remote public key and a local secret key.
79pub(crate) fn rspamd_x25519_scalarmult(remote_pk: &[u8], local_sk: &SecretKey) -> Result<Zeroizing<MontgomeryPoint>, RspamdError> {
80	let remote_pk: [u8; 32] = decode(remote_pk)
81		.map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?
82		.as_slice().try_into().unwrap();
83	// Do manual scalarmult as Rspamd is using it's own way there
84	let e = Scalar::from_bytes_mod_order(clamp_integer(local_sk.to_bytes()));
85	let p = MontgomeryPoint(remote_pk);
86	Ok(Zeroizing::new(e * p))
87}
88
89/// Unlike IETF version, Rspamd uses an old suggested way to derive a shared secret - it performs
90/// hchacha iteration on the point and a zeroed nonce.
91pub(crate) fn rspamd_x25519_ecdh(point: Zeroizing<MontgomeryPoint>) -> Zeroizing<GenericArray<u8, U32>> {
92	let n0 = arr![u8; 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8, 0u8,];
93	Zeroizing::new(hchacha::<U10>(&point.to_bytes().into(), &n0))
94}
95
96/// Encrypt a plaintext with a given peer public key generating an ephemeral keypair.
97fn encrypt_inplace(
98	plaintext: &[u8],
99	recipient_public_key: &[u8],
100	local_sk: &SecretKey,
101) -> Result<(Vec<u8>, RspamdNM), RspamdError> {
102	let mut dest = Vec::with_capacity(plaintext.len() +
103		24 +
104		poly1305::BLOCK_SIZE);
105	let ec_point = rspamd_x25519_scalarmult(recipient_public_key, local_sk)?;
106	let nm = rspamd_x25519_ecdh(ec_point);
107
108	let nonce = ChaChaBox::generate_nonce(&mut OsRng);
109	let cbox = RspamdSecretbox::new(nm.clone(), nonce);
110	dest.extend_from_slice(nonce.as_slice());
111	// Make room in the buffer for the tag. It needs to be prepended.
112	dest.extend_from_slice(Tag::default().as_slice());
113	let offset = dest.len();
114	dest.extend_from_slice(plaintext);
115	let tag = cbox.encrypt_in_place(&mut dest.as_mut_slice()[offset..]);
116	let tag_dest = &mut <Vec<u8> as AsMut<Vec<u8>>>::as_mut(&mut dest)[nonce.len()..(nonce.len() + poly1305::BLOCK_SIZE)];
117	tag_dest.copy_from_slice(tag.as_slice());
118	Ok((dest, nm))
119}
120
121
122pub fn httpcrypt_encrypt<T, HN, HV>(url: &str, body: &[u8], headers: T, peer_key: &[u8]) -> Result<HTTPCryptEncrypted, RspamdError>
123where T: IntoIterator<Item = (HN, HV)>,
124	  HN: AsRef<[u8]>,
125	  HV: AsRef<[u8]>
126{
127	let local_sk = SecretKey::generate(&mut OsRng);
128	let local_pk = local_sk.public_key();
129	let extra_size = std::mem::size_of::<<ChaChaBox as AeadCore>::NonceSize>() + std::mem::size_of::<<ChaChaBox as AeadCore>::TagSize>();
130	let mut dest = Vec::with_capacity(body.len() + 128 + extra_size);
131
132	// Fill the inner headers
133	dest.extend_from_slice(b"POST ");
134	dest.extend_from_slice(url.as_bytes());
135	dest.extend_from_slice(b" HTTP/1.1\n");
136	for (k, v) in headers {
137		dest.extend_from_slice(k.as_ref());
138		dest.extend_from_slice(b": ");
139		dest.extend_from_slice(v.as_ref());
140		dest.push(b'\n');
141	}
142	dest.extend_from_slice(format!("Content-Length: {}\n\n", body.len()).as_bytes());
143	dest.extend_from_slice(body.as_ref());
144
145	let (encrypted, nm) = encrypt_inplace(dest.as_slice(), peer_key, &local_sk)?;
146
147	Ok(HTTPCryptEncrypted {
148		body: encrypted,
149		peer_key: rspamd_base32::encode(local_pk.as_ref()),
150		shared_key: nm,
151	})
152}
153
154/// Decrypts body using HTTPCrypt algorithm
155pub fn httpcrypt_decrypt(body: &mut [u8], nm: RspamdNM) -> Result<usize, RspamdError> {
156	if body.len() < 24 + poly1305::BLOCK_SIZE {
157		return Err(RspamdError::EncryptionError("Invalid body size".to_string()));
158	}
159
160	let (nonce, remain) = body.split_at_mut(24);
161	let (tag, decrypted_dest) = remain.split_at_mut(poly1305::BLOCK_SIZE);
162	let tag = Tag::from_slice(tag);
163	let mut offset = nonce.len();
164	let mut sbox = RspamdSecretbox::new(nm, *chacha20::XNonce::from_slice(nonce));
165	offset += sbox.decrypt_in_place(decrypted_dest, tag)?;
166	Ok(offset)
167}
168
169#[cfg(test)]
170mod tests {
171	use crate::protocol::encryption::*;
172	const EXPECTED_POINT : [u8; 32] = [95, 76, 225, 188, 0, 26, 146, 94, 70, 249,
173		90, 189, 35, 51, 1, 42, 9, 37, 94, 254, 204, 55, 198, 91, 180, 90,
174		46, 217, 140, 226, 211, 90];
175
176	#[cfg(test)]
177	#[test]
178	fn test_scalarmult() {
179		use crypto_box::{SecretKey};
180		let sk = SecretKey::from_slice(&[0u8; 32]).unwrap();
181		let pk = "k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay";
182		let point = rspamd_x25519_scalarmult(pk.as_bytes(), &sk).unwrap();
183		assert_eq!(point.to_bytes().as_slice(), EXPECTED_POINT);
184	}
185
186	#[cfg(test)]
187	#[test]
188	fn test_ecdh() {
189		const EXPECTED_NM : [u8; 32] = [61, 109, 220, 195, 100, 174, 127, 237, 148,
190			122, 154, 61, 165, 83, 93, 105, 127, 166, 153, 112, 103, 224, 2, 200,
191			136, 243, 73, 51, 8, 163, 150, 7];
192		let point = Zeroizing::new(MontgomeryPoint(EXPECTED_POINT));
193		let nm = rspamd_x25519_ecdh(point);
194		assert_eq!(nm.as_slice(), &EXPECTED_NM);
195	}
196}