Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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
    }
}