use std::collections::BTreeMap;
use crate::cipher;
use crate::cipher::{format_cipher_extfield, parse_cipher_extfield};
use crate::crypto::{self, CryptoPolicy};
use crate::error::{Error, Result};
use crate::etree;
use crate::extfield::EncryptedExtField;
use crate::pbkdf::{PBKDFCache, derive_key};
use crate::pki;
const WRAP_ALG: &str = "aes-256-gcm";
const RECOVERY_HKDF_INFO: &[u8] = b"enprot-recovery-wrap";
const KEM_SHARED_LEN: usize = 32;
#[allow(clippy::too_many_arguments)]
#[tracing::instrument(skip(pt, password, recovery_pub_pems, rng, pbkdfopts, cipheropts, cache, policy), fields(bytes = pt.len(), alg = %cipheropts.alg, recovery_keys = recovery_pub_pems.len()))]
pub fn encrypt(
pt: Vec<u8>,
password: &str,
recovery_pub_pems: &[String],
rng: &mut Option<botan::RandomNumberGenerator>,
pbkdfopts: &etree::PBKDFOptions,
cipheropts: &etree::CipherOptions,
cache: &mut Option<PBKDFCache>,
policy: &dyn CryptoPolicy,
) -> Result<(Vec<u8>, BTreeMap<String, String>)> {
if recovery_pub_pems.is_empty() {
return Err(Error::InvalidArg {
arg: "--recovery-key",
reason: "escrow encrypt: no recovery pubkeys supplied".to_string(),
});
}
if cipheropts.alg.ends_with("-det") {
return Err(Error::InvalidArg {
arg: "--recovery-key",
reason: format!(
"recovery mode is incompatible with {} — the fresh CEK breaks the \
deterministic (same-input → same-output, CAS-dedup) contract",
cipheropts.alg
),
});
}
policy
.check_cipher_alg(&cipheropts.alg)
.map_err(Error::Policy)?;
policy.check_cipher_alg(WRAP_ALG).map_err(Error::Policy)?;
let mut wrap_enc = cipher::encryption(WRAP_ALG)?;
let wrap_key_len = wrap_enc.key_len_max();
let enc = cipher::encryption(&cipheropts.alg)?;
let payload_key_len = enc.key_len_max();
let cek = rng
.as_mut()
.ok_or_else(|| Error::InvalidArg {
arg: "rng",
reason: "Missing RNG for escrow encrypt".to_string(),
})?
.read(payload_key_len)
.map_err(Error::botan)?;
let mut extfields: BTreeMap<String, String> = BTreeMap::new();
let (key, iv) =
crate::prot::compute_iv(cipheropts, &cek, &pt, payload_key_len, enc.nonce_len(), rng)?;
if !cipheropts.alg.starts_with("aes-256-siv") {
EncryptedExtField::Cipher(format_cipher_extfield(&cipheropts.alg, &iv)?)
.insert_into(&mut extfields);
}
policy
.check_cipher(&cipheropts.alg, &key, &iv, &[])
.map_err(Error::Policy)?;
let pt_final = crate::prot::apply_compression(pt, cipheropts.compress, &mut extfields)?;
let mut enc = enc;
let ct = enc.process(&key, &iv, &[], &pt_final)?;
let (kek, pbkdf) = derive_key(password, wrap_key_len, rng, pbkdfopts, cache, policy)?;
if let Some(p) = pbkdf {
EncryptedExtField::Pbkdf(p).insert_into(&mut extfields);
}
let wrapped = gcm_seal(
&mut wrap_enc,
&kek,
&cek,
rng.as_mut().ok_or_else(|| Error::InvalidArg {
arg: "rng",
reason: "Missing RNG for escrow encrypt".to_string(),
})?,
)?;
EncryptedExtField::PwWrap(crate::utils::base64_encode(&wrapped)?).insert_into(&mut extfields);
let mut fps: Vec<String> = Vec::with_capacity(recovery_pub_pems.len());
for pub_pem in recovery_pub_pems {
let (shared, kem_ct) = pki::kem_encapsulate(
pub_pem,
KEM_SHARED_LEN,
rng.as_mut().ok_or_else(|| Error::InvalidArg {
arg: "rng",
reason: "Missing RNG for escrow encrypt".to_string(),
})?,
)?;
let wrap_key = crypto::hkdf_sha256(&shared, RECOVERY_HKDF_INFO, wrap_key_len)?;
let wrapped = gcm_seal(
&mut wrap_enc,
&wrap_key,
&cek,
rng.as_mut().ok_or_else(|| Error::InvalidArg {
arg: "rng",
reason: "Missing RNG for escrow encrypt".to_string(),
})?,
)?;
let fp = crate::capability::KeyFp::from_pem(pub_pem)?.to_hex();
fps.push(format!("mlkem:{}", fp));
extfields.insert(
format!("recovery-kem-mlkem-{}", fp),
crate::utils::base64_encode(&kem_ct)?,
);
extfields.insert(
format!("recovery-wrap-mlkem-{}", fp),
crate::utils::base64_encode(&wrapped)?,
);
}
EncryptedExtField::Recovery(fps.join(",")).insert_into(&mut extfields);
Ok((ct, extfields))
}
pub fn decrypt_with_password(
ct: Vec<u8>,
password: &str,
extfields: &BTreeMap<String, String>,
cache: &mut Option<PBKDFCache>,
policy: &dyn CryptoPolicy,
) -> Result<Vec<u8>> {
let pw_wrap = extfields.get("pw-wrap").ok_or_else(|| Error::Extfield {
field: "pw-wrap",
reason: "escrow block has no pw-wrap field; the password path is unavailable".to_string(),
})?;
let wrapped = crate::utils::base64_decode(pw_wrap)?;
let wrap_key_len = cipher::encryption(WRAP_ALG)?.key_len_max();
let kek = crate::prot::derive_decrypt_key(
password,
extfields.get("pbkdf").map(|s| s.as_str()),
wrap_key_len,
cache,
policy,
)?;
let cek = gcm_open(&kek, &wrapped)?;
finish(ct, cek, extfields, policy)
}
pub fn decrypt_with_key(
ct: Vec<u8>,
priv_pem: &str,
extfields: &BTreeMap<String, String>,
policy: &dyn CryptoPolicy,
) -> Result<Vec<u8>> {
let botan_priv = botan::Privkey::load_pem(priv_pem).map_err(Error::botan)?;
let pub_pem = botan_priv
.pubkey()
.map_err(Error::botan)?
.pem_encode()
.map_err(Error::botan)?;
let fp = crate::capability::KeyFp::from_pem(&pub_pem)?.to_hex();
let kem_ct = crate::utils::base64_decode(
extfields
.get(&format!("recovery-kem-mlkem-{}", fp))
.ok_or_else(|| Error::InvalidArg {
arg: "key-file",
reason: format!("no recovery entry for fingerprint {fp} in this block"),
})?,
)?;
let wrapped = crate::utils::base64_decode(
extfields
.get(&format!("recovery-wrap-mlkem-{}", fp))
.ok_or_else(|| Error::Extfield {
field: "recovery-wrap",
reason: format!("recovery KEM entry for {fp} present but its CEK wrap is not"),
})?,
)?;
let shared = pki::kem_decapsulate(priv_pem, &kem_ct, KEM_SHARED_LEN)?;
let wrap_key_len = cipher::encryption(WRAP_ALG)?.key_len_max();
let wrap_key = crypto::hkdf_sha256(&shared, RECOVERY_HKDF_INFO, wrap_key_len)?;
let cek = gcm_open(&wrap_key, &wrapped)?;
finish(ct, cek, extfields, policy)
}
fn finish(
ct: Vec<u8>,
cek: Vec<u8>,
extfields: &BTreeMap<String, String>,
policy: &dyn CryptoPolicy,
) -> Result<Vec<u8>> {
let (cipher_alg, iv) = match extfields.get("cipher").map(|s| s.as_str()) {
Some(s) => parse_cipher_extfield(s)?,
None => (cipher::DEFAULT_CIPHER_ALG.to_string(), Vec::new()),
};
policy
.check_cipher_alg(&cipher_alg)
.map_err(Error::Policy)?;
let dec = cipher::decryption(&cipher_alg)?;
let key_len = dec.key_len_max();
if cek.len() != key_len {
return Err(Error::AeadFailed {
alg: WRAP_ALG,
op: "decrypt",
});
}
let key = crate::prot::recover_key(&cipher_alg, cek, key_len)?;
policy
.check_cipher(&cipher_alg, &key, &iv, &[])
.map_err(Error::Policy)?;
let mut dec = dec;
let pt = dec.process(&key, &iv, &[], &ct)?;
if extfields.get("compress").map(|s| s.as_str()) == Some(crate::compress::COMPRESS_EXTFIELD) {
crate::compress::decompress(&pt)
} else {
Ok(pt)
}
}
fn gcm_seal(
enc: &mut Box<dyn cipher::SymmetricCipher>,
key: &[u8],
data: &[u8],
rng: &mut botan::RandomNumberGenerator,
) -> Result<Vec<u8>> {
let iv = rng.read(enc.nonce_len()).map_err(Error::botan)?;
let ct = enc.process(key, &iv, &[], data)?;
let mut blob = iv;
blob.extend_from_slice(&ct);
Ok(blob)
}
fn gcm_open(key: &[u8], blob: &[u8]) -> Result<Vec<u8>> {
let mut dec = cipher::decryption(WRAP_ALG)?;
let nonce_len = dec.nonce_len();
if blob.len() < nonce_len {
return Err(Error::AeadFailed {
alg: WRAP_ALG,
op: "decrypt",
});
}
dec.process(key, &blob[..nonce_len], &[], &blob[nonce_len..])
.map_err(|_| Error::AeadFailed {
alg: WRAP_ALG,
op: "decrypt",
})
}
#[cfg(test)]
mod tests {
use super::*;
fn paops_defaults() -> (etree::PBKDFOptions, etree::CipherOptions) {
let policy = crate::crypto::default_policy();
let mut cipheropts = etree::CipherOptions::new(&*policy);
cipheropts.alg = "aes-256-siv".to_string();
(etree::PBKDFOptions::new(&*policy), cipheropts)
}
fn rng() -> Option<botan::RandomNumberGenerator> {
Some(botan::RandomNumberGenerator::new_system().unwrap())
}
fn keypair() -> (String, String) {
let mut r = botan::RandomNumberGenerator::new_system().unwrap();
pki::kem_keygen(pki::KemAlgKind::MlKem, &mut r).unwrap()
}
#[test]
fn roundtrip_via_password_and_via_recovery_key() {
let (pbkdfopts, cipheropts) = paops_defaults();
let (rec_priv, rec_pub) = keypair();
let policy = crate::crypto::default_policy();
let pt = b"escrow roundtrip".to_vec();
let (ct, ext) = encrypt(
pt.clone(),
"pw",
&[rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
assert!(ext.contains_key("recovery"));
assert!(ext.contains_key("pw-wrap"));
let via_pw = decrypt_with_password(ct.clone(), "pw", &ext, &mut None, &*policy).unwrap();
let via_key = decrypt_with_key(ct, &rec_priv, &ext, &*policy).unwrap();
assert_eq!(via_pw, pt);
assert_eq!(via_key, pt);
}
#[test]
fn wrong_password_fails_cleanly() {
let (pbkdfopts, cipheropts) = paops_defaults();
let (_, rec_pub) = keypair();
let policy = crate::crypto::default_policy();
let (ct, ext) = encrypt(
b"secret".to_vec(),
"pw",
&[rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
let err = decrypt_with_password(ct, "wrong", &ext, &mut None, &*policy).unwrap_err();
assert!(matches!(err, Error::AeadFailed { .. }), "got {err:?}");
}
#[test]
fn any_one_of_multiple_recovery_keys_suffices() {
let (pbkdfopts, cipheropts) = paops_defaults();
let (k1_priv, k1_pub) = keypair();
let (k2_priv, k2_pub) = keypair();
let policy = crate::crypto::default_policy();
let (ct, ext) = encrypt(
b"multi".to_vec(),
"pw",
&[k1_pub, k2_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
assert!(decrypt_with_key(ct.clone(), &k1_priv, &ext, &*policy).is_ok());
assert!(decrypt_with_key(ct, &k2_priv, &ext, &*policy).is_ok());
}
#[test]
fn det_cipher_refused() {
let (pbkdfopts, _) = paops_defaults();
let cipheropts = etree::CipherOptions {
alg: "aes-256-gcm-det".to_string(),
..paops_defaults().1
};
let (_, rec_pub) = keypair();
let policy = crate::crypto::default_policy();
let err = encrypt(
b"x".to_vec(),
"pw",
&[rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap_err();
assert!(err.to_string().contains("incompatible"), "got {err}");
}
}