crates/nornir-vault/src/vault.rs
//! The [`Vault`] itself: create, open (locked), unlock (→ memory), read/write
//! entries, lock, and the secret-free [`VaultState`] a UI may show.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use zeroize::Zeroizing;
use crate::crypto::{self, KEY_LEN};
use crate::error::{Result, VaultError};
use crate::header::{
EntryRecord, KeySlot, Lockout, SealedRepr, VaultHeader, LEGACY_HOLDER, VAULT_VERSION,
};
use crate::provider::{AuthMethod, ProviderParams, VaultKeyProvider};
use crate::secret::{EntryKind, SecretEntry};
/// A client secrets vault. Two states:
///
/// * **locked** — only the header (metadata + ciphertext) is in memory; no DEK,
/// no plaintext. This is what [`Vault::open`] gives you.
/// * **unlocked** — [`Vault::unlock`] has decrypted the DEK and every entry into
/// memory. The DEK and all entries are zeroized on drop / on [`Vault::lock`].
///
/// The plaintext of a secret **only ever exists in RAM inside an unlocked
/// vault** — it is never written back to disk in the clear.
pub struct Vault {
path: PathBuf,
header: VaultHeader,
/// Present iff unlocked. Zeroized on drop.
dek: Option<Zeroizing<[u8; KEY_LEN]>>,
/// Decrypted entries, present iff unlocked. Each value zeroizes on drop.
cache: BTreeMap<String, SecretEntry>,
/// Which slot's holder opened this vault, iff unlocked. It is what `set`
/// stamps an entry's author with, so the author is a fact the vault
/// observed rather than a string the caller passed in.
opened_by: Option<String>,
}
impl Vault {
/// Create a brand-new vault at `path`, provisioned for `provider`'s method,
/// and return it **unlocked**. Writes the encrypted header (0600) to disk.
pub fn create<P: AsRef<Path>>(path: P, provider: &dyn VaultKeyProvider) -> Result<Self> {
Self::create_for(path, provider, LEGACY_HOLDER)
}
/// [`create`](Self::create) naming the first holder.
///
/// Prefer this wherever a real account is known — the whole point of a slot
/// list is being able to say who holds what, and a vault created through the
/// unnamed constructor can only report a placeholder.
pub fn create_for<P: AsRef<Path>>(
path: P,
provider: &dyn VaultKeyProvider,
holder: &str,
) -> Result<Self> {
let params = provider.provision()?;
let dek = crypto::random_key()?;
let kek = provider.kek(¶ms)?;
let wrapped = crypto::seal(&*kek, &*dek, &VaultHeader::slot_aad(holder))?;
let header = VaultHeader {
version: VAULT_VERSION,
slots: vec![KeySlot {
holder: holder.to_string(),
method: provider.method(),
provider: params,
wrapped_dek: SealedRepr::of(&wrapped),
added_at: None,
v1_aad: false,
}],
legacy_method: None,
legacy_provider: None,
legacy_wrapped_dek: None,
lockout: Lockout::default(),
entries: BTreeMap::new(),
};
let vault = Vault {
path: path.as_ref().to_path_buf(),
header,
dek: Some(dek),
cache: BTreeMap::new(),
opened_by: Some(holder.to_string()),
};
vault.save()?;
Ok(vault)
}
/// Open an existing vault file **locked** (reads only the header). Call
/// [`Vault::unlock`] to decrypt.
///
/// A v1 file is folded into the slot shape in memory here
/// ([`VaultHeader::normalise`]); it is not rewritten until something else
/// saves, and the fold re-encrypts nothing.
pub fn open<P: AsRef<Path>>(path: P) -> Result<Self> {
let bytes = std::fs::read(path.as_ref())?;
let mut header: VaultHeader = serde_json::from_slice(&bytes)?;
header.normalise()?;
Ok(Vault {
path: path.as_ref().to_path_buf(),
header,
dek: None,
cache: BTreeMap::new(),
opened_by: None,
})
}
/// Is the vault currently locked (no DEK in memory)?
pub fn is_locked(&self) -> bool {
self.dek.is_none()
}
/// The method the FIRST slot unlocks with.
///
/// Kept for callers written against the one-slot vault. A vault may now hold
/// holders using different methods, so this is no longer the whole truth —
/// [`Vault::holders`] is.
pub fn method(&self) -> AuthMethod {
self.header.slots.first().map(|s| s.method).unwrap_or(AuthMethod::PinCode)
}
/// Who can open this vault: one row per key slot, name and method. Works
/// locked — slot metadata is header, not ciphertext. No secret bytes.
pub fn holders(&self) -> Vec<HolderPresence> {
self.header
.slots
.iter()
.map(|s| HolderPresence {
holder: s.holder.clone(),
method: s.method,
added_at: s.added_at.clone(),
})
.collect()
}
/// The provider's **public, non-secret** params (the Argon2 salt + costs, or
/// the keyring coordinates) — what a caller needs to run the KDF itself, e.g.
/// to derive a KEK once and cache it for [`CachedKekProvider`].
///
/// [`CachedKekProvider`]: crate::CachedKekProvider
pub fn provider_params(&self) -> &ProviderParams {
self.header
.slots
.first()
.map(|s| &s.provider)
.expect("normalise() refuses a header with no slots")
}
/// The public params of a NAMED holder's slot — what that holder needs to
/// run their own KDF. With more than one slot, [`Self::provider_params`]
/// (which answers for the first) is the wrong question to ask.
pub fn provider_params_for(&self, holder: &str) -> Option<&ProviderParams> {
self.header.slots.iter().find(|s| s.holder == holder).map(|s| &s.provider)
}
/// Unlock attempts remaining before lockout.
pub fn attempts_remaining(&self) -> u32 {
self.header.lockout.remaining()
}
/// Unlock the vault: derive/fetch the KEK via `provider`, unwrap the DEK, and
/// decrypt every entry into memory.
///
/// * A wrong credential (or tampered wrapped-DEK) **increments the persisted
/// lockout counter**, saves the header, and returns [`VaultError::Unlock`]
/// with no oracle. Once the budget is spent, further attempts return
/// [`VaultError::LockedOut`].
/// * On success the counter is reset to zero (and persisted if it had risen).
/// * A provider that could not produce a key **at all** — no keyfile, a TPM
/// that refused its policy, a keyring that is not running — is not an
/// authentication failure and burns **no** attempt: its own error is
/// returned. Counting it would let an unattended machine lock itself out
/// of a vault whose credential was never wrong.
/// With several slots, every slot whose method matches the provider is
/// tried, and the first that opens wins. One failed unlock burns **one**
/// attempt no matter how many slots were tried — the budget counts the
/// operator's guesses, not the vault's internal work, or adding holders
/// would silently shrink it.
pub fn unlock(&mut self, provider: &dyn VaultKeyProvider) -> Result<()> {
self.unlock_as(provider, None)
}
/// [`unlock`](Self::unlock) restricted to ONE named holder's slot.
///
/// Prefer it where the caller knows who is at the keyboard: it makes a wrong
/// credential mean "wrong credential" instead of "no holder using this
/// method has this credential", and it is what lets an audit line name who
/// opened the vault.
pub fn unlock_as(
&mut self,
provider: &dyn VaultKeyProvider,
holder: Option<&str>,
) -> Result<()> {
if self.header.lockout.is_locked_out() {
return Err(VaultError::LockedOut);
}
let candidates: Vec<usize> = self
.header
.slots
.iter()
.enumerate()
.filter(|(_, s)| s.method == provider.method())
.filter(|(_, s)| holder.is_none_or(|h| s.holder == h))
.map(|(i, _)| i)
.collect();
if candidates.is_empty() {
// Not an authentication failure — no credential was judged — so it
// burns no attempt. It is a configuration answer: nobody here
// unlocks that way.
return Err(VaultError::MethodMismatch {
vault: self.method(),
provider: provider.method(),
});
}
let mut opened: Option<(usize, Zeroizing<Vec<u8>>)> = None;
// Why these are counted separately: a provider that cannot PRODUCE a key
// at all (the keyfile is missing, the mount is not up yet, the TPM
// refused its PCR policy, the keyring is not running) has not made a
// guess — nothing was judged. Folding that into the failed-attempt
// counter is a real outage on an unattended machine: five reboots with a
// disk not yet mounted and the vault is locked out for good, with the
// credential never having been wrong once. So the budget is only spent
// when a key existed and did not open the slot.
let mut keys_offered = 0usize;
let mut first_provider_error: Option<VaultError> = None;
for i in candidates {
let slot = &self.header.slots[i];
let kek = match provider.kek(&slot.provider) {
Ok(k) => {
keys_offered += 1;
k
}
Err(e) => {
first_provider_error.get_or_insert(e);
continue;
}
};
let wrapped = slot.wrapped_dek.to_sealed()?;
let aad = if slot.v1_aad {
VaultHeader::DEK_AAD.to_vec()
} else {
VaultHeader::slot_aad(&slot.holder)
};
if let Ok(pt) = crypto::open(&*kek, &wrapped, &aad) {
opened = Some((i, pt));
break;
}
}
let Some((slot_index, dek_bytes)) = opened else {
if keys_offered == 0 {
// Nothing was judged, so nothing is spent. The provider's own
// sentence is returned instead of a coarse "did not unlock",
// because "the keyfile is mode 644" and "the PIN is wrong" send
// an operator to opposite ends of the building — and this branch
// cannot be a guessing oracle, since no credential was tried.
return Err(first_provider_error.unwrap_or(VaultError::Provider(
"no key provider could produce a key for any matching slot".into(),
)));
}
// Authentication failure: burn an attempt, persist, no oracle.
self.header.lockout.failed = self.header.lockout.failed.saturating_add(1);
self.save()?;
return Err(VaultError::Unlock { remaining: self.header.lockout.remaining() });
};
if dek_bytes.len() != KEY_LEN {
return Err(VaultError::Unlock { remaining: self.header.lockout.remaining() });
}
// DEK recovered → decrypt entries into a staging map (atomic: commit only
// if every entry opens).
let mut dek: Zeroizing<[u8; KEY_LEN]> = Zeroizing::new([0u8; KEY_LEN]);
dek[..].copy_from_slice(&dek_bytes);
let mut staged: BTreeMap<String, SecretEntry> = BTreeMap::new();
for (name, rec) in &self.header.entries {
let aad =
VaultHeader::entry_aad(name, rec.kind, rec.label.as_deref(), rec.author.as_deref());
let sealed = rec.sealed.to_sealed()?;
let pt = crypto::open(&*dek, &sealed, &aad)?;
staged.insert(name.clone(), SecretEntry::decode(rec.kind, &pt)?);
}
self.opened_by = Some(self.header.slots[slot_index].holder.clone());
// Success: reset the counter (persist only if it had moved).
if self.header.lockout.failed != 0 {
self.header.lockout.failed = 0;
self.save()?;
}
self.dek = Some(dek);
self.cache = staged;
Ok(())
}
/// Lock the vault: drop the DEK and every decrypted entry (all zeroized).
/// The on-disk file is untouched; [`Vault::unlock`] re-opens it.
pub fn lock(&mut self) {
self.dek = None; // Zeroizing → wiped
self.cache.clear(); // each SecretEntry ZeroizeOnDrop → wiped
self.opened_by = None;
}
/// Which holder opened this vault, iff unlocked. Non-secret.
pub fn opened_by(&self) -> Option<&str> {
self.opened_by.as_deref()
}
/// **Grant a second holder access.** Requires an unlocked vault.
///
/// Wraps the DEK already in memory under `provider`'s KEK and appends a
/// slot. Nothing is re-encrypted: the vault's entries are untouched, so this
/// costs one KDF run whatever the vault holds.
///
/// Refuses a duplicate holder name by name rather than appending a second
/// slot under it — two slots with one name make "remove this holder"
/// ambiguous, and an ambiguous revoke is one that half-works.
pub fn add_holder(
&mut self,
holder: &str,
provider: &dyn VaultKeyProvider,
added_at: Option<&str>,
) -> Result<()> {
if holder.is_empty() {
return Err(VaultError::Provider("a holder must have a name".into()));
}
if self.header.slots.iter().any(|s| s.holder == holder) {
return Err(VaultError::Provider(format!(
"holder {holder:?} already has a key slot"
)));
}
let params = provider.provision()?;
let kek = provider.kek(¶ms)?;
let wrapped = {
let dek = self.dek()?;
crypto::seal(&*kek, dek, &VaultHeader::slot_aad(holder))?
};
self.header.slots.push(KeySlot {
holder: holder.to_string(),
method: provider.method(),
provider: params,
wrapped_dek: SealedRepr::of(&wrapped),
added_at: added_at.map(str::to_string),
v1_aad: false,
});
self.save()
}
/// **Revoke a holder's access going forward.** Requires an unlocked vault.
/// Returns whether a slot was removed.
///
/// Read [`KeySlot`] for what this does and does not buy: the DEK is
/// unchanged, so this shuts the door on future unlocks, it does not unlearn
/// what that holder already read. The vault refuses to remove the last slot
/// — a vault nobody can open is data loss dressed as success.
pub fn remove_holder(&mut self, holder: &str) -> Result<bool> {
self.dek()?; // must be unlocked
let Some(i) = self.header.slots.iter().position(|s| s.holder == holder) else {
return Ok(false);
};
if self.header.slots.len() == 1 {
return Err(VaultError::Provider(format!(
"{holder:?} holds the only key: removing it would leave a vault \
nobody can open. Add another holder first."
)));
}
self.header.slots.remove(i);
self.save()?;
Ok(true)
}
fn dek(&self) -> Result<&[u8; KEY_LEN]> {
self.dek.as_deref().ok_or(VaultError::Locked)
}
/// Store (or overwrite) a secret under `name`. Requires an unlocked vault.
/// Seals the secret under the DEK and persists the header.
pub fn set(&mut self, name: impl Into<String>, entry: SecretEntry) -> Result<()> {
self.set_with_meta(name, entry, None, None)
}
/// [`set`](Self::set) plus the two non-secret metadata fields a forge-style
/// vault carries: a freeform `label` (the sub-kind of an `Opaque` entry, e.g.
/// `"git-ssh-key"`) and an `added_at` stamp.
///
/// The label is **bound into the AEAD's AAD**, so relabelling a stored blob
/// fails the tag rather than silently succeeding; `added_at` is metadata only.
pub fn set_with_meta(
&mut self,
name: impl Into<String>,
entry: SecretEntry,
label: Option<&str>,
added_at: Option<&str>,
) -> Result<()> {
let name = name.into();
let kind = entry.kind();
// The author is the holder whose slot opened this vault — read off the
// unlock, never taken as an argument. A caller-supplied author would be
// a claim; this is an observation, which is the only kind worth binding
// into the AAD.
let author = self.opened_by.clone();
let sealed = {
let dek = self.dek()?;
let pt = entry.encode();
let aad = VaultHeader::entry_aad(&name, kind, label, author.as_deref());
crypto::seal(dek, &pt, &aad)?
};
self.header.entries.insert(
name.clone(),
EntryRecord {
kind,
sealed: SealedRepr::of(&sealed),
label: label.map(str::to_string),
added_at: added_at.map(str::to_string),
author,
},
);
self.cache.insert(name, entry);
self.save()
}
/// Who wrote the secret stored under `name`, if the vault recorded it.
/// `None` for entries written before authors existed.
pub fn entry_author(&self, name: &str) -> Option<&str> {
self.header.entries.get(name).and_then(|r| r.author.as_deref())
}
/// The freeform non-secret label stored beside `name`, if any.
pub fn entry_label(&self, name: &str) -> Option<&str> {
self.header.entries.get(name).and_then(|r| r.label.as_deref())
}
/// The RFC-3339 stamp stored beside `name`, if any.
pub fn entry_added_at(&self, name: &str) -> Option<&str> {
self.header.entries.get(name).and_then(|r| r.added_at.as_deref())
}
/// Borrow a decrypted secret. `Err(Locked)` if the vault is locked;
/// `Ok(None)` if no such entry.
pub fn get(&self, name: &str) -> Result<Option<&SecretEntry>> {
if self.is_locked() {
return Err(VaultError::Locked);
}
Ok(self.cache.get(name))
}
/// Remove a secret. Requires an unlocked vault. Returns whether it existed.
pub fn remove(&mut self, name: &str) -> Result<bool> {
self.dek()?; // must be unlocked
let existed = self.header.entries.remove(name).is_some();
self.cache.remove(name);
self.save()?;
Ok(existed)
}
/// The non-secret entry names currently stored (works locked or unlocked —
/// names live in the header, not the ciphertext).
pub fn entry_names(&self) -> Vec<&str> {
self.header.entries.keys().map(|s| s.as_str()).collect()
}
/// A UI-safe snapshot: lock state, method, attempts-remaining, and the
/// present entries by name + kind. **Contains no secret material** — this is
/// exactly what may go into a `state_json`.
pub fn state(&self) -> VaultState {
VaultState {
locked: self.is_locked(),
method: self.method(),
attempts_remaining: self.header.lockout.remaining(),
locked_out: self.header.lockout.is_locked_out(),
holders: self.holders(),
entries: self
.header
.entries
.iter()
.map(|(name, rec)| EntryPresence {
name: name.clone(),
kind: rec.kind,
label: rec.label.clone(),
added_at: rec.added_at.clone(),
author: rec.author.clone(),
})
.collect(),
}
}
/// Serialise the header and write it to disk with owner-only (0600)
/// permissions, via a temp file + atomic rename.
pub fn save(&self) -> Result<()> {
if let Some(dir) = self.path.parent() {
if !dir.as_os_str().is_empty() {
std::fs::create_dir_all(dir)?;
}
}
let json = serde_json::to_vec_pretty(&self.header)?;
let tmp = self.path.with_extension("tmp");
write_0600(&tmp, &json)?;
std::fs::rename(&tmp, &self.path)?;
Ok(())
}
}
/// Write `bytes` to `path`, creating it with 0600 on unix.
fn write_0600(path: &Path, bytes: &[u8]) -> Result<()> {
use std::io::Write;
let mut opts = std::fs::OpenOptions::new();
opts.write(true).create(true).truncate(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
opts.mode(0o600);
}
let mut f = opts.open(path)?;
f.write_all(bytes)?;
f.flush()?;
Ok(())
}
/// A secret-free snapshot of a vault for display / `state_json`.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VaultState {
/// Is the vault locked (no secrets in memory)?
pub locked: bool,
/// Which method unlocks it.
pub method: AuthMethod,
/// Unlock attempts remaining before lockout.
pub attempts_remaining: u32,
/// Has the vault locked out?
pub locked_out: bool,
/// The stored entries, by name + kind only. No secret bytes.
pub entries: Vec<EntryPresence>,
/// Who can open this vault. Names and methods only, no key material.
#[serde(default)]
pub holders: Vec<HolderPresence>,
}
/// The presence of one entry — its name, non-secret kind and metadata. No secret
/// bytes: every field here is something a UI may print.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntryPresence {
/// The entry's name.
pub name: String,
/// The entry's non-secret kind.
pub kind: EntryKind,
/// The freeform non-secret sub-kind, when the writer set one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// When the entry was stored (RFC-3339), when the writer set one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
/// Which holder wrote it, when the vault recorded one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
/// One holder who can open the vault: a name and the method they use. Every
/// field is non-secret — this is slot metadata, never key material.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct HolderPresence {
/// The holder's name.
pub holder: String,
/// The method this holder unlocks with.
pub method: AuthMethod,
/// When the slot was added (RFC-3339), when the granter set one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
}