crates/nornir-vault/tests/roundtrip.rs
//! End-to-end vault behaviour: PIN round-trip, wrong-PIN lockout, the
//! no-plaintext-on-disk invariant, zeroization, and the secret-free state view.
use nornir_vault::{
AuthMethod, EntryKind, OidcOpenBaoProvider, PinProvider, SecretEntry, SecretString, Vault,
VaultError,
};
use zeroize::Zeroize;
const TOKEN_NEEDLE: &str = "SPARK-TOKEN-NEEDLE-8f3a1c2b-do-not-persist-plaintext";
const CERT_NEEDLE: &str = "CERT-PEM-NEEDLE-BEGIN-CERTIFICATE-zzz";
const KEY_NEEDLE: &str = "KEY-PEM-NEEDLE-BEGIN-PRIVATE-KEY-yyy";
fn seed(v: &mut Vault) {
v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE)))
.unwrap();
v.set(
"mtls",
SecretEntry::MtlsClientIdentity {
cert_pem: SecretString::new(CERT_NEEDLE),
key_pem: SecretString::new(KEY_NEEDLE),
},
)
.unwrap();
}
#[test]
fn pin_roundtrip_encrypt_lock_unlock_read() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
seed(&mut v);
assert!(!v.is_locked());
v.lock();
assert!(v.is_locked());
// Reading while locked is an error, not a silent empty.
assert!(matches!(v.get("spark"), Err(VaultError::Locked)));
// Re-open cold from disk and unlock with the correct PIN.
let mut v = Vault::open(&path).unwrap();
assert!(v.is_locked());
v.unlock(&PinProvider::new("4913")).unwrap();
match v.get("spark").unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
other => panic!("expected spark token, got {other:?}"),
}
match v.get("mtls").unwrap() {
Some(SecretEntry::MtlsClientIdentity { cert_pem, key_pem }) => {
assert_eq!(cert_pem.expose(), CERT_NEEDLE);
assert_eq!(key_pem.expose(), KEY_NEEDLE);
}
other => panic!("expected mtls identity, got {other:?}"),
}
assert!(v.get("absent").unwrap().is_none());
}
#[test]
fn wrong_pin_fails_and_increments_lockout() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("1234")).unwrap();
seed(&mut v);
v.lock();
// First wrong attempt: fails, and the persisted counter drops by one.
let mut v = Vault::open(&path).unwrap();
let before = v.attempts_remaining();
let err = v.unlock(&PinProvider::new("0000")).unwrap_err();
match err {
VaultError::Unlock { remaining } => assert_eq!(remaining, before - 1),
other => panic!("expected Unlock, got {other:?}"),
}
assert!(v.is_locked(), "a failed unlock must leave the vault locked");
// The decrement is persisted: a *fresh* open sees the reduced budget.
let v2 = Vault::open(&path).unwrap();
assert_eq!(v2.attempts_remaining(), before - 1, "lockout must survive reopen");
// The correct PIN still works and resets the counter to full.
let mut v3 = Vault::open(&path).unwrap();
v3.unlock(&PinProvider::new("1234")).unwrap();
assert_eq!(v3.attempts_remaining(), before, "success resets the counter");
// Reset is persisted.
let v4 = Vault::open(&path).unwrap();
assert_eq!(v4.attempts_remaining(), before);
}
#[test]
fn lockout_trips_after_budget_exhausted() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("1234")).unwrap();
seed(&mut v);
let budget = v.attempts_remaining();
v.lock();
let mut v = Vault::open(&path).unwrap();
for _ in 0..budget {
assert!(matches!(
v.unlock(&PinProvider::new("9999")),
Err(VaultError::Unlock { .. })
));
}
// Budget spent → the correct PIN is now refused too (hard backstop).
assert!(matches!(v.unlock(&PinProvider::new("1234")), Err(VaultError::LockedOut)));
}
#[test]
fn no_plaintext_secret_is_ever_written_to_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
seed(&mut v);
v.lock();
let raw = std::fs::read(&path).unwrap();
// The file must be a valid vault header (JSON) …
let _hdr: serde_json::Value = serde_json::from_slice(&raw).unwrap();
// … and must NOT contain any secret's plaintext bytes.
let hay = raw.as_slice();
for needle in [TOKEN_NEEDLE, CERT_NEEDLE, KEY_NEEDLE] {
assert!(
!contains(hay, needle.as_bytes()),
"on-disk vault leaked plaintext for needle: {needle}"
);
}
}
#[test]
fn state_view_carries_no_secret_bytes() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
seed(&mut v);
let state = v.state();
assert_eq!(state.method, AuthMethod::PinCode);
assert!(!state.locked);
let kinds: Vec<_> = state.entries.iter().map(|e| (e.name.as_str(), e.kind)).collect();
assert!(kinds.contains(&("spark", EntryKind::SparkToken)));
assert!(kinds.contains(&("mtls", EntryKind::MtlsClientIdentity)));
// Serialising the state (what a UI would put in state_json) leaks nothing.
let json = serde_json::to_string(&state).unwrap();
for needle in [TOKEN_NEEDLE, CERT_NEEDLE, KEY_NEEDLE] {
assert!(!json.contains(needle), "state_json leaked {needle}");
}
}
#[test]
fn secretstring_zeroizes_and_redacts() {
// Zeroize wipes the bytes.
let mut s = SecretString::new("super-secret-value");
assert_eq!(s.expose(), "super-secret-value");
s.zeroize();
assert!(s.expose().is_empty(), "zeroize must clear the secret");
// Debug is redacted, never revealing.
let s = SecretString::new("hunter2");
assert_eq!(format!("{s:?}"), "SecretString(***)");
let e = SecretEntry::SparkToken(SecretString::new("hunter2"));
let dbg = format!("{e:?}");
assert!(!dbg.contains("hunter2"), "SecretEntry Debug must not reveal the secret");
assert!(dbg.contains("***"));
}
#[test]
fn provider_method_mismatch_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("korp.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
v.lock();
// A PIN vault refuses a non-PIN provider before touching crypto.
let err = v.unlock(&OidcOpenBaoProvider).unwrap_err();
assert!(matches!(err, VaultError::MethodMismatch { .. }));
}
#[test]
fn design_only_providers_do_not_pretend() {
use nornir_vault::VaultKeyProvider;
assert!(matches!(
OidcOpenBaoProvider.provision(),
Err(VaultError::ProviderUnimplemented(_))
));
}
/// Naive substring search over bytes.
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
// ── The forge-vault metadata: freeform label + added_at (dwarves absorption) ──
/// **RED-when-broken: the freeform label round-trips and is AUTHENTICATED.**
///
/// A forge vault stores opaque bytes under a sub-kind (`git-ssh-key`,
/// `crates-token`). The label is non-secret and printable, but it must not be
/// editable behind the AEAD's back — so it is bound into the entry AAD. Editing
/// it in the on-disk JSON must make the entry fail to open, not silently relabel
/// a private key as something else.
#[test]
fn a_labelled_entry_round_trips_and_the_label_cannot_be_edited_on_disk() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("forge.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
v.set_with_meta(
"deploy",
SecretEntry::Opaque(zeroize::Zeroizing::new(b"PRIVATE-KEY-BYTES".to_vec())),
Some("git-ssh-key"),
Some("2026-08-21T20:00:00Z"),
)
.unwrap();
v.lock();
// Round-trip: label + stamp survive, bytes survive.
let mut v = Vault::open(&path).unwrap();
v.unlock(&PinProvider::new("4913")).unwrap();
assert_eq!(v.entry_label("deploy"), Some("git-ssh-key"));
assert_eq!(v.entry_added_at("deploy"), Some("2026-08-21T20:00:00Z"));
match v.get("deploy").unwrap() {
Some(SecretEntry::Opaque(b)) => assert_eq!(&b[..], b"PRIVATE-KEY-BYTES"),
other => panic!("expected the opaque bytes, got {other:?}"),
}
// The label is visible in the secret-free state view — it is metadata, not a secret.
let st = v.state();
let e = st.entries.iter().find(|e| e.name == "deploy").expect("entry present");
assert_eq!(e.label.as_deref(), Some("git-ssh-key"));
assert_eq!(e.kind, EntryKind::Opaque);
// TAMPER: relabel the entry in the file. The AAD no longer matches → unlock fails.
let raw = std::fs::read_to_string(&path).unwrap();
assert!(raw.contains("git-ssh-key"), "the label is stored in the clear (it is not a secret)");
std::fs::write(&path, raw.replace("git-ssh-key", "crates-token")).unwrap();
let mut v = Vault::open(&path).unwrap();
assert!(
v.unlock(&PinProvider::new("4913")).is_err(),
"relabelling a sealed entry on disk MUST fail the tag, never silently succeed"
);
}
/// **RED-when-broken: a vault written before the label field existed still opens.**
///
/// The absorption added two `Option` fields to every entry record. Their absence
/// must produce the byte-exact pre-label AAD, or every vault on every box stops
/// opening on upgrade. Proven by writing a labelless entry, then stripping the
/// (already absent) keys and re-reading — plus an explicit assert that a `None`
/// label leaves no key in the JSON at all.
#[test]
fn an_unlabelled_entry_keeps_the_historical_on_disk_shape_and_opens() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("legacy.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE))).unwrap();
v.lock();
// No label ⇒ the keys are absent from the file entirely (skip_serializing_if),
// so the document is byte-shaped exactly as it was before the fields existed.
let raw = std::fs::read_to_string(&path).unwrap();
assert!(!raw.contains("\"label\""), "an unlabelled entry must not write a label key");
assert!(!raw.contains("\"added_at\""), "an unlabelled entry must not write an added_at key");
let mut v = Vault::open(&path).unwrap();
v.unlock(&PinProvider::new("4913")).unwrap();
assert_eq!(v.entry_label("spark"), None);
match v.get("spark").unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
other => panic!("expected the token, got {other:?}"),
}
}
/// **RED-when-broken: a cached KEK unlocks the same vault without re-running the
/// KDF, is bound to that vault, and cannot create a new one.**
///
/// This is the seam a CLI's `unlock`/`lock` session cache stands on: derive once,
/// reuse across a burst of short-lived processes. Three arms, because two of them
/// are what keep it from being a back door.
#[test]
fn a_cached_kek_unlocks_the_same_vault_but_cannot_provision_one() {
use nornir_vault::{CachedKekProvider, VaultKeyProvider};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("session.vault");
let mut v = Vault::create(&path, &PinProvider::new("4913")).unwrap();
v.set("spark", SecretEntry::SparkToken(SecretString::new(TOKEN_NEEDLE))).unwrap();
// Derive the KEK the way a session cache would: once, from the vault's own params.
let kek = PinProvider::new("4913").kek(v.provider_params()).unwrap();
v.lock();
// ARM 1 — the cached KEK opens it, no PIN involved.
let mut v = Vault::open(&path).unwrap();
v.unlock(&CachedKekProvider::new(kek.clone(), v.method())).unwrap();
match v.get("spark").unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), TOKEN_NEEDLE),
other => panic!("expected the token, got {other:?}"),
}
// ARM 2 — it is bound to THIS vault: another vault's KEK does not open it,
// and the failure is a normal wrong-credential unlock (the counter moves).
let other = dir.path().join("other.vault");
let ov = Vault::create(&other, &PinProvider::new("4913")).unwrap();
let other_kek = PinProvider::new("4913").kek(ov.provider_params()).unwrap();
let mut v = Vault::open(&path).unwrap();
assert!(
v.unlock(&CachedKekProvider::new(other_kek, v.method())).is_err(),
"a KEK derived against another vault's salt must NOT open this one"
);
assert!(v.attempts_remaining() < 5, "a wrong cached KEK must burn an attempt like any other");
// ARM 3 — it cannot stand in for a real KDF run when CREATING a vault.
let fresh = dir.path().join("fresh.vault");
let err = match Vault::create(&fresh, &CachedKekProvider::new(kek, AuthMethod::PinCode)) {
Err(e) => e,
Ok(_) => panic!("a recycled KEK must not be able to create a vault"),
};
assert!(
format!("{err}").contains("cannot provision"),
"creating a vault from a recycled KEK must be refused BY NAME, got: {err}"
);
assert!(!fresh.exists(), "the refused create must not leave a file behind");
}