crates/nornir-vault/tests/host_keyfile.rs
//! The **unattended** unlock: a vault a headless machine opens by itself.
//!
//! Every test here is red-when-broken against a specific way this could fail
//! quietly, because that is the only way it fails: a keyfile read from the wrong
//! path, a keyfile the whole box can read, a provision that clobbered the key of
//! another vault, or a secret that survived the round trip in readable form on
//! disk. None of those raise an error on their own.
use std::io::Write;
use nornir_vault::{
AuthMethod, HostKeyfileProvider, PinProvider, SecretEntry, SecretString, Vault, VaultError,
};
/// A per-test directory, named after the test, so two tests never share a path.
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("nv-hostkey-{}-{tag}", std::process::id()));
std::fs::remove_dir_all(&d).ok();
std::fs::create_dir_all(&d).unwrap();
d
}
/// Naive substring search over bytes — the same shape `roundtrip.rs` uses.
fn contains(hay: &[u8], needle: &[u8]) -> bool {
hay.windows(needle.len()).any(|w| w == needle)
}
/// **A machine with no human, no desktop session and no TPM opens its own vault.**
///
/// This is the whole point: create, lock, and re-open in a *fresh process's*
/// worth of state with nothing but a file on disk. If this goes red, a headless
/// server's reboot becomes an outage.
#[test]
fn a_headless_machine_opens_its_own_vault_with_nothing_but_a_keyfile() {
let d = dir("headless");
let vault_path = d.join("front.vault");
let key_path = d.join("front.key");
let p = HostKeyfileProvider::new(&key_path);
let mut v = Vault::create_for(&vault_path, &p, "monetize-server").unwrap();
v.set("stripe", SecretEntry::SparkToken(SecretString::new("rk_live_PLANTED_0001"))).unwrap();
v.lock();
drop(v);
// The keyfile is exactly a key, and owner-only.
let raw = std::fs::read(&key_path).unwrap();
assert_eq!(raw.len(), 32, "a KEK is 32 bytes");
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let mode = std::fs::metadata(&key_path).unwrap().mode() & 0o777;
assert_eq!(mode, 0o600, "the keyfile must be created 0600, not chmod'ed later");
}
// Re-open with no interaction of any kind.
let mut again = Vault::open(&vault_path).unwrap();
assert_eq!(again.method(), AuthMethod::HostKeyfile);
again.unlock(&HostKeyfileProvider::new(&key_path)).unwrap();
match again.get("stripe").unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), "rk_live_PLANTED_0001"),
other => panic!("expected the token, got {other:?}"),
}
assert_eq!(again.opened_by(), Some("monetize-server"), "the slot names who opened it");
}
/// **A key nobody declared does not open the vault**, and the failure burns an
/// attempt rather than answering.
#[test]
fn a_keyfile_nobody_declared_is_refused() {
let d = dir("stranger");
let vault_path = d.join("v.vault");
let mine = d.join("mine.key");
let theirs = d.join("theirs.key");
let mut v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&mine), "front").unwrap();
v.set("t", SecretEntry::SparkToken(SecretString::new("s"))).unwrap();
drop(v);
// A perfectly well-formed 32-byte 0600 keyfile — just not this vault's.
std::fs::write(&theirs, [7u8; 32]).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&theirs, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let mut o = Vault::open(&vault_path).unwrap();
// `following_header` would read the header's path, so point the *header* at
// the stranger's file to make this a genuine wrong-key attempt.
let hdr = std::fs::read_to_string(&vault_path).unwrap();
let swapped = hdr.replace(
&mine.display().to_string().replace('\\', "\\\\"),
&theirs.display().to_string().replace('\\', "\\\\"),
);
let swapped_path = d.join("swapped.vault");
std::fs::write(&swapped_path, swapped).unwrap();
let mut s = Vault::open(&swapped_path).unwrap();
let before = s.attempts_remaining();
let err = s.unlock(&HostKeyfileProvider::following_header()).unwrap_err();
assert!(matches!(err, VaultError::Unlock { .. }), "wrong key ⇒ a plain unlock failure: {err:?}");
assert_eq!(s.attempts_remaining(), before - 1, "a wrong key burns one attempt");
assert!(s.is_locked(), "a refused unlock leaves the vault locked");
// And the original still opens with the key it actually declared.
o.unlock(&HostKeyfileProvider::new(&mine)).unwrap();
assert!(!o.is_locked());
}
/// **The wrong path is refused BY NAME, not read.**
///
/// The house scar: a credential chosen by shape matched the wrong account and
/// the call *succeeded* against it. A key silently read from another path fails
/// as "wrong credential", which on a vault is indistinguishable from a corrupt
/// file — so the strict provider names both paths instead.
#[test]
fn a_header_naming_another_keyfile_is_refused_by_name() {
let d = dir("bypath");
let vault_path = d.join("v.vault");
let declared = d.join("declared.key");
let other = d.join("other.key");
std::fs::write(&other, [9u8; 32]).unwrap();
let v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&declared), "front").unwrap();
drop(v);
let mut o = Vault::open(&vault_path).unwrap();
let err = o.unlock(&HostKeyfileProvider::new(&other)).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains(&declared.display().to_string()), "names the slot's key: {msg}");
assert!(msg.contains(&other.display().to_string()), "names what the caller offered: {msg}");
assert_eq!(
o.attempts_remaining(),
nornir_vault::header::DEFAULT_MAX_ATTEMPTS,
"a path disagreement is a configuration answer, not a failed guess — it burns no attempt"
);
}
/// **A group- or world-readable keyfile is refused**, because a KEK anyone can
/// read protects nothing and the failure mode of not checking is silence.
#[test]
#[cfg(unix)]
fn a_keyfile_the_box_can_read_is_refused() {
use std::os::unix::fs::PermissionsExt;
let d = dir("mode");
let vault_path = d.join("v.vault");
let key_path = d.join("k.key");
let v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&key_path), "front").unwrap();
drop(v);
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let mut o = Vault::open(&vault_path).unwrap();
let err = o.unlock(&HostKeyfileProvider::new(&key_path)).unwrap_err();
assert!(format!("{err}").contains("600"), "says what it wanted: {err}");
assert_eq!(
o.attempts_remaining(),
nornir_vault::header::DEFAULT_MAX_ATTEMPTS,
"no credential was judged, so no attempt is spent"
);
}
/// **A keyfile that is not there yet does not spend the lockout budget.**
///
/// The outage this prevents, spelled out: a headless machine whose keyfile lives
/// on a mount that comes up late reboots, fails to unlock, and tries again. With
/// the budget spent on a key that was never *wrong*, the sixth reboot finds a
/// vault that refuses everybody — including the correct key, once the mount is
/// there. It is asserted over the whole budget plus one, so an off-by-one cannot
/// pass it.
#[test]
fn a_missing_keyfile_never_locks_the_vault_out() {
let d = dir("missing");
let vault_path = d.join("v.vault");
let key_path = d.join("late-mount.key");
let mut v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&key_path), "front").unwrap();
v.set("t", SecretEntry::SparkToken(SecretString::new("still-here"))).unwrap();
drop(v);
let stashed = std::fs::read(&key_path).unwrap();
std::fs::remove_file(&key_path).unwrap();
for attempt in 0..=nornir_vault::header::DEFAULT_MAX_ATTEMPTS {
let mut o = Vault::open(&vault_path).unwrap();
let err = o.unlock(&HostKeyfileProvider::new(&key_path)).unwrap_err();
assert!(
format!("{err}").contains("cannot be read"),
"reboot {attempt} must say the keyfile is missing, not 'did not unlock': {err}"
);
assert_eq!(o.attempts_remaining(), nornir_vault::header::DEFAULT_MAX_ATTEMPTS);
}
// The mount arrives. The machine comes back on its own.
std::fs::write(&key_path, &stashed).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&key_path, std::fs::Permissions::from_mode(0o600)).unwrap();
}
let mut o = Vault::open(&vault_path).unwrap();
o.unlock(&HostKeyfileProvider::new(&key_path)).unwrap();
match o.get("t").unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), "still-here"),
other => panic!("{other:?}"),
}
}
/// **Provisioning never writes over an existing keyfile.** Clobbering locks out
/// whatever vault those bytes opened; reusing them gives two vaults one key.
#[test]
fn provision_refuses_to_clobber_an_existing_keyfile() {
let d = dir("clobber");
let key_path = d.join("shared.key");
let first = d.join("a.vault");
let second = d.join("b.vault");
Vault::create_for(&first, &HostKeyfileProvider::new(&key_path), "a").unwrap();
let original = std::fs::read(&key_path).unwrap();
// `Vault` has no Debug — deliberately, it holds a DEK — so the error is
// taken out by hand rather than with `unwrap_err`.
let err = match Vault::create_for(&second, &HostKeyfileProvider::new(&key_path), "b") {
Ok(_) => panic!("provisioning over an existing keyfile must be refused"),
Err(e) => e,
};
assert!(format!("{err}").contains("already exists"), "{err}");
assert_eq!(std::fs::read(&key_path).unwrap(), original, "the first vault's key is untouched");
assert!(!second.exists(), "a vault is not left half-made");
// And the first vault still opens.
let mut o = Vault::open(&first).unwrap();
o.unlock(&HostKeyfileProvider::new(&key_path)).unwrap();
}
/// **The TPM migration costs nothing**, proven with a second keyfile standing in
/// for the chip: a headless vault gains a second holder, the entry ciphertext is
/// byte-identical, and then the first holder can be revoked.
///
/// This is the whole argument for choosing a keyfile *now*: it is a slot, and a
/// slot is replaceable without re-encrypting anything.
#[test]
fn a_second_holder_joins_and_the_first_can_leave_without_re_encrypting() {
let d = dir("migrate");
let vault_path = d.join("v.vault");
let boot_key = d.join("boot.key");
let chip_key = d.join("chip.key");
let mut v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&boot_key), "boot").unwrap();
v.set("stripe", SecretEntry::SparkToken(SecretString::new("rk_live_PLANTED_0002"))).unwrap();
let before = std::fs::read_to_string(&vault_path).unwrap();
let ct_before = ciphertext_of(&before, "stripe");
v.add_holder("chip", &HostKeyfileProvider::new(&chip_key), Some("2026-09-20T00:00:00Z")).unwrap();
let after = std::fs::read_to_string(&vault_path).unwrap();
assert_eq!(ct_before, ciphertext_of(&after, "stripe"), "granting access re-encrypts nothing");
// The new holder opens it.
let mut o = Vault::open(&vault_path).unwrap();
o.unlock_as(&HostKeyfileProvider::new(&chip_key), Some("chip")).unwrap();
assert_eq!(o.opened_by(), Some("chip"));
// …and the old one can then be retired, but never the last.
assert!(o.remove_holder("boot").unwrap());
let err = o.remove_holder("chip").unwrap_err();
assert!(format!("{err}").contains("only key"), "the last holder is not removable: {err}");
}
/// The base64 ciphertext of one entry, straight out of the on-disk JSON.
fn ciphertext_of(json: &str, name: &str) -> String {
let v: serde_json::Value = serde_json::from_str(json).unwrap();
v["entries"][name]["sealed"]["c"].as_str().unwrap().to_string()
}
/// **THE NEGATIVE PROOF: a planted, secret-shaped value is nowhere readable on
/// disk after the round trip.**
///
/// Not "the file looks encrypted" — the exact shapes this estate actually holds
/// (`rk_live_…`, `whsec_…`, `ucat_…`) are planted, and then every byte the round
/// trip wrote is searched: the vault file, the keyfile, and the temp file
/// `save()` renames through. A store nobody has watched fail is not a store.
#[test]
fn nothing_secret_shaped_survives_readable_on_disk() {
let d = dir("negative");
let vault_path = d.join("front.vault");
let key_path = d.join("front.key");
let planted: [(&str, &str); 3] = [
("stripe", "rk_live_51PLANTEDzzzzNEVERONDISK"),
("webhook", "whsec_PLANTEDzzzzNEVERONDISK"),
("upcloud", "ucat_PLANTEDzzzzNEVERONDISK"),
];
let mut v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&key_path), "front").unwrap();
for (name, value) in planted {
v.set(name, SecretEntry::SparkToken(SecretString::new(value))).unwrap();
}
v.lock();
drop(v);
// Every file the round trip could have written, including the temp file the
// atomic save renames through — a leak there is a leak.
let mut looked_at = 0usize;
for entry in std::fs::read_dir(&d).unwrap() {
let path = entry.unwrap().path();
let bytes = std::fs::read(&path).unwrap();
looked_at += 1;
for (name, value) in planted {
assert!(
!contains(&bytes, value.as_bytes()),
"{name}: the planted secret is readable in {}",
path.display()
);
// Not even the distinctive tail, in case something stored a suffix.
assert!(
!contains(&bytes, b"PLANTEDzzzzNEVERONDISK"),
"{name}: a fragment of the planted secret is readable in {}",
path.display()
);
}
}
assert!(looked_at >= 2, "the vault and its keyfile must both have been examined");
// The proof is only worth something if the values really were stored: read
// them back, then assert the search would have FOUND them had they leaked.
let mut o = Vault::open(&vault_path).unwrap();
o.unlock(&HostKeyfileProvider::new(&key_path)).unwrap();
for (name, value) in planted {
match o.get(name).unwrap() {
Some(SecretEntry::SparkToken(t)) => assert_eq!(t.expose(), value),
other => panic!("{name} did not round-trip: {other:?}"),
}
}
let mut control = std::fs::File::create(d.join("control.txt")).unwrap();
control.write_all(planted[0].1.as_bytes()).unwrap();
control.flush().unwrap();
assert!(
contains(&std::fs::read(d.join("control.txt")).unwrap(), planted[0].1.as_bytes()),
"the search itself must be able to find a secret that IS on disk, or the assertions above are vacuous"
);
}
/// The method is reported, the params carry only a path, and a mismatched
/// provider is rejected rather than half-tried.
#[test]
fn the_slot_carries_a_path_and_no_key_material() {
let d = dir("params");
let vault_path = d.join("v.vault");
let key_path = d.join("k.key");
let v = Vault::create_for(&vault_path, &HostKeyfileProvider::new(&key_path), "front").unwrap();
let state = serde_json::to_string(&v.state()).unwrap();
assert!(state.contains("HostKeyfile"), "the UI view names the method: {state}");
drop(v);
let on_disk = std::fs::read(&vault_path).unwrap();
let key = std::fs::read(&key_path).unwrap();
assert!(!contains(&on_disk, &key), "the header must never carry the key itself");
let mut o = Vault::open(&vault_path).unwrap();
let err = o.unlock(&PinProvider::new("0000")).unwrap_err();
assert!(
matches!(err, VaultError::MethodMismatch { .. }),
"a PIN against a keyfile vault is a configuration answer: {err:?}"
);
}