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;
const SHORT_KEY_ID_SIZE: usize = 5;
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, pub shared_key: RspamdNM,
}
impl RspamdSecretbox {
pub fn new(key: RspamdNM, nonce: &[u8; NONCE_SIZE]) -> Self {
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,
}
}
pub fn encrypt_in_place(mut self, data: &mut [u8]) -> Tag {
self.enc_ctx.apply_keystream(data);
self.mac_ctx.compute_unpadded(data)
}
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))
}
pub struct HttpCryptContext {
peer_pk: [u8; 32],
key_id_b32: String,
}
impl HttpCryptContext {
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,
})
}
pub fn key_header(&self, local_pk_b32: &str) -> String {
format!("{}={}", self.key_id_b32, local_pk_b32)
}
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);
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,
})
}
}
pub(crate) fn rspamd_x25519_scalarmult_raw(
remote_pk: &[u8; 32],
local_sk: &SecretKey,
) -> Zeroizing<MontgomeryPoint> {
let e = Scalar::from_bytes_mod_order(clamp_integer(local_sk.to_bytes()));
let p = MontgomeryPoint(*remote_pk);
Zeroizing::new(e * p)
}
#[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))
}
pub(crate) fn rspamd_x25519_ecdh(point: Zeroizing<MontgomeryPoint>) -> RspamdNM {
let n0 = Default::default();
Zeroizing::new(hchacha::<R20>(&point.to_bytes().into(), &n0).into())
}
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());
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)
}
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";
#[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()
);
}
#[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());
assert!(HttpCryptContext::new("k4nz984k36xmcynm").is_err());
}
}