rspamd-client 0.7.0

Rspamd client API
Documentation
//! This module contains encryption functions used by the HTTPCrypt protocol.
//! This encryption is common to x25519 and chacha20-poly1305 as defined in RFC 8439.
//! However, since HTTPCrypt has been designed before RFC being published, it uses
//! a different way to do KEX and to derive shared keys.
//! In general, it relies on hchacha20 for kdf and x25519 for key exchange.

use crate::error::RspamdError;
use blake2b_simd::blake2b;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use chacha20::{hchacha, XChaCha20, R20};
use crypto_box::{
    aead::{AeadCore, OsRng},
    ChaChaBox, SecretKey,
};
use curve25519_dalek::scalar::clamp_integer;
use curve25519_dalek::{MontgomeryPoint, Scalar};
use poly1305::universal_hash::KeyInit;
use poly1305::{Poly1305, Tag};
use rspamd_base32::{decode, encode};
use zeroize::Zeroizing;

/// It must be the same as Rspamd one, that is currently 5
const SHORT_KEY_ID_SIZE: usize = 5;

/// XChaCha20 nonce size in bytes
const NONCE_SIZE: usize = 24;

pub(crate) type RspamdNM = Zeroizing<[u8; 32]>;

pub struct RspamdSecretbox {
    enc_ctx: XChaCha20,
    mac_ctx: Poly1305,
}

pub struct HTTPCryptEncrypted {
    pub body: Vec<u8>,
    pub peer_key: String, // Encoded as base32
    pub shared_key: RspamdNM,
}

impl RspamdSecretbox {
    /// Construct new secretbox following Rspamd conventions
    pub fn new(key: RspamdNM, nonce: &[u8; NONCE_SIZE]) -> Self {
        // Rspamd does it in a different way, doing full chacha20 round on the extended mac key
        let mut chacha = XChaCha20::new_from_slices(key.as_slice(), nonce.as_slice()).unwrap();
        let mut mac_key = Zeroizing::new([0u8; 64]);
        chacha.apply_keystream(mac_key.as_mut());
        let poly = Poly1305::new_from_slice(&mac_key[..32]).unwrap();
        RspamdSecretbox {
            enc_ctx: chacha,
            mac_ctx: poly,
        }
    }

    /// Encrypts data in place and returns a tag
    pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
        // Encrypt-then-mac
        self.enc_ctx.apply_keystream(data);
        self.mac_ctx.compute_unpadded(data)
    }

    /// Decrypts in place if auth tag is correct
    pub fn decrypt_in_place(&mut self, data: &mut [u8], tag: &Tag) -> Result<usize, RspamdError> {
        let computed = self.mac_ctx.clone().compute_unpadded(data);
        if computed != *tag {
            return Err(RspamdError::EncryptionError(
                "Authentication failed".to_string(),
            ));
        }
        self.enc_ctx.apply_keystream(&mut data[..]);

        Ok(computed.len())
    }
}

pub fn make_key_header(remote_pk: &str, local_pk: &str) -> Result<String, RspamdError> {
    let remote_pk = decode(remote_pk)
        .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?;
    let hash = blake2b(remote_pk.as_slice());
    let hash_b32 = encode(&hash.as_bytes()[0..SHORT_KEY_ID_SIZE]);
    Ok(format!("{}={}", hash_b32.as_str(), local_pk))
}

/// Precomputed HTTPCrypt state for a fixed destination keypair.
///
/// Decoding the base32 peer public key and deriving the short key id used in
/// the `Key` header depend only on the destination key, so they are done once
/// at construction and shared by every request. Everything derived from
/// ephemeral keys (scalarmult, shared secret, nonce) stays per-request.
pub struct HttpCryptContext {
    /// Decoded remote public key
    peer_pk: [u8; 32],
    /// Base32-encoded blake2b short id of the peer key, the `Key` header prefix
    key_id_b32: String,
}

impl HttpCryptContext {
    /// Parse a base32-encoded remote public key into a reusable context.
    pub fn new(peer_key_b32: &str) -> Result<Self, RspamdError> {
        let decoded = decode(peer_key_b32)
            .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?;
        let peer_pk: [u8; 32] = decoded
            .as_slice()
            .try_into()
            .map_err(|_| RspamdError::EncryptionError("Invalid public key length".to_string()))?;
        let hash = blake2b(peer_pk.as_slice());
        let key_id_b32 = encode(&hash.as_bytes()[0..SHORT_KEY_ID_SIZE]);
        Ok(Self {
            peer_pk,
            key_id_b32,
        })
    }

    /// Build the `Key` header value for an ephemeral local public key.
    pub fn key_header(&self, local_pk_b32: &str) -> String {
        format!("{}={}", self.key_id_b32, local_pk_b32)
    }

    /// Encrypt a request for the destination this context was built for,
    /// generating a fresh ephemeral keypair.
    pub fn encrypt<T, HN, HV>(
        &self,
        url: &str,
        body: &[u8],
        headers: T,
    ) -> Result<HTTPCryptEncrypted, RspamdError>
    where
        T: IntoIterator<Item = (HN, HV)>,
        HN: AsRef<[u8]>,
        HV: AsRef<[u8]>,
    {
        let local_sk = SecretKey::generate(&mut OsRng);
        let local_pk = local_sk.public_key();
        let extra_size = std::mem::size_of::<<ChaChaBox as AeadCore>::NonceSize>()
            + std::mem::size_of::<<ChaChaBox as AeadCore>::TagSize>();
        let mut dest = Vec::with_capacity(body.len() + 128 + extra_size);

        // Fill the inner headers
        dest.extend_from_slice(b"POST ");
        dest.extend_from_slice(url.as_bytes());
        dest.extend_from_slice(b" HTTP/1.1\n");
        for (k, v) in headers {
            dest.extend_from_slice(k.as_ref());
            dest.extend_from_slice(b": ");
            dest.extend_from_slice(v.as_ref());
            dest.push(b'\n');
        }
        dest.extend_from_slice(format!("Content-Length: {}\n\n", body.len()).as_bytes());
        dest.extend_from_slice(body.as_ref());

        let (encrypted, nm) = encrypt_inplace(dest.as_slice(), &self.peer_pk, &local_sk)?;

        Ok(HTTPCryptEncrypted {
            body: encrypted,
            peer_key: rspamd_base32::encode(local_pk.as_ref()),
            shared_key: nm,
        })
    }
}

/// Scalar multiplication with an already decoded remote public key.
pub(crate) fn rspamd_x25519_scalarmult_raw(
    remote_pk: &[u8; 32],
    local_sk: &SecretKey,
) -> Zeroizing<MontgomeryPoint> {
    // Do manual scalarmult as Rspamd is using it's own way there
    let e = Scalar::from_bytes_mod_order(clamp_integer(local_sk.to_bytes()));
    let p = MontgomeryPoint(*remote_pk);
    Zeroizing::new(e * p)
}

/// Perform a scalar multiplication with a base32-encoded remote public key and a local secret key.
#[cfg(test)]
pub(crate) fn rspamd_x25519_scalarmult(
    remote_pk: &[u8],
    local_sk: &SecretKey,
) -> Result<Zeroizing<MontgomeryPoint>, RspamdError> {
    let remote_pk: [u8; 32] = decode(remote_pk)
        .map_err(|_| RspamdError::EncryptionError("Base32 decode failed".to_string()))?
        .as_slice()
        .try_into()
        .map_err(|_| RspamdError::EncryptionError("Invalid public key length".to_string()))?;
    Ok(rspamd_x25519_scalarmult_raw(&remote_pk, local_sk))
}

/// Unlike IETF version, Rspamd uses an old suggested way to derive a shared secret - it performs
/// hchacha iteration on the point and a zeroed nonce.
pub(crate) fn rspamd_x25519_ecdh(point: Zeroizing<MontgomeryPoint>) -> RspamdNM {
    let n0 = Default::default();
    Zeroizing::new(hchacha::<R20>(&point.to_bytes().into(), &n0).into())
}

/// Encrypt a plaintext with a decoded peer public key and a local secret key.
fn encrypt_inplace(
    plaintext: &[u8],
    recipient_public_key: &[u8; 32],
    local_sk: &SecretKey,
) -> Result<(Vec<u8>, RspamdNM), RspamdError> {
    let mut dest = Vec::with_capacity(plaintext.len() + NONCE_SIZE + poly1305::BLOCK_SIZE);
    let ec_point = rspamd_x25519_scalarmult_raw(recipient_public_key, local_sk);
    let nm = rspamd_x25519_ecdh(ec_point);

    let nonce: [u8; NONCE_SIZE] = ChaChaBox::generate_nonce(&mut OsRng).into();
    let cbox = RspamdSecretbox::new(nm.clone(), &nonce);
    dest.extend_from_slice(nonce.as_slice());
    // Make room in the buffer for the tag. It needs to be prepended.
    dest.extend_from_slice(Tag::default().as_slice());
    let offset = dest.len();
    dest.extend_from_slice(plaintext);
    let tag = cbox.encrypt_in_place(&mut dest.as_mut_slice()[offset..]);
    let tag_dest = &mut <Vec<u8> as AsMut<Vec<u8>>>::as_mut(&mut dest)
        [nonce.len()..(nonce.len() + poly1305::BLOCK_SIZE)];
    tag_dest.copy_from_slice(tag.as_slice());
    Ok((dest, nm))
}

pub fn httpcrypt_encrypt<T, HN, HV>(
    url: &str,
    body: &[u8],
    headers: T,
    peer_key: &[u8],
) -> Result<HTTPCryptEncrypted, RspamdError>
where
    T: IntoIterator<Item = (HN, HV)>,
    HN: AsRef<[u8]>,
    HV: AsRef<[u8]>,
{
    let ctx = HttpCryptContext::new(std::str::from_utf8(peer_key)?)?;
    ctx.encrypt(url, body, headers)
}

/// Decrypts body using HTTPCrypt algorithm
pub fn httpcrypt_decrypt(body: &mut [u8], nm: RspamdNM) -> Result<usize, RspamdError> {
    if body.len() < NONCE_SIZE + poly1305::BLOCK_SIZE {
        return Err(RspamdError::EncryptionError(
            "Invalid body size".to_string(),
        ));
    }

    let (nonce, remain) = body.split_at_mut(NONCE_SIZE);
    let (tag, decrypted_dest) = remain.split_at_mut(poly1305::BLOCK_SIZE);
    let tag = Tag::try_from(&*tag)
        .map_err(|_| RspamdError::EncryptionError("Invalid tag size".to_string()))?;
    let mut offset = nonce.len();
    let nonce: &[u8; NONCE_SIZE] = (&*nonce).try_into().unwrap();
    let mut sbox = RspamdSecretbox::new(nm, nonce);
    offset += sbox.decrypt_in_place(decrypted_dest, &tag)?;
    Ok(offset)
}

#[cfg(test)]
mod tests {
    use crate::protocol::encryption::*;
    const EXPECTED_POINT: [u8; 32] = [
        95, 76, 225, 188, 0, 26, 146, 94, 70, 249, 90, 189, 35, 51, 1, 42, 9, 37, 94, 254, 204, 55,
        198, 91, 180, 90, 46, 217, 140, 226, 211, 90,
    ];

    #[cfg(test)]
    #[test]
    fn test_scalarmult() {
        use crypto_box::SecretKey;
        let sk = SecretKey::from_slice(&[0u8; 32]).unwrap();
        let pk = "k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay";
        let point = rspamd_x25519_scalarmult(pk.as_bytes(), &sk).unwrap();
        assert_eq!(point.to_bytes().as_slice(), EXPECTED_POINT);
    }

    #[cfg(test)]
    #[test]
    fn test_ecdh() {
        const EXPECTED_NM: [u8; 32] = [
            61, 109, 220, 195, 100, 174, 127, 237, 148, 122, 154, 61, 165, 83, 93, 105, 127, 166,
            153, 112, 103, 224, 2, 200, 136, 243, 73, 51, 8, 163, 150, 7,
        ];
        let point = Zeroizing::new(MontgomeryPoint(EXPECTED_POINT));
        let nm = rspamd_x25519_ecdh(point);
        assert_eq!(nm.as_slice(), &EXPECTED_NM);
    }

    const PEER_PK_B32: &str = "k4nz984k36xmcynm1hr9kdbn6jhcxf4ggbrb1quay7f88rpm9kay";

    /// The precomputed context must produce the same `Key` header as the
    /// legacy per-request helper.
    #[test]
    fn test_context_key_header_matches_legacy() {
        let ctx = HttpCryptContext::new(PEER_PK_B32).unwrap();
        let local_pk = "oqqm9kkt7c1ws638cyf41apar3in1wuyx647gzrx88hhd94ehm3y";
        assert_eq!(
            ctx.key_header(local_pk),
            make_key_header(PEER_PK_B32, local_pk).unwrap()
        );
    }

    /// Encrypting through the context and decrypting with the returned shared
    /// key must round-trip the inner request.
    #[test]
    fn test_context_encrypt_decrypt_roundtrip() {
        let ctx = HttpCryptContext::new(PEER_PK_B32).unwrap();
        let body = b"test message body";
        let headers = [("From", "user@example.com")];
        let encrypted = ctx.encrypt("/checkv2", body, headers).unwrap();

        let mut encrypted_body = encrypted.body;
        let offset = httpcrypt_decrypt(encrypted_body.as_mut_slice(), encrypted.shared_key)
            .expect("decryption with the shared key must succeed");
        let plaintext = &encrypted_body[offset..];
        let plaintext_str = std::str::from_utf8(plaintext).unwrap();
        assert!(plaintext_str.starts_with("POST /checkv2 HTTP/1.1\n"));
        assert!(plaintext_str.contains("From: user@example.com\n"));
        assert!(plaintext_str.ends_with("test message body"));
    }

    #[test]
    fn test_context_rejects_bad_key() {
        assert!(HttpCryptContext::new("not-base32!").is_err());
        // Valid base32 but wrong length
        assert!(HttpCryptContext::new("k4nz984k36xmcynm").is_err());
    }
}