envseal 0.3.10

Write-only secret vault with process-level access control — post-agent secret management
Documentation
//! AES-256-GCM authenticated encryption for arbitrary configuration
//! blobs, with HKDF-derived per-domain keys off the master key.
//!
//! Solves the v0.2.x "policy.toml is plaintext on disk" leak: an
//! attacker who reads the file (without breaking any crypto) saw
//! exactly which binaries were authorized for which secrets. HMAC
//! gave integrity but not confidentiality. AEAD gives both, in one
//! primitive.
//!
//! # Domain separation
//!
//! Each consumer (policy, security config, future blobs) calls
//! [`seal`] / [`unseal`] with its own static `domain` byte string —
//! `b"policy.v1"`, `b"security_config.v1"`, etc. The encryption key
//! is `HKDF-Expand(master_key, domain)` so a leaked policy
//! ciphertext can't help an attacker decrypt the security config or
//! any secret. The salt is fixed (the HKDF construction is
//! domain-separated by `info`, not `salt`, in our usage).
//!
//! # File format
//!
//! `version_byte (0x01) || nonce (12 bytes) || ciphertext+tag`
//!
//! Version byte lets us migrate the AEAD primitive in the future
//! without breaking forward-compat: a v2 reader that sees v1 still
//! decrypts, a v1 reader that sees v2 fails loudly with
//! `Error::CryptoFailure("unsupported sealed blob version")`.

use aes_gcm::{
    aead::{Aead, AeadCore},
    Aes256Gcm, KeyInit,
};
use hkdf::Hkdf;
use rand::rngs::OsRng;
use sha2::Sha256;

use crate::error::Error;

/// Current sealed-blob format version. Bumping requires
/// dual-version read support (handle both during a release window).
pub const VERSION: u8 = 0x01;

/// 12-byte AES-GCM nonce — the AEAD requires a fresh nonce per
/// encryption; we use `OsRng`.
const NONCE_SIZE: usize = 12;

/// HKDF salt — fixed across all domains. Domain separation is
/// supplied via the `info` parameter, not the salt.
const HKDF_SALT: &[u8] = b"envseal-sealed-blob-v1";

/// AES-256-GCM seal an arbitrary plaintext blob under a domain-
/// derived key. Output format: `version || nonce || ciphertext+tag`.
///
/// # Errors
/// Returns [`Error::CryptoFailure`] on KDF expansion failure or
/// AEAD encrypt failure (the latter shouldn't happen under
/// well-formed inputs but is propagated for forensic visibility).
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)
}

/// Inverse of [`seal`]. Returns the plaintext bytes.
///
/// # Errors
/// `Error::CryptoFailure` for: too-short input, version-byte
/// mismatch, KDF failure, AEAD decrypt failure (wrong key OR
/// tampered ciphertext — the AEAD tag covers both cases).
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() {
        // The whole point of sealing — there must not be a
        // recognizable substring of the plaintext anywhere in the
        // ciphertext (a property AEAD provides over plaintext-with-
        // HMAC, which leaked the body in the file).
        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() {
        // Domain separation: same master key, different domain string,
        // must NOT decrypt. Otherwise an attacker who steals the
        // policy file could swap it with a same-key-different-domain
        // file and have the consumer accept it.
        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();
        // Flip a bit in the ciphertext.
        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() {
        // Re-sealing the same plaintext twice must produce different
        // ciphertexts (fresh nonce). Otherwise the AEAD reuses a
        // (key, nonce) pair → catastrophic key recovery.
        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);
    }
}