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 is_escrow_block(extfields: &std::collections::BTreeMap<String, String>) -> bool {
crate::extfield::EncryptedExtFields::from_map(extfields).is_recovery_mode()
}
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 unwrap_cek_with_password(
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,
)?;
gcm_open(&kek, &wrapped)
}
fn unwrap_cek_with_key(priv_pem: &str, extfields: &BTreeMap<String, String>) -> 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)?;
gcm_open(&wrap_key, &wrapped)
}
#[allow(clippy::too_many_arguments)]
pub fn rotate(
extfields: &BTreeMap<String, String>,
old_password: Option<&str>,
old_recovery_priv_pem: Option<&str>,
new_password: &str,
new_recovery_pub_pems: &[String],
rng: &mut Option<botan::RandomNumberGenerator>,
pbkdfopts: &etree::PBKDFOptions,
cache: &mut Option<PBKDFCache>,
policy: &dyn CryptoPolicy,
) -> Result<BTreeMap<String, String>> {
if old_password.is_none() && old_recovery_priv_pem.is_none() {
return Err(Error::InvalidArg {
arg: "--old-password",
reason: "rotate: supply the current password (-k) or a current recovery \
privkey (--key-file) to unwrap the CEK"
.to_string(),
});
}
if new_recovery_pub_pems.is_empty() {
return Err(Error::InvalidArg {
arg: "--recovery-key",
reason: "rotate: supply at least one --recovery-key for the new wrap \
(escrow mode requires a recovery path)"
.to_string(),
});
}
let cek = if let Some(pw) = old_password {
unwrap_cek_with_password(pw, extfields, cache, policy)?
} else {
unwrap_cek_with_key(old_recovery_priv_pem.expect("checked above"), extfields)?
};
let mut out: BTreeMap<String, String> = BTreeMap::new();
for key in ["cipher", "compress"] {
if let Some(v) = extfields.get(key) {
out.insert(key.to_string(), v.clone());
}
}
let wrap_key_len = cipher::encryption(WRAP_ALG)?.key_len_max();
let (kek, pbkdf) = derive_key(new_password, wrap_key_len, rng, pbkdfopts, cache, policy)?;
if let Some(p) = pbkdf {
EncryptedExtField::Pbkdf(p).insert_into(&mut out);
}
let mut wrap_enc = cipher::encryption(WRAP_ALG)?;
let wrapped = gcm_seal(&mut wrap_enc, &kek, &cek, need_rng(rng)?)?;
EncryptedExtField::PwWrap(crate::utils::base64_encode(&wrapped)?).insert_into(&mut out);
let mut fps: Vec<String> = Vec::with_capacity(new_recovery_pub_pems.len());
for pub_pem in new_recovery_pub_pems {
let (shared, kem_ct) = pki::kem_encapsulate(pub_pem, KEM_SHARED_LEN, need_rng(rng)?)?;
let wrap_key = crypto::hkdf_sha256(&shared, RECOVERY_HKDF_INFO, wrap_key_len)?;
let wrapped = gcm_seal(&mut wrap_enc, &wrap_key, &cek, need_rng(rng)?)?;
let fp = crate::capability::KeyFp::from_pem(pub_pem)?.to_hex();
fps.push(format!("mlkem:{}", fp));
out.insert(
format!("recovery-kem-mlkem-{}", fp),
crate::utils::base64_encode(&kem_ct)?,
);
out.insert(
format!("recovery-wrap-mlkem-{}", fp),
crate::utils::base64_encode(&wrapped)?,
);
}
EncryptedExtField::Recovery(fps.join(",")).insert_into(&mut out);
Ok(out)
}
fn need_rng(
rng: &mut Option<botan::RandomNumberGenerator>,
) -> Result<&mut botan::RandomNumberGenerator> {
rng.as_mut().ok_or_else(|| Error::InvalidArg {
arg: "rng",
reason: "Missing RNG for escrow rotate".to_string(),
})
}
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 is_escrow_block_detects_mode() {
let policy = crate::crypto::default_policy();
let (pbkdfopts, cipheropts) = paops_defaults();
let (_, rec_pub) = keypair();
let (_ct, ext) = encrypt(
b"x".to_vec(),
"pw",
&[rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
assert!(is_escrow_block(&ext));
let legacy = std::collections::BTreeMap::from([(
"pbkdf".to_string(),
"$argon2$m=1,p=1,t=1$AAAA".to_string(),
)]);
assert!(!is_escrow_block(&legacy));
}
#[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 rotate_preserves_payload_and_both_paths_work() {
let (pbkdfopts, cipheropts) = paops_defaults();
let (old_rec_priv, old_rec_pub) = keypair();
let (new_rec_priv, new_rec_pub) = keypair();
let policy = crate::crypto::default_policy();
let pt = b"rotated secret".to_vec();
let (ct, ext) = encrypt(
pt.clone(),
"old-password",
&[old_rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
let ext2 = rotate(
&ext,
Some("old-password"),
None,
"new-password",
&[new_rec_pub],
&mut rng(),
&pbkdfopts,
&mut None,
&*policy,
)
.unwrap();
assert!(
!ext2.contains_key("recovery-kem-mlkem-"),
"old recovery entries must not leak" );
let via_new_pw =
decrypt_with_password(ct.clone(), "new-password", &ext2, &mut None, &*policy).unwrap();
assert_eq!(via_new_pw, pt);
let via_new_key = decrypt_with_key(ct, &new_rec_priv, &ext2, &*policy).unwrap();
assert_eq!(via_new_key, pt);
let err = decrypt_with_password(pt.clone(), "old-password", &ext2, &mut None, &*policy);
assert!(err.is_err());
let err2 = decrypt_with_key(pt, &old_rec_priv, &ext2, &*policy);
assert!(err2.is_err());
}
#[test]
fn rotate_via_recovery_key_instead_of_password() {
let (pbkdfopts, cipheropts) = paops_defaults();
let (old_rec_priv, old_rec_pub) = keypair();
let (_new_rec_priv, new_rec_pub) = keypair();
let policy = crate::crypto::default_policy();
let pt = b"key-rotated".to_vec();
let (ct, ext) = encrypt(
pt.clone(),
"pw",
&[old_rec_pub],
&mut rng(),
&pbkdfopts,
&cipheropts,
&mut None,
&*policy,
)
.unwrap();
let ext2 = rotate(
&ext,
None,
Some(&old_rec_priv),
"rotated-pw",
&[new_rec_pub],
&mut rng(),
&pbkdfopts,
&mut None,
&*policy,
)
.unwrap();
assert_eq!(
decrypt_with_password(ct, "rotated-pw", &ext2, &mut None, &*policy).unwrap(),
pt
);
}
#[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}");
}
}