crates/nornir-vault/tests/many_holders.rs
//! **More than one key opens the vault**, and the vault records who wrote what.
//!
//! These cover the two properties the slot list exists for, and the three ways a
//! naive implementation of it silently fails: by re-encrypting (slow, and a
//! chance to corrupt), by letting the last holder be removed (data loss that
//! reports success), and by leaving the author field forgeable (accountability
//! that is worse than none, because it reads as evidence).
use nornir_vault::{PinProvider, SecretEntry, SecretString, Vault};
/// Cheap Argon2 parameters would be nice here, but the cost is baked into
/// `PinProvider`. Each unlock is ~19 MiB / 2 iterations, so tests stay small.
fn tmp(name: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("nv-many-{}-{}", std::process::id(), name));
std::fs::create_dir_all(&dir).unwrap();
dir.join("v.vault")
}
fn secret(s: &str) -> SecretEntry {
SecretEntry::SparkToken(SecretString::new(s))
}
#[test]
fn two_holders_open_the_same_vault_and_read_the_same_secret() {
let path = tmp("two");
let alice = PinProvider::new("1111");
let bob = PinProvider::new("2222");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.set("token", secret("the-one-secret")).unwrap();
v.add_holder("bob", &bob, Some("2026-09-20T12:00:00Z")).unwrap();
v.lock();
// Alice's PIN opens it.
let mut a = Vault::open(&path).unwrap();
a.unlock(&alice).unwrap();
assert_eq!(a.opened_by(), Some("alice"));
// Bob's DIFFERENT PIN opens the same file and finds the same bytes — the
// point of one DEK wrapped twice rather than two vaults.
let mut b = Vault::open(&path).unwrap();
b.unlock(&bob).unwrap();
assert_eq!(b.opened_by(), Some("bob"));
match b.get("token").unwrap().unwrap() {
SecretEntry::SparkToken(t) => assert_eq!(t.expose(), "the-one-secret"),
other => panic!("wrong kind: {other:?}"),
}
// And a third PIN opens nothing.
let mut c = Vault::open(&path).unwrap();
assert!(c.unlock(&PinProvider::new("9999")).is_err());
}
#[test]
fn granting_access_re_encrypts_nothing() {
// The property that makes a slot list worth having: adding a holder is one
// KDF run, not a re-encryption of the whole vault. Asserted on the bytes —
// every entry's ciphertext must be identical before and after.
let path = tmp("no-reencrypt");
let alice = PinProvider::new("1111");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
for i in 0..8 {
v.set(format!("s{i}"), secret(&format!("value-{i}"))).unwrap();
}
let before: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
v.add_holder("bob", &PinProvider::new("2222"), None).unwrap();
let after: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
assert_eq!(
before["entries"], after["entries"],
"adding a holder rewrote entry ciphertext: the DEK must not change"
);
assert_eq!(before["slots"].as_array().unwrap().len(), 1);
assert_eq!(after["slots"].as_array().unwrap().len(), 2);
}
#[test]
fn the_last_holder_cannot_be_removed() {
let path = tmp("last");
let alice = PinProvider::new("1111");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.set("token", secret("x")).unwrap();
let err = v.remove_holder("alice").unwrap_err().to_string();
assert!(err.contains("only key"), "must refuse by reason, got: {err}");
// Still openable afterwards — the refusal left nothing half-done.
v.lock();
let mut a = Vault::open(&path).unwrap();
a.unlock(&alice).unwrap();
}
#[test]
fn a_removed_holder_stops_opening_it() {
let path = tmp("revoke");
let alice = PinProvider::new("1111");
let bob = PinProvider::new("2222");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.add_holder("bob", &bob, None).unwrap();
assert!(v.remove_holder("bob").unwrap());
assert!(!v.remove_holder("bob").unwrap(), "removing twice is not an error, it is false");
v.lock();
let mut b = Vault::open(&path).unwrap();
assert!(b.unlock(&bob).is_err(), "a revoked holder still opened the vault");
let mut a = Vault::open(&path).unwrap();
a.unlock(&alice).unwrap();
}
#[test]
fn one_failed_unlock_burns_one_attempt_however_many_slots_exist() {
// A budget that shrank as holders were added would make the vault harder to
// open for its owners and no harder for an attacker.
let path = tmp("budget");
let mut v = Vault::create_for(&path, &PinProvider::new("1111"), "alice").unwrap();
v.add_holder("bob", &PinProvider::new("2222"), None).unwrap();
v.add_holder("carol", &PinProvider::new("3333"), None).unwrap();
v.lock();
let mut o = Vault::open(&path).unwrap();
let before = o.attempts_remaining();
assert!(o.unlock(&PinProvider::new("0000")).is_err());
assert_eq!(o.attempts_remaining(), before - 1, "three slots must still cost one attempt");
}
#[test]
fn unlock_as_names_the_holder_and_refuses_another() {
let path = tmp("as");
let alice = PinProvider::new("1111");
let bob = PinProvider::new("2222");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.add_holder("bob", &bob, None).unwrap();
v.lock();
let mut o = Vault::open(&path).unwrap();
o.unlock_as(&bob, Some("bob")).unwrap();
assert_eq!(o.opened_by(), Some("bob"));
// Bob's PIN against Alice's named slot is a failure, not a quiet success
// via Bob's own slot.
let mut p = Vault::open(&path).unwrap();
assert!(p.unlock_as(&bob, Some("alice")).is_err());
}
#[test]
fn the_author_is_recorded_and_cannot_be_edited_in_the_file() {
let path = tmp("author");
let alice = PinProvider::new("1111");
let bob = PinProvider::new("2222");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.add_holder("bob", &bob, None).unwrap();
v.set("from-alice", secret("a")).unwrap();
v.lock();
// Bob writes his own entry; each entry carries the holder who wrote it.
let mut b = Vault::open(&path).unwrap();
b.unlock(&bob).unwrap();
b.set("from-bob", secret("b")).unwrap();
assert_eq!(b.entry_author("from-alice"), Some("alice"));
assert_eq!(b.entry_author("from-bob"), Some("bob"));
// Now forge it on disk: rename bob's entry to alice.
let mut doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
doc["entries"]["from-bob"]["author"] = serde_json::json!("alice");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
let mut f = Vault::open(&path).unwrap();
assert!(
f.unlock(&alice).is_err(),
"the author is bound into the AAD, so rewriting it must fail the tag"
);
// And so must deleting it — an entry cannot be quietly made anonymous.
let mut doc2: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
doc2["entries"]["from-bob"].as_object_mut().unwrap().remove("author");
std::fs::write(&path, serde_json::to_vec(&doc2).unwrap()).unwrap();
let mut g = Vault::open(&path).unwrap();
assert!(g.unlock(&alice).is_err(), "stripping the author must fail the tag too");
}
#[test]
fn a_slot_cannot_be_relabelled_to_another_holder() {
// The holder name is in the wrap's AAD. Swapping the names in the file must
// not turn Bob's slot into Alice's — otherwise "who can open this" is a
// label anybody with write access to the file can rewrite.
let path = tmp("relabel");
let alice = PinProvider::new("1111");
let bob = PinProvider::new("2222");
let mut v = Vault::create_for(&path, &alice, "alice").unwrap();
v.add_holder("bob", &bob, None).unwrap();
v.lock();
let mut doc: serde_json::Value =
serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
doc["slots"][1]["holder"] = serde_json::json!("alice-the-second");
std::fs::write(&path, serde_json::to_vec(&doc).unwrap()).unwrap();
let mut o = Vault::open(&path).unwrap();
assert!(o.unlock(&bob).is_err(), "a relabelled slot still opened");
// Alice's untouched slot is unaffected — the damage is confined.
let mut a = Vault::open(&path).unwrap();
a.unlock(&alice).unwrap();
}
#[test]
fn holders_are_listed_while_the_vault_is_locked_and_carry_no_secrets() {
let path = tmp("list");
let mut v = Vault::create_for(&path, &PinProvider::new("1111"), "alice").unwrap();
v.add_holder("bob", &PinProvider::new("2222"), Some("2026-09-20T12:00:00Z")).unwrap();
v.lock();
let o = Vault::open(&path).unwrap();
let names: Vec<String> = o.holders().into_iter().map(|h| h.holder).collect();
assert_eq!(names, vec!["alice", "bob"]);
assert!(o.is_locked(), "holders() must not need the DEK");
// The UI snapshot carries them and still serialises without a secret byte.
let state = serde_json::to_string(&o.state()).unwrap();
assert!(state.contains("alice") && state.contains("bob"));
assert!(!state.contains("1111") && !state.contains("2222"), "a PIN reached the state: {state}");
}