Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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");
    }
}