use std::collections::BTreeMap;
use crate::cipher;
use crate::error::{Error, Result};
use crate::pki;
pub fn encrypt(
pt: Vec<u8>,
recipient_pub_pems: &[String],
cipher_alg: &str,
rng: &mut botan::RandomNumberGenerator,
) -> Result<(Vec<u8>, BTreeMap<String, String>)> {
if recipient_pub_pems.is_empty() {
return Err(Error::msg(
"multi-recipient encrypt: no recipients supplied",
));
}
let enc = cipher::encryption(cipher_alg)?;
let key_len = enc.key_len_max();
let mut recipient_fps: Vec<String> = Vec::with_capacity(recipient_pub_pems.len());
let mut shared_seed: Option<Vec<u8>> = None;
let mut per_recipient_ct: Vec<(String, Vec<u8>)> = Vec::new();
for pub_pem in recipient_pub_pems {
let (shared, kem_ct) = pki::kem_encapsulate(pub_pem, 32, rng)?;
match &shared_seed {
None => shared_seed = Some(shared.clone()),
Some(existing) if existing != &shared => {
return Err(Error::msg(
"ML-KEM produced different shared secrets for different recipients; \
this indicates the recipient pubkeys are not for the same group key \
(local-files mode requires all recipients to share a group key — \
Confium handles per-recipient threshold coordination)",
));
}
_ => {}
}
let fp = crate::capability::KeyFp::from_pem(pub_pem)?;
recipient_fps.push(format!("mlkem:{}", fp.to_hex()));
per_recipient_ct.push((fp.to_hex(), kem_ct));
}
let shared_seed = shared_seed.unwrap();
let aes_key = crate::crypto::hkdf_sha256(&shared_seed, b"enprot-kemenc", key_len)?;
let iv_len = enc.nonce_len();
let iv = rng.read(iv_len).map_err(Error::botan)?;
let mut enc = enc;
let ct = enc.process(&aes_key, &iv, &[], &pt)?;
let mut extfields: BTreeMap<String, String> = BTreeMap::new();
extfields.insert("recipients".into(), recipient_fps.join(","));
for (fp, kem_ct) in per_recipient_ct {
extfields.insert(
format!("recipient-mlkem-{}", fp),
crate::utils::base64_encode(&kem_ct)?,
);
}
extfields.insert(
"cipher".into(),
crate::cipher::format_cipher_extfield(cipher_alg, &iv)?,
);
Ok((ct, extfields))
}
pub fn decrypt(ct: &[u8], priv_pem: &str, extfields: &BTreeMap<String, String>) -> Result<Vec<u8>> {
let botan_priv = botan::Privkey::load_pem(priv_pem).map_err(Error::botan)?;
let botan_pub = botan_priv.pubkey().map_err(Error::botan)?;
let pub_pem = botan_pub.pem_encode().map_err(Error::botan)?;
let priv_fp = crate::capability::KeyFp::from_pem(&pub_pem)?;
let field = format!("recipient-mlkem-{}", priv_fp.to_hex());
let kem_ct_b64 = extfields.get(&field).ok_or_else(|| {
Error::msg(format!(
"no recipient entry for fingerprint {} in this Encrypted block",
priv_fp
))
})?;
let kem_ct = crate::utils::base64_decode(kem_ct_b64)
.map_err(|e| Error::msg(format!("invalid base64 in recipient ct: {e}")))?;
let cipher_str = extfields
.get("cipher")
.ok_or_else(|| Error::msg("Encrypted block missing 'cipher' field"))?;
let (cipher_alg, iv) = crate::cipher::parse_cipher_extfield(cipher_str)?;
let key_len = cipher::decryption(&cipher_alg)?.key_len_max();
let shared_seed = pki::kem_decapsulate(priv_pem, &kem_ct, 32)?;
let aes_key = crate::crypto::hkdf_sha256(&shared_seed, b"enprot-kemenc", key_len)?;
let mut dec = cipher::decryption(&cipher_alg)?;
dec.process(&aes_key, &iv, &[], ct)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pki::KemAlgKind;
fn recipient_keypair() -> (String, String) {
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let (priv_pem, pub_pem) = pki::kem_keygen(KemAlgKind::MlKem, &mut rng).unwrap();
(priv_pem, pub_pem)
}
#[test]
fn single_recipient_round_trip() {
let (priv_pem, pub_pem) = recipient_keypair();
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let pt = b"hello world";
let (ct, ext) = encrypt(
pt.to_vec(),
std::slice::from_ref(&pub_pem),
"aes-256-siv",
&mut rng,
)
.unwrap();
let recovered = decrypt(&ct, &priv_pem, &ext).unwrap();
assert_eq!(recovered, pt);
}
#[test]
fn multi_recipient_distinct_pubkeys_documented_constraint() {
let (_priv1, pub1) = recipient_keypair();
let (_priv2, pub2) = recipient_keypair();
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let pt = b"shared secret";
let result = encrypt(pt.to_vec(), &[pub1, pub2], "aes-256-siv", &mut rng);
assert!(result.is_err(), "distinct pubkeys must error in local mode");
}
#[test]
fn round_trip_with_single_recipient_works() {
let (priv_pem, pub_pem) = recipient_keypair();
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let pt = b"round trip payload";
let (ct, ext) = encrypt(pt.to_vec(), &[pub_pem], "aes-256-siv", &mut rng).unwrap();
let recovered = decrypt(&ct, &priv_pem, &ext).unwrap();
assert_eq!(recovered, pt);
}
#[test]
fn decrypt_with_wrong_privkey_fails() {
let (_priv1, pub1) = recipient_keypair();
let (priv2, _pub2) = recipient_keypair();
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let pt = b"hello";
let (ct, ext) = encrypt(pt.to_vec(), &[pub1], "aes-256-siv", &mut rng).unwrap();
assert!(decrypt(&ct, &priv2, &ext).is_err());
}
#[test]
fn no_recipients_errors() {
let mut rng = botan::RandomNumberGenerator::new_system().unwrap();
let result = encrypt(b"x".to_vec(), &[], "aes-256-siv", &mut rng);
assert!(result.is_err());
}
}