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