crates/nornir-vault/src/crypto.rs
//! The envelope primitives: AES-256-GCM seal/open and the CSPRNG helpers.
//!
//! ## Why AES-256-GCM (and the XChaCha20-Poly1305 note)
//!
//! The vault re-seals its entries on every save under a **long-lived DEK** with a
//! **fresh random 96-bit nonce** per seal. AES-256-GCM with random 96-bit nonces
//! is safe up to ~2³² seals *per key* (NIST SP 800-38D birthday bound); a
//! personal client vault is many orders of magnitude below that, so random
//! nonces are sound here — and AES-256-GCM is the copy already vetted in the edda
//! lock (a full release, AES-NI-accelerated on the host). The noted alternative,
//! **XChaCha20-Poly1305**, has a 192-bit nonce that removes the bound entirely;
//! it is the better choice if the DEK ever becomes shared or extremely
//! long-lived. The seal/open seam below is the single place to swap the cipher.
//!
//! Each ciphertext is stored as its own `{nonce, ct}` pair ([`Sealed`]); the tag
//! is the GCM postfix tag inside `ct`. The DEK-wrap and every entry carry their
//! own independent nonce.
use aes_gcm::aead::array::Array;
use aes_gcm::aead::{consts::U12, Aead, KeyInit, Payload};
use aes_gcm::Aes256Gcm;
use zeroize::Zeroizing;
use crate::error::{Result, VaultError};
/// Symmetric key length (bytes) for both the DEK and the KEK (AES-256).
pub const KEY_LEN: usize = 32;
/// GCM nonce length (bytes) — 96 bits, the AES-GCM standard nonce.
pub const NONCE_LEN: usize = 12;
/// A sealed blob: the random nonce it was sealed under, and the ciphertext
/// (which includes the 16-byte GCM tag). Base64-encoded when it lands in the
/// on-disk JSON header (see `header.rs`).
#[derive(Clone)]
pub struct Sealed {
/// The 96-bit random nonce, unique per seal.
pub nonce: [u8; NONCE_LEN],
/// Ciphertext || GCM tag.
pub ct: Vec<u8>,
}
/// Fill `buf` with cryptographically secure random bytes from the OS CSPRNG.
pub fn fill_random(buf: &mut [u8]) -> Result<()> {
getrandom::fill(buf).map_err(|e| VaultError::Provider(format!("csprng: {e}")))
}
/// A fresh 32-byte random key (DEK or a keyring wrapping secret), zeroizing.
pub fn random_key() -> Result<Zeroizing<[u8; KEY_LEN]>> {
let mut k = Zeroizing::new([0u8; KEY_LEN]);
fill_random(&mut k[..])?;
Ok(k)
}
fn cipher(key: &[u8]) -> Result<Aes256Gcm> {
Aes256Gcm::new_from_slice(key)
.map_err(|_| VaultError::Provider("aead key must be 32 bytes".into()))
}
/// Seal `plaintext` under `key`, binding `aad` (associated data — authenticated
/// but not encrypted) into the tag. A fresh random nonce is drawn per call.
pub fn seal(key: &[u8], plaintext: &[u8], aad: &[u8]) -> Result<Sealed> {
let c = cipher(key)?;
let mut nonce = [0u8; NONCE_LEN];
fill_random(&mut nonce)?;
let n: Array<u8, U12> = Array::from(nonce);
let ct = c
.encrypt(&n, Payload { msg: plaintext, aad })
.map_err(|_| VaultError::Provider("aead seal failed".into()))?;
Ok(Sealed { nonce, ct })
}
/// Open a [`Sealed`] under `key` with the same `aad` used to seal it. Returns the
/// plaintext in a zeroizing buffer. A wrong key, a tampered ciphertext, or a
/// mismatched `aad` all fail here — the caller maps that to a coarse
/// [`VaultError::Unlock`] with no oracle.
pub fn open(key: &[u8], sealed: &Sealed, aad: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
let c = cipher(key)?;
let n: Array<u8, U12> = Array::from(sealed.nonce);
let pt = c
.decrypt(&n, Payload { msg: &sealed.ct, aad })
.map_err(|_| VaultError::Provider("aead open failed".into()))?;
Ok(Zeroizing::new(pt))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn seal_open_roundtrip_with_aad() {
let key = random_key().unwrap();
let s = seal(&*key, b"hello", b"name").unwrap();
let pt = open(&*key, &s, b"name").unwrap();
assert_eq!(&*pt, b"hello");
}
#[test]
fn wrong_aad_fails() {
let key = random_key().unwrap();
let s = seal(&*key, b"hello", b"name-A").unwrap();
assert!(open(&*key, &s, b"name-B").is_err(), "aad must be authenticated");
}
#[test]
fn wrong_key_fails() {
let k1 = random_key().unwrap();
let k2 = random_key().unwrap();
let s = seal(&*k1, b"hello", b"").unwrap();
assert!(open(&*k2, &s, b"").is_err());
}
#[test]
fn nonce_is_fresh_per_seal() {
let key = random_key().unwrap();
let a = seal(&*key, b"x", b"").unwrap();
let b = seal(&*key, b"x", b"").unwrap();
assert_ne!(a.nonce, b.nonce, "each seal must draw a fresh nonce");
assert_ne!(a.ct, b.ct, "same plaintext under fresh nonce ⇒ different ct");
}
}
/// Known-answer test for the at-rest AEAD (2026-10-02, W3 crypto bump): a value
/// sealed by the PRE-bump stack (aes-gcm 0.11, getrandom 0.2) must open with
/// every later stack — the vault holds operator secrets. The fixture is
/// `nonce || ct` under a fixed key and AAD. Regenerate ONLY on purpose:
/// `NORNIR_VAULT_KAT_REGEN=1 cargo test -p nornir-vault crypto_kat`.
#[cfg(test)]
mod crypto_kat {
use super::*;
#[test]
fn crypto_kat_old_ciphertext_opens() {
let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/crypto-kat/sealed.bin");
let key = [0x42u8; KEY_LEN];
if std::env::var_os("NORNIR_VAULT_KAT_REGEN").is_some() {
let s = seal(&key, b"nornir vault kat plaintext", b"kat-aad").unwrap();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, [s.nonce.as_slice(), s.ct.as_slice()].concat()).unwrap();
}
let raw = std::fs::read(&path).unwrap();
let sealed = Sealed { nonce: raw[..NONCE_LEN].try_into().unwrap(), ct: raw[NONCE_LEN..].to_vec() };
assert_eq!(open(&key, &sealed, b"kat-aad").unwrap().as_slice(), b"nornir vault kat plaintext");
}
}