crates/nornir-vault/src/header.rs
//! The on-disk representation — the *only* thing that ever touches the disk.
//!
//! It is a JSON document containing **exclusively** non-secret metadata and
//! **ciphertext**: the auth method, the provider's public params (Argon2 salt +
//! cost, or keyring coordinates), the wrapped DEK, the lockout counter, and each
//! entry's kind + sealed blob. No plaintext secret, no DEK, no PIN is ever here.
//! The invariant "the file never contains a token's plaintext bytes" is asserted
//! by a test in `tests/roundtrip.rs`.
use std::collections::BTreeMap;
use base64::Engine;
use crate::crypto::Sealed;
use crate::error::{Result, VaultError};
use crate::provider::{AuthMethod, ProviderParams};
use crate::secret::EntryKind;
/// The current on-disk format version.
///
/// **v2 (2026-09-20)** turned the single wrapped DEK into a list of [`KeySlot`]s
/// so more than one holder can open the same vault. A v1 file still opens: see
/// [`VaultHeader::normalise`], which folds its three top-level fields into one
/// slot in memory. Nothing is re-encrypted by that fold — the DEK is unchanged,
/// so every entry's ciphertext stays exactly as it was written.
pub const VAULT_VERSION: u32 = 2;
/// The holder name a migrated v1 vault's single slot is given.
///
/// v1 had no notion of *who* held the key, so there is no name to recover — this
/// is a placeholder, not a discovered fact, and it is spelled distinctly enough
/// that nobody mistakes it for a real account.
pub const LEGACY_HOLDER: &str = "original-holder";
/// Default failed-attempt budget before a PIN vault locks out.
pub const DEFAULT_MAX_ATTEMPTS: u32 = 5;
/// Base64-encode bytes (standard alphabet, padded).
pub fn b64(bytes: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// Decode a base64 string, mapping failure to [`VaultError::Base64`].
pub fn unb64(s: &str) -> Result<Vec<u8>> {
base64::engine::general_purpose::STANDARD
.decode(s.as_bytes())
.map_err(|_| VaultError::Base64)
}
/// A [`Sealed`] blob as it is stored: two base64 strings (nonce, ciphertext+tag).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct SealedRepr {
/// Base64 of the 96-bit nonce.
pub n: String,
/// Base64 of ciphertext || GCM tag.
pub c: String,
}
impl SealedRepr {
/// Encode an in-memory [`Sealed`].
pub fn of(s: &Sealed) -> Self {
SealedRepr { n: b64(&s.nonce), c: b64(&s.ct) }
}
/// Decode back to an in-memory [`Sealed`].
pub fn to_sealed(&self) -> Result<Sealed> {
let nonce_v = unb64(&self.n)?;
let nonce: [u8; crate::crypto::NONCE_LEN] =
nonce_v.try_into().map_err(|_| VaultError::Base64)?;
Ok(Sealed { nonce, ct: unb64(&self.c)? })
}
}
/// One stored entry: its non-secret kind label + the sealed ciphertext.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct EntryRecord {
/// The non-secret kind (drives decode; also surfaced to UIs).
pub kind: EntryKind,
/// The entry's secret, sealed under the DEK.
pub sealed: SealedRepr,
/// A **caller-defined, non-secret** sub-kind for [`EntryKind::Opaque`] entries
/// — the freeform label a forge-style vault carries beside the bytes
/// (`"git-ssh-key"`, `"crates-token"`, `"registry-creds"`). It is AUTHENTICATED:
/// [`VaultHeader::entry_aad`] binds it, so a stored blob cannot be relabelled
/// without failing the tag. `None` (the historical shape) produces the exact
/// AAD it always did, so vaults written before this field still open.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
/// When the entry was stored, RFC-3339, non-secret. Metadata only: it is NOT
/// bound into the AAD, because re-stamping a timestamp must not invalidate a
/// secret that is otherwise intact.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
/// **Who wrote this secret** — the holder name of whoever called `set`.
/// Non-secret, and unlike `added_at` it IS bound into the AAD
/// ([`VaultHeader::entry_aad`]), because it is an authenticity claim: an
/// author nobody can verify is worse than no author, since it reads as
/// evidence. Editing or deleting it in the JSON fails the tag.
///
/// `None` is the honest state for every entry written before this field
/// existed, and those entries keep their original v1 AAD byte for byte.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
}
/// The failed-unlock counter — the low-entropy-PIN backstop. Persisted in the
/// header so it survives process restarts (an attacker cannot reset it by
/// relaunching). Incremented on every failed unlock, reset to zero on success.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Lockout {
/// Consecutive failed unlock attempts.
pub failed: u32,
/// Attempts allowed before the vault refuses further tries.
pub max: u32,
}
impl Default for Lockout {
fn default() -> Self {
Lockout { failed: 0, max: DEFAULT_MAX_ATTEMPTS }
}
}
impl Lockout {
/// Is the vault currently locked out (budget exhausted)?
pub fn is_locked_out(&self) -> bool {
self.failed >= self.max
}
/// Attempts remaining before lockout.
pub fn remaining(&self) -> u32 {
self.max.saturating_sub(self.failed)
}
}
/// One key slot: a single holder's wrapped copy of the vault's **one** DEK.
///
/// This is the LUKS keyslot / age multi-recipient shape. The DEK is generated
/// once and never changes; a slot is one wrap of it under one holder's KEK. So:
///
/// * **Adding a holder re-encrypts nothing.** An unlocked vault has the DEK in
/// memory; granting access wraps those same 32 bytes under the new holder's
/// KEK and appends a slot. A vault with ten thousand entries grants access in
/// the time it takes to run one KDF.
/// * **Removing a holder is a slot deletion, and that is exactly as strong as it
/// sounds.** The DEK is unchanged, so anyone who already read a secret still
/// knows it, and anyone who kept a copy of the removed slot's bytes *and* its
/// KEK can still unwrap. Removing a holder is "revoke access going forward",
/// not "unring the bell" — to get the stronger property the vault must be
/// re-keyed under a fresh DEK, which is a different operation.
///
/// Every field here is non-secret: a holder's name, the method they use, the
/// public KDF params, and ciphertext.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct KeySlot {
/// Who holds this key. Non-secret, and **authenticated** — it is bound into
/// the wrap's AAD by [`VaultHeader::slot_aad`], so a slot cannot be
/// relabelled to another holder without failing the tag.
pub holder: String,
/// The method this slot unlocks with. Slots may differ: one PIN holder and
/// one OS-keyring holder on the same vault is the normal case.
pub method: AuthMethod,
/// This holder's public (non-secret) provider params — their own Argon2 salt
/// and costs, not shared with any other slot.
pub provider: ProviderParams,
/// The DEK, sealed under THIS holder's KEK.
pub wrapped_dek: SealedRepr,
/// When the slot was added (RFC-3339), non-secret. Metadata only, not bound
/// into the AAD — re-stamping must not invalidate a slot that still works.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub added_at: Option<String>,
/// True only for the slot a v1 vault was folded into, which was sealed under
/// the old fixed [`VaultHeader::DEK_AAD`] and therefore cannot be verified
/// against the holder-bound AAD that v2 slots use.
///
/// It exists so the fold re-encrypts nothing. The flag clears itself the
/// moment that holder is re-added under v2 — and because the AAD it selects
/// binds no holder, a migrated slot is the one slot whose `holder` field is
/// only a label. That is a faithful record of what v1 stored, which is
/// nothing.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub v1_aad: bool,
}
/// The whole on-disk header.
///
/// The three `legacy_*` fields are v1's shape. They are read, folded into
/// [`Self::slots`] by [`Self::normalise`] and never written again.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct VaultHeader {
/// On-disk format version.
pub version: u32,
/// The key slots — one per holder who can open this vault. Never empty after
/// [`Self::normalise`]; a header that would leave it empty is refused rather
/// than written, because a vault nobody can open is data loss that looks
/// like success.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slots: Vec<KeySlot>,
/// v1: how this vault unlocks. Folded into `slots` on open.
#[serde(default, rename = "method", skip_serializing_if = "Option::is_none")]
pub legacy_method: Option<AuthMethod>,
/// v1: the provider's public params. Folded into `slots` on open.
#[serde(default, rename = "provider", skip_serializing_if = "Option::is_none")]
pub legacy_provider: Option<ProviderParams>,
/// v1: the DEK sealed under the one KEK. Folded into `slots` on open.
#[serde(default, rename = "wrapped_dek", skip_serializing_if = "Option::is_none")]
pub legacy_wrapped_dek: Option<SealedRepr>,
/// The failed-attempt lockout counter.
pub lockout: Lockout,
/// name → sealed entry. `BTreeMap` for a deterministic on-disk order.
pub entries: BTreeMap<String, EntryRecord>,
}
impl VaultHeader {
/// AAD bound into a **v1** DEK wrap (domain separation from entry seals).
/// Binds no holder, because v1 had no holders.
pub const DEK_AAD: &'static [u8] = b"nornir-vault/dek/v1";
/// The AAD bound into a v2 slot's DEK wrap: the holder's name, so a slot
/// cannot be moved to another holder's row without failing the tag.
///
/// Length-prefixed rather than separator-delimited. A holder name is
/// caller-supplied text, and with a separator a name containing that
/// separator could produce the same bytes as a different (name, suffix)
/// pair. A length prefix has no such reading.
pub fn slot_aad(holder: &str) -> Vec<u8> {
let mut aad = Vec::with_capacity(holder.len() + 28);
aad.extend_from_slice(b"nornir-vault/dek/v2/");
aad.extend_from_slice(&(holder.len() as u32).to_be_bytes());
aad.extend_from_slice(holder.as_bytes());
aad
}
/// Fold a v1 header into the v2 shape, in memory. Called by `Vault::open`.
///
/// Refuses rather than guesses:
/// * a header carrying BOTH `slots` and the v1 fields is ambiguous — two
/// answers to "which wrap is the DEK" — so it is rejected by name;
/// * a header carrying neither cannot be opened by anybody;
/// * a v1 header missing any one of its three fields is incomplete.
///
/// The migrated slot keeps [`KeySlot::v1_aad`], so its wrap is opened with
/// the AAD it was actually sealed under. Nothing is re-encrypted and the
/// file is not rewritten here — a v1 vault that is only ever read stays a
/// v1 file on disk.
pub fn normalise(&mut self) -> Result<()> {
let has_legacy = self.legacy_method.is_some()
|| self.legacy_provider.is_some()
|| self.legacy_wrapped_dek.is_some();
if !self.slots.is_empty() && has_legacy {
return Err(VaultError::Provider(
"vault header carries both `slots` and the v1 `wrapped_dek`: \
two answers to which wrap is the DEK, refusing to pick one"
.into(),
));
}
if !self.slots.is_empty() {
return Ok(());
}
if !has_legacy {
return Err(VaultError::Provider(
"vault header has no key slots: nobody can open this vault".into(),
));
}
let (Some(method), Some(provider), Some(wrapped_dek)) = (
self.legacy_method,
self.legacy_provider.take(),
self.legacy_wrapped_dek.take(),
) else {
return Err(VaultError::Provider(
"v1 vault header is incomplete: method, provider and wrapped_dek \
must all be present"
.into(),
));
};
self.legacy_method = None;
// The folded header IS v2 — it has slots and no v1 fields — so it says
// so. Leaving `version: 1` here would write a file that claims a shape
// it does not have the next time anything saves, and the version number
// is the first thing a reader trusts.
self.version = VAULT_VERSION;
self.slots.push(KeySlot {
holder: LEGACY_HOLDER.to_string(),
method,
provider,
wrapped_dek,
added_at: None,
v1_aad: true,
});
Ok(())
}
/// The AAD bound into an entry seal: its name + kind (+ the freeform `label`
/// when one is set). Binds the ciphertext to all of them, so a stored blob
/// cannot be silently moved to another name/kind/label.
///
/// **Backward compatibility is byte-exact:** with `label == None` this emits
/// precisely the pre-label AAD, so every vault written before the field
/// existed still opens. A labelled entry appends `0x00 ‖ label`, which no
/// unlabelled AAD can collide with (the kind byte is 1..=3, never 0).
pub fn entry_aad(
name: &str,
kind: EntryKind,
label: Option<&str>,
author: Option<&str>,
) -> Vec<u8> {
// Kind byte — stable discriminant, independent of enum ordering.
let kind_byte = match kind {
EntryKind::MtlsClientIdentity => 1u8,
EntryKind::SparkToken => 2,
EntryKind::Opaque => 3,
};
// ── v2: an entry that records its author ──────────────────────────
//
// A SEPARATE scheme rather than another separator-joined field, and the
// reason is worth keeping. The v1 AAD joins with 0x00, and both `label`
// and `author` are caller-supplied text with no forbidden bytes. Append
// the author after the label with a separator and a writer can choose
// `label = "real-label\x01<len><someone-else>"` so the bytes read as a
// different (label, author) pair — the accountability the author field
// exists for would be forgeable by the one party it names.
//
// v2 length-prefixes every field, so exactly one reading exists. The
// presence of the `author` record field selects the scheme, which also
// means STRIPPING the author from the JSON fails the tag rather than
// quietly returning the secret as anonymous.
if let Some(a) = author {
fn lp(aad: &mut Vec<u8>, s: &str) {
aad.extend_from_slice(&(s.len() as u32).to_be_bytes());
aad.extend_from_slice(s.as_bytes());
}
let mut aad = Vec::with_capacity(name.len() + a.len() + 40);
aad.extend_from_slice(b"nornir-vault/entry/v2/");
lp(&mut aad, name);
aad.push(kind_byte);
// A present-but-empty label and an absent label are different
// states, so the flag is carried rather than inferred from a length
// of zero.
match label {
Some(l) => {
aad.push(1);
lp(&mut aad, l);
}
None => aad.push(0),
}
lp(&mut aad, a);
return aad;
}
// ── v1: byte-exact, for every entry written before authors existed ──
let mut aad = Vec::with_capacity(name.len() + 8);
aad.extend_from_slice(b"nornir-vault/entry/v1/");
aad.extend_from_slice(name.as_bytes());
aad.push(0);
aad.push(kind_byte);
if let Some(l) = label {
aad.push(0);
aad.extend_from_slice(l.as_bytes());
}
aad
}
}