use aes_gcm::{
aead::{Aead, AeadCore},
Aes256Gcm, KeyInit,
};
use hkdf::Hkdf;
use rand::rngs::OsRng;
use sha2::Sha256;
use crate::error::Error;
pub const VERSION: u8 = 0x01;
const NONCE_SIZE: usize = 12;
const HKDF_SALT: &[u8] = b"envseal-sealed-blob-v1";
pub fn seal(plaintext: &[u8], master_key: &[u8; 32], domain: &[u8]) -> Result<Vec<u8>, Error> {
let key = derive_aead_key(master_key, domain)?;
let cipher = Aes256Gcm::new((&key).into());
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
let ct = cipher
.encrypt(&nonce, plaintext)
.map_err(|e| Error::CryptoFailure(format!("sealed-blob encrypt failed: {e}")))?;
let mut out = Vec::with_capacity(1 + NONCE_SIZE + ct.len());
out.push(VERSION);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ct);
Ok(out)
}
pub fn unseal(sealed: &[u8], master_key: &[u8; 32], domain: &[u8]) -> Result<Vec<u8>, Error> {
if sealed.len() < 1 + NONCE_SIZE + 16 {
return Err(Error::CryptoFailure(
"sealed blob too short to be valid".to_string(),
));
}
if sealed[0] != VERSION {
return Err(Error::CryptoFailure(format!(
"unsupported sealed blob version: 0x{:02x} (expected 0x{:02x})",
sealed[0], VERSION
)));
}
let nonce = aes_gcm::Nonce::from_slice(&sealed[1..=NONCE_SIZE]);
let ct = &sealed[1 + NONCE_SIZE..];
let key = derive_aead_key(master_key, domain)?;
let cipher = Aes256Gcm::new((&key).into());
cipher.decrypt(nonce, ct).map_err(|_| {
Error::CryptoFailure(
"sealed-blob decrypt failed (wrong key, tampered ciphertext, or unsupported version)"
.to_string(),
)
})
}
fn derive_aead_key(master_key: &[u8; 32], domain: &[u8]) -> Result<[u8; 32], Error> {
let hk = Hkdf::<Sha256>::new(Some(HKDF_SALT), master_key);
let mut out = [0u8; 32];
hk.expand(domain, &mut out).map_err(|_| {
Error::CryptoFailure("HKDF expansion for sealed-blob key failed".to_string())
})?;
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY_A: [u8; 32] = [0xA7; 32];
const KEY_B: [u8; 32] = [0xBE; 32];
#[test]
fn round_trips_arbitrary_payload() {
let payload = b"some policy data with non-ascii: \xff\xfe\x00\x01";
let sealed = seal(payload, &KEY_A, b"policy.v1").unwrap();
let opened = unseal(&sealed, &KEY_A, b"policy.v1").unwrap();
assert_eq!(&opened, payload);
}
#[test]
fn ciphertext_does_not_contain_plaintext() {
let payload = b"OPENAI_API_KEY=sk-proj-recognizable-plaintext-prefix";
let sealed = seal(payload, &KEY_A, b"policy.v1").unwrap();
let needle = b"sk-proj-recognizable";
assert!(
sealed.windows(needle.len()).all(|w| w != needle),
"plaintext leaked into the sealed blob"
);
}
#[test]
fn wrong_key_fails_decrypt() {
let payload = b"x";
let sealed = seal(payload, &KEY_A, b"d").unwrap();
assert!(unseal(&sealed, &KEY_B, b"d").is_err());
}
#[test]
fn wrong_domain_fails_decrypt() {
let payload = b"x";
let sealed = seal(payload, &KEY_A, b"policy.v1").unwrap();
assert!(unseal(&sealed, &KEY_A, b"security_config.v1").is_err());
}
#[test]
fn tampered_ciphertext_fails_decrypt() {
let payload = b"some data";
let mut sealed = seal(payload, &KEY_A, b"d").unwrap();
let last = sealed.len() - 1;
sealed[last] ^= 0x01;
assert!(unseal(&sealed, &KEY_A, b"d").is_err());
}
#[test]
fn unsupported_version_byte_rejected() {
let payload = b"x";
let mut sealed = seal(payload, &KEY_A, b"d").unwrap();
sealed[0] = 0xFF;
let err = unseal(&sealed, &KEY_A, b"d").unwrap_err();
assert!(format!("{err}").contains("unsupported sealed blob version"));
}
#[test]
fn truncated_input_rejected() {
assert!(unseal(b"\x01short", &KEY_A, b"d").is_err());
assert!(unseal(b"", &KEY_A, b"d").is_err());
}
#[test]
fn nonce_is_unique_per_seal() {
let payload = b"deterministic body";
let s1 = seal(payload, &KEY_A, b"d").unwrap();
let s2 = seal(payload, &KEY_A, b"d").unwrap();
assert_ne!(s1, s2);
}
}