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/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(&params)?;
        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(&params)?;
        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>,
}