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/provider.rs

//! The pluggable **key providers** — the core abstraction.
//!
//! A vault's entries are sealed by a random DEK; the DEK is wrapped by a **KEK**.
//! *Where the KEK comes from* is the only thing that differs between unlock
//! methods, and that is exactly what [`VaultKeyProvider`] abstracts:
//!
//! | [`AuthMethod`]  | Provider                | KEK source                                   | Status         |
//! |-----------------|-------------------------|----------------------------------------------|----------------|
//! | `PinCode`       | [`PinProvider`]         | `Argon2id(pin, salt)`                         | **implemented**|
//! | `OsKeyring`     | [`OsKeyringProvider`]   | random secret in the OS keyring (Secret Svc) | **implemented** (feature `os-keyring`) |
//! | `HostKeyfile`   | [`HostKeyfileProvider`] | 32 random bytes in a 0600 file on this host  | **implemented** |
//! | `Oidc`          | `OidcOpenBaoProvider`   | OIDC login → OpenBao releases the DEK/KEK     | design-only stub |
//! | *(TPM)*         | `TpmProvider`           | TPM-sealed KEK                                | design-only stub |
//! | *(FIDO2)*       | `Fido2Provider`         | FIDO2 `hmac-secret` → KEK                      | design-only stub |
//! | `Mtls`          | —                       | mTLS is a *stored credential*, not an unlock  | n/a            |
//! | `StaticToken`   | —                       | a static token is a *stored credential*       | n/a            |
//!
//! The stubs exist so the trait is proven to admit them; they return
//! [`VaultError::ProviderUnimplemented`] rather than half-built crypto.

use zeroize::Zeroizing;

use crate::crypto::{self, KEY_LEN};
use crate::error::{Result, VaultError};

/// The wrapping key. Zeroized on drop; only ever lives long enough to (un)wrap
/// the DEK.
pub type Kek = Zeroizing<[u8; KEY_LEN]>;

/// How a vault is locked / which credential unlocks it. Aligned with the
/// `nornir-auth-*` family's method names (mTLS, OIDC, bearer/static-token) so a
/// thin client speaks one taxonomy across "which server auth" and "how the local
/// vault unlocks". `PinCode` selects [`PinProvider`]; `OsKeyring` selects
/// [`OsKeyringProvider`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum AuthMethod {
    /// Low-entropy PIN stretched by Argon2id. The MVP unlock.
    PinCode,
    /// KEK stored in the OS keyring (Secret Service / KDE Wallet / libsecret).
    OsKeyring,
    /// mTLS client certificate. Here a *stored credential kind*, not an unlock.
    Mtls,
    /// A static bearer token. A *stored credential kind*, not an unlock.
    StaticToken,
    /// OIDC login (device-code) → OpenBao releases the key. Design-only.
    Oidc,
    /// A TPM 2.0 holds the KEK, sealed to a PCR policy. The key material never
    /// leaves the chip in the clear and cannot be copied off the machine.
    Tpm,
    /// The KEK is 32 random bytes in an owner-only file on this host, at a path
    /// the header records. The **unattended** unlock: no human, no desktop
    /// session, no chip. See [`HostKeyfileProvider`] for what it does and does
    /// not buy.
    HostKeyfile,
}

/// The non-secret material a provider persists in the vault header so it can
/// re-derive/fetch the KEK on the next unlock. Contains **no** secret bytes: the
/// PIN is never stored (only its salt + Argon2 cost), and the keyring variant
/// stores only the lookup coordinates (the secret lives in the OS keyring).
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ProviderParams {
    /// Argon2id salt + cost parameters. The salt is public; the PIN is not stored.
    Pin {
        /// Base64 salt (16 random bytes).
        salt_b64: String,
        /// Argon2 memory cost, KiB.
        m_cost: u32,
        /// Argon2 time cost (iterations).
        t_cost: u32,
        /// Argon2 parallelism (lanes).
        p_cost: u32,
    },
    /// OS-keyring lookup coordinates. The KEK itself is in the keyring.
    OsKeyring {
        /// Keyring service name.
        service: String,
        /// Keyring account/entry name.
        account: String,
    },
    /// A TPM-sealed KEK. Both fields are **ciphertext or public policy**, safe
    /// in a header that an attacker may read: the blob only opens on the TPM
    /// that sealed it, and only while the named PCRs still hold the values they
    /// held at seal time.
    Tpm {
        /// Base64 of the sealed blob (TPM2B_PUBLIC ‖ TPM2B_PRIVATE, as
        /// `nornir_tpm::SealedBlob::encode` writes it).
        blob_b64: String,
        /// The PCR selection the blob is sealed against, in
        /// `nornir_tpm::PcrSelection::parse` syntax (e.g. `"sha256:0,7"`).
        /// Recorded so an operator can see what would invalidate the slot
        /// without having to unseal it.
        pcrs: String,
    },
    /// A KEK in an owner-only file on this host. The field is a **path**, which
    /// is not a secret — the bytes it names are. Recording it means the vault
    /// says out loud which file has to exist for it to open, instead of that
    /// being a fact only the calling code knows.
    HostKeyfile {
        /// Absolute path to the 32-byte keyfile, as the provisioning holder
        /// wrote it.
        path: String,
    },
    /// A design-only method with no persisted unlock material.
    Stub(AuthMethod),
}

impl ProviderParams {
    /// The auth method these params belong to.
    pub fn method(&self) -> AuthMethod {
        match self {
            ProviderParams::Pin { .. } => AuthMethod::PinCode,
            ProviderParams::OsKeyring { .. } => AuthMethod::OsKeyring,
            ProviderParams::Tpm { .. } => AuthMethod::Tpm,
            ProviderParams::HostKeyfile { .. } => AuthMethod::HostKeyfile,
            ProviderParams::Stub(m) => *m,
        }
    }
}

/// The seam every unlock method implements. A provider (1) provisions its public
/// params once at vault creation and (2) derives/fetches the KEK from those
/// params on every unlock. It never sees the DEK or the entries.
pub trait VaultKeyProvider {
    /// The method this provider speaks.
    fn method(&self) -> AuthMethod;

    /// Produce the public params to persist in the header at vault creation
    /// (generating a fresh salt / storing a fresh keyring secret as needed).
    fn provision(&self) -> Result<ProviderParams>;

    /// Derive (PIN) or fetch (keyring) the KEK from the persisted params.
    fn kek(&self, params: &ProviderParams) -> Result<Kek>;
}

// ─── PinProvider — Argon2id(salt, pin) → KEK (implemented) ───────────────────

/// Argon2id default cost: OWASP's first recommended Argon2id configuration
/// (19 MiB memory, 2 iterations, 1 lane). Stored per-vault in the header, so a
/// future vault can raise them without breaking existing files.
pub const ARGON2_M_COST_KIB: u32 = 19_456;
/// Argon2id time cost (iterations).
pub const ARGON2_T_COST: u32 = 2;
/// Argon2id parallelism (lanes).
pub const ARGON2_P_COST: u32 = 1;
/// Salt length (bytes).
pub const SALT_LEN: usize = 16;

/// Unlock by a PIN, stretched with Argon2id into the KEK. The PIN is held only
/// for the duration of an unlock and is zeroized on drop. Low PIN entropy is
/// backstopped by the vault's lockout counter (see `header.rs`) — Argon2id makes
/// each guess expensive, the counter caps the number of guesses.
pub struct PinProvider {
    pin: crate::secret::SecretString,
}

impl PinProvider {
    /// Build from the user's PIN.
    pub fn new(pin: impl Into<String>) -> Self {
        PinProvider { pin: crate::secret::SecretString::new(pin.into()) }
    }

    fn derive(pin: &str, salt: &[u8], m: u32, t: u32, p: u32) -> Result<Kek> {
        use argon2::{Algorithm, Argon2, Params, Version};
        let params = Params::new(m, t, p, Some(KEY_LEN))
            .map_err(|e| VaultError::Provider(format!("argon2 params: {e}")))?;
        let a2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
        let mut kek: Kek = Zeroizing::new([0u8; KEY_LEN]);
        a2.hash_password_into(pin.as_bytes(), salt, &mut kek[..])
            .map_err(|e| VaultError::Provider(format!("argon2: {e}")))?;
        Ok(kek)
    }
}

impl VaultKeyProvider for PinProvider {
    fn method(&self) -> AuthMethod {
        AuthMethod::PinCode
    }

    fn provision(&self) -> Result<ProviderParams> {
        let mut salt = [0u8; SALT_LEN];
        crypto::fill_random(&mut salt)?;
        Ok(ProviderParams::Pin {
            salt_b64: crate::header::b64(&salt),
            m_cost: ARGON2_M_COST_KIB,
            t_cost: ARGON2_T_COST,
            p_cost: ARGON2_P_COST,
        })
    }

    fn kek(&self, params: &ProviderParams) -> Result<Kek> {
        match params {
            ProviderParams::Pin { salt_b64, m_cost, t_cost, p_cost } => {
                let salt = crate::header::unb64(salt_b64)?;
                Self::derive(self.pin.expose(), &salt, *m_cost, *t_cost, *p_cost)
            }
            other => Err(VaultError::MethodMismatch {
                vault: other.method(),
                provider: AuthMethod::PinCode,
            }),
        }
    }
}

// ─── CachedKekProvider — "the KDF already ran, here is its output" ───────────

/// Unlock from an **already-derived KEK**, skipping the KDF.
///
/// The reason this exists: Argon2id is deliberately expensive (19 MiB, ~tens of
/// ms), and a CLI that runs a burst of short operations — a pipeline fetching
/// five secrets, one per leg, each in its own process — would pay that cost once
/// per operation and re-prompt each time. Such a client caches the KEK in an
/// owner-only session file with a TTL and hands it back through this provider.
///
/// **What it is not.** It is not a way around the KDF: the cached bytes ARE the
/// KDF's output, obtained by a real unlock, and they are bound to the salt in the
/// vault's header. Re-key the vault (a new salt) and the cached KEK stops
/// unlocking it — the lockout counter treats it as the failed attempt it is.
///
/// **Handling rule for the caller.** The KEK is key material: hold it in a 0600
/// file at most, give it a TTL, and delete it on lock. Anyone who can read it can
/// open the vault without the PIN.
///
/// It reports the method of the vault it was derived for (a KEK is meaningless
/// apart from the vault whose salt produced it), and it cannot [`provision`] —
/// a brand-new vault needs a real KDF run, not a recycled key.
///
/// [`provision`]: VaultKeyProvider::provision
pub struct CachedKekProvider {
    kek: Kek,
    method: AuthMethod,
}

impl CachedKekProvider {
    /// Wrap a KEK obtained from an earlier successful unlock of THIS vault,
    /// declaring the method that produced it (normally [`AuthMethod::PinCode`]).
    pub fn new(kek: Kek, method: AuthMethod) -> Self {
        CachedKekProvider { kek, method }
    }
}

impl VaultKeyProvider for CachedKekProvider {
    fn method(&self) -> AuthMethod {
        self.method
    }

    /// Refused by name: creating a vault requires a real KDF run against a fresh
    /// salt. A recycled KEK has no salt of its own to record.
    fn provision(&self) -> Result<ProviderParams> {
        Err(VaultError::Provider(
            "a cached KEK cannot provision a NEW vault — create it with the real provider \
             (the KDF must run against a fresh salt), then cache the KEK it yields"
                .into(),
        ))
    }

    /// Hand back the cached KEK. `params` is ignored **by construction**: the
    /// caller asserted these bytes came from unlocking this vault, and a wrong
    /// KEK simply fails the DEK unwrap like any other wrong credential.
    fn kek(&self, _params: &ProviderParams) -> Result<Kek> {
        Ok(self.kek.clone())
    }
}

// ─── OsKeyringProvider — KEK in the OS keyring (Secret Service) ──────────────

/// Unlock by fetching the KEK from the OS keyring (Secret Service on the KDE
/// Plasma thin client; also KDE Wallet / GNOME Keyring / macOS / Windows via the
/// `keyring` crate). At creation a fresh random 32-byte KEK is generated and
/// stored in the keyring; unlock fetches it. Behind the `os-keyring` feature (it
/// adds the `keyring` dependency and needs a running Secret Service).
///
/// This is a thin-client convenience: the desktop session is already unlocked, so
/// the vault unlocks without a PIN prompt — while the credentials still sit
/// encrypted at rest on the container FS (the RDP-open-container threat model:
/// stop an idle RDP viewer from reading cert/key/token straight off disk).
///
/// ## Backend selection (consumer's choice)
///
/// The `keyring` crate needs a *platform store backend* enabled or every call is
/// a no-op at runtime. This crate deliberately does **not** hard-wire one, so the
/// choice stays with the consumer and the default build needs no C libraries. On
/// the KDE Plasma thin client pick a Secret Service backend, e.g.
/// `keyring/sync-secret-service` (needs `libdbus`) or the pure-Rust
/// `keyring/async-secret-service` + a runtime. The provider code compiles either
/// way; the live test below is `#[ignore]`d because it needs a running keyring.
#[cfg(feature = "os-keyring")]
pub struct OsKeyringProvider {
    service: String,
    account: String,
}

#[cfg(feature = "os-keyring")]
impl OsKeyringProvider {
    /// Build with the keyring coordinates (e.g. service `"korp-vault"`, account
    /// `"kek"`). The same coordinates must be used to provision and to unlock.
    pub fn new(service: impl Into<String>, account: impl Into<String>) -> Self {
        OsKeyringProvider { service: service.into(), account: account.into() }
    }

    fn entry(&self) -> Result<keyring::Entry> {
        keyring::Entry::new(&self.service, &self.account)
            .map_err(|e| VaultError::Provider(format!("keyring open: {e}")))
    }
}

#[cfg(feature = "os-keyring")]
impl VaultKeyProvider for OsKeyringProvider {
    fn method(&self) -> AuthMethod {
        AuthMethod::OsKeyring
    }

    fn provision(&self) -> Result<ProviderParams> {
        // Generate a fresh KEK and stash it in the OS keyring as base64.
        let kek = crypto::random_key()?;
        let entry = self.entry()?;
        entry
            .set_password(&crate::header::b64(&*kek))
            .map_err(|e| VaultError::Provider(format!("keyring set: {e}")))?;
        Ok(ProviderParams::OsKeyring {
            service: self.service.clone(),
            account: self.account.clone(),
        })
    }

    fn kek(&self, params: &ProviderParams) -> Result<Kek> {
        match params {
            ProviderParams::OsKeyring { .. } => {
                let entry = self.entry()?;
                let b64 = entry
                    .get_password()
                    .map_err(|e| VaultError::Provider(format!("keyring get: {e}")))?;
                let raw = crate::header::unb64(&b64)?;
                if raw.len() != KEY_LEN {
                    return Err(VaultError::Provider("keyring KEK wrong length".into()));
                }
                let mut kek: Kek = Zeroizing::new([0u8; KEY_LEN]);
                kek[..].copy_from_slice(&raw);
                Ok(kek)
            }
            other => Err(VaultError::MethodMismatch {
                vault: other.method(),
                provider: AuthMethod::OsKeyring,
            }),
        }
    }
}

// ─── HostKeyfileProvider — the unattended unlock (implemented) ───────────────

/// **The KEK is 32 random bytes in an owner-only file on this host.** This is
/// the provider for a machine that must come back on its own: a headless server
/// that reboots at 03:00 with nobody watching.
///
/// ## Why it exists
///
/// Every other implemented provider needs something a standing server does not
/// have. [`PinProvider`] needs a human at the keyboard on every boot — a vault
/// behind it turns an unattended reboot into an outage. [`OsKeyringProvider`]
/// needs a running Secret Service, which is a *desktop session*; a systemd unit
/// on a headless VM has no D-Bus session bus to ask. [`TpmProvider`] is the
/// right answer and needs `/dev/tpmrm0`, which bare metal has and a cloud guest
/// generally does not. That left no provider at all for the commonest shape in
/// this house, and a vault nothing on the box can open is worse than the 0600
/// file it replaced, because it fails at the worst possible moment.
///
/// This is the shape LUKS keyfiles and `systemd-creds` without a TPM already
/// use, and it is deliberately modest.
///
/// ## What it buys, and what it does not
///
/// It does **not** hide anything from root on that machine: whoever can read
/// the keyfile can open the vault. Anyone claiming otherwise is selling
/// something.
///
/// What it does buy is real and worth having:
///
/// * **N credentials collapse into one.** The vault file stops being a
///   credential — it can be copied, backed up, rsynced to a deploy host and
///   read by anyone, and it discloses nothing. Only one 32-byte file is
///   sensitive, and it is the same 32 bytes whatever the vault grows to hold.
/// * **The key and the ciphertext can be separated.** The keyfile may live on
///   another mount, another device, or be delivered at boot and never written
///   down; the vault does not care. A single 0600 env file cannot be split that
///   way — the secrets and their protection are the same bytes.
/// * **It is a slot, so it is temporary if you want it to be.** Because a vault
///   is multi-holder, the day the machine gets a TPM you `add_holder` a
///   [`TpmProvider`] slot beside this one and re-encrypt **nothing**, then drop
///   this slot. The migration costs one KDF-less wrap.
///
/// ## The path is checked, not guessed
///
/// [`Self::new`] is **strict**: `kek` refuses if the header names a different
/// file than the caller asked for, naming both. That is the house lesson from
/// the credential that was chosen by SHAPE and matched the wrong account — a
/// key read from the wrong file does not fail loudly, it fails as "wrong
/// credential", and on a vault that is indistinguishable from a broken one.
/// [`Self::following_header`] is the loose mode for a vault that legitimately
/// moved, and it says so in its name.
pub struct HostKeyfileProvider {
    /// `None` = follow whatever the header says (the loose mode).
    path: Option<std::path::PathBuf>,
}

impl HostKeyfileProvider {
    /// A provider bound to one keyfile path. `provision` creates the file;
    /// `kek` reads it and refuses a header that names another.
    pub fn new(path: impl Into<std::path::PathBuf>) -> Self {
        HostKeyfileProvider { path: Some(path.into()) }
    }

    /// Open using whatever path the slot records, without asserting one.
    ///
    /// For a vault whose keyfile legitimately lives somewhere else than the
    /// caller knows. Note what it gives up: the header is then the only thing
    /// saying which file is the key, so a header an attacker can write chooses
    /// which file this process reads. It cannot *see* those bytes — a wrong key
    /// only fails the unwrap — but prefer [`Self::new`] wherever the caller
    /// knows the path.
    pub fn following_header() -> Self {
        HostKeyfileProvider { path: None }
    }

    /// Read the keyfile, refusing anything that is not exactly a key.
    fn read_key(path: &std::path::Path) -> Result<Kek> {
        let bytes = std::fs::read(path).map_err(|e| {
            VaultError::Provider(format!(
                "vault keyfile {} cannot be read ({e}). This vault does not open without it — \
                 it is the whole key, and losing it loses the vault unless another holder has a slot",
                path.display()
            ))
        })?;

        // An owner-only file, or it is not a key. Refused BY NAME rather than
        // read anyway: a KEK the whole box can read protects nothing, and the
        // failure mode of not checking is silence.
        #[cfg(unix)]
        {
            use std::os::unix::fs::MetadataExt;
            let mode = std::fs::metadata(path)?.mode() & 0o777;
            if mode & 0o077 != 0 {
                return Err(VaultError::Provider(format!(
                    "vault keyfile {} is mode {mode:o}; it must be 600. It is the vault's key — \
                     group- or world-readable, it protects nothing",
                    path.display()
                )));
            }
        }

        if bytes.len() != KEY_LEN {
            return Err(VaultError::Provider(format!(
                "vault keyfile {} holds {} bytes, not {KEY_LEN}: that is a truncated or \
                 foreign file, not this vault's key",
                path.display(),
                bytes.len()
            )));
        }
        let mut kek: Kek = Zeroizing::new([0u8; KEY_LEN]);
        kek[..].copy_from_slice(&bytes);
        Ok(kek)
    }
}

impl VaultKeyProvider for HostKeyfileProvider {
    fn method(&self) -> AuthMethod {
        AuthMethod::HostKeyfile
    }

    /// Mint a fresh 32-byte key and write it 0600.
    ///
    /// **Refuses to write over an existing file.** Both readings of "just
    /// overwrite it" are wrong: clobbering locks out whatever vault used the old
    /// bytes, and silently reusing them hands two different vaults the same KEK.
    /// So an existing path is an error naming the file, and the operator decides.
    fn provision(&self) -> Result<ProviderParams> {
        let Some(path) = self.path.as_ref() else {
            return Err(VaultError::Provider(
                "HostKeyfileProvider::following_header() cannot provision: there is no header \
                 yet, so nothing names the file to create. Use ::new(path)"
                    .into(),
            ));
        };
        if path.exists() {
            return Err(VaultError::Provider(format!(
                "vault keyfile {} already exists. Overwriting it would lock out whatever vault \
                 those bytes open; reusing it would give two vaults one key. Remove it \
                 deliberately, or point at another path",
                path.display()
            )));
        }
        if let Some(dir) = path.parent() {
            if !dir.as_os_str().is_empty() {
                std::fs::create_dir_all(dir)?;
            }
        }
        let kek = crypto::random_key()?;
        write_key_0600(path, &kek[..])?;
        Ok(ProviderParams::HostKeyfile { path: path.display().to_string() })
    }

    fn kek(&self, params: &ProviderParams) -> Result<Kek> {
        let ProviderParams::HostKeyfile { path: in_header } = params else {
            return Err(VaultError::MethodMismatch {
                vault: params.method(),
                provider: AuthMethod::HostKeyfile,
            });
        };
        match self.path.as_ref() {
            None => Self::read_key(std::path::Path::new(in_header)),
            Some(mine) => {
                if mine.as_os_str() != std::ffi::OsStr::new(in_header) {
                    return Err(VaultError::Provider(format!(
                        "this slot's key is {in_header}, but the caller asked to open it with {}. \
                         Refusing rather than reading the other file: a key taken from the wrong \
                         path does not announce itself, it just fails as a wrong credential. Use \
                         HostKeyfileProvider::following_header() if the move was intended",
                        mine.display()
                    )));
                }
                Self::read_key(mine)
            }
        }
    }
}

/// Write key material with owner-only permissions **at creation**, never a
/// create-then-chmod: between those two calls the key is world-readable, and
/// that window is exactly when a freshly provisioned key is most interesting.
fn write_key_0600(path: &std::path::Path, bytes: &[u8]) -> Result<()> {
    use std::io::Write;
    let mut opts = std::fs::OpenOptions::new();
    // `create_new`: the existence check above is a friendly message, this is the
    // one that is not racy.
    opts.write(true).create_new(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(())
}

// ─── Design-only stubs — proof the trait admits richer providers ─────────────

/// OIDC → OpenBao. **Design-only.** Flow (to implement): run the OIDC
/// device-code grant via `nornir_auth_client::login`, present the ID/access
/// token to OpenBao's JWT/OIDC auth, and read the vault's DEK-wrapping key from a
/// KV path (or have OpenBao's Transit `decrypt` unwrap the DEK). TODO: implement
/// the OpenBao client + token→lease exchange; do not fabricate crypto here.
pub struct OidcOpenBaoProvider;
impl VaultKeyProvider for OidcOpenBaoProvider {
    fn method(&self) -> AuthMethod {
        AuthMethod::Oidc
    }
    fn provision(&self) -> Result<ProviderParams> {
        Err(VaultError::ProviderUnimplemented(AuthMethod::Oidc))
    }
    fn kek(&self, _params: &ProviderParams) -> Result<Kek> {
        Err(VaultError::ProviderUnimplemented(AuthMethod::Oidc))
    }
}

#[cfg(feature = "tpm")]
/// **TPM-sealed KEK** — the hardware-backed slot.
///
/// The KEK is 32 random bytes drawn from the TPM's own RNG and handed straight
/// back to the TPM to seal against a PCR policy. What lands in the vault header
/// is the sealed blob: ciphertext that **only that one chip** can open, and only
/// while the named PCRs still read what they read at seal time. Copy the vault
/// file to another machine and the slot is inert — which is the property a
/// software KDF cannot give you at any cost parameter.
///
/// Pure Rust, no FFI: [`nornir_tpm`] speaks the TPM 2.0 command protocol
/// directly over `/dev/tpmrm0`. There is no `tss-esapi` C library and no
/// `tpm2-tools` subprocess, so this satisfies the pure-Rust law without an
/// exception and works inside an initramfs.
///
/// ## What this does and does not protect
///
/// * It stops the vault file being **taken elsewhere** and ground down offline.
///   Against a stolen disk, a TPM slot is worth far more than a PIN slot.
/// * It does **not** stop code running on that machine, as that user, at that
///   boot state, from unsealing. That is what the PCR policy narrows and what a
///   second factor would close; a TPM slot alone is unattended-unlock by design.
/// * **A TPM slot is not a backup.** Clear the TPM, replace the board, or change
///   anything the PCR policy measures, and the slot is gone for good. Always
///   leave a second holder — [`Vault::remove_holder`] refuses to leave none, but
///   nothing stops a TPM being cleared behind the vault's back.
///
/// [`Vault::remove_holder`]: crate::Vault::remove_holder
pub struct TpmProvider {
    device: Option<std::path::PathBuf>,
    pcrs: String,
}

#[cfg(feature = "tpm")]
impl TpmProvider {
    /// A provider on the default device (`/dev/tpmrm0`, then `/dev/tpm0`) sealing
    /// against `pcrs`, in [`nornir_tpm::PcrSelection::parse`] syntax.
    ///
    /// An empty selection (`""`) seals with no PCR policy: the blob opens on this
    /// chip in any boot state. That is a real choice for a server that must come
    /// back unattended after a kernel update, and a bad one for a laptop.
    pub fn new(pcrs: impl Into<String>) -> Self {
        TpmProvider { device: None, pcrs: pcrs.into() }
    }

    /// The same, on an explicit device node.
    pub fn on_device(device: impl Into<std::path::PathBuf>, pcrs: impl Into<String>) -> Self {
        TpmProvider { device: Some(device.into()), pcrs: pcrs.into() }
    }

    fn open(&self) -> Result<nornir_tpm::Tpm> {
        match &self.device {
            Some(p) => nornir_tpm::Tpm::open(p),
            None => nornir_tpm::Tpm::open_default(),
        }
        .map_err(|e| VaultError::Provider(format!("tpm: {e}")))
    }

    fn selection(&self) -> Result<nornir_tpm::PcrSelection> {
        nornir_tpm::PcrSelection::parse(&self.pcrs)
            .map_err(|e| VaultError::Provider(format!("tpm pcr selection {:?}: {e}", self.pcrs)))
    }
}

#[cfg(feature = "tpm")]
impl VaultKeyProvider for TpmProvider {
    fn method(&self) -> AuthMethod {
        AuthMethod::Tpm
    }

    fn provision(&self) -> Result<ProviderParams> {
        let sel = self.selection()?;
        let mut tpm = self.open()?;
        // The TPM's RNG, not the host's. The chip is about to be the only thing
        // that can recover this key, so it may as well be the thing that chose
        // it — one fewer component whose CSPRNG has to be sound.
        let kek = tpm
            .get_random(crate::crypto::KEY_LEN as u16)
            .map_err(|e| VaultError::Provider(format!("tpm get_random: {e}")))?;
        if kek.len() != crate::crypto::KEY_LEN {
            return Err(VaultError::Provider(format!(
                "tpm returned {} random bytes, wanted {}",
                kek.len(),
                crate::crypto::KEY_LEN
            )));
        }
        let report = tpm
            .seal(&kek, &sel)
            .map_err(|e| VaultError::Provider(format!("tpm seal: {e}")))?;
        Ok(ProviderParams::Tpm {
            blob_b64: crate::header::b64(&report.blob.encode()),
            pcrs: self.pcrs.clone(),
        })
    }

    fn kek(&self, params: &ProviderParams) -> Result<Kek> {
        let ProviderParams::Tpm { blob_b64, .. } = params else {
            return Err(VaultError::Provider(
                "TpmProvider was handed another method's params".into(),
            ));
        };
        let blob = nornir_tpm::SealedBlob::decode(&crate::header::unb64(blob_b64)?)
            .map_err(|e| VaultError::Provider(format!("tpm blob: {e}")))?;
        let mut tpm = self.open()?;
        // A PCR-policy refusal is the expected answer on a machine whose boot
        // state moved, so it is named rather than folded into a generic failure:
        // "the vault is corrupt" and "this machine booted differently" send an
        // operator to opposite ends of the building.
        let secret = tpm.unseal(&blob).map_err(|e| {
            if e.is_policy_refusal() {
                VaultError::Provider(format!(
                    "tpm refused the policy: this slot is sealed to PCRs {:?} and \
                     the machine's current measurements do not match. The boot \
                     state changed (kernel, bootloader, firmware setting) — the \
                     vault is intact, this slot is not usable in this state. \
                     Open with another holder.",
                    self.pcrs
                ))
            } else {
                VaultError::Provider(format!("tpm unseal: {e}"))
            }
        })?;
        if secret.len() != crate::crypto::KEY_LEN {
            return Err(VaultError::Provider(format!(
                "tpm unsealed {} bytes, wanted a {}-byte KEK",
                secret.len(),
                crate::crypto::KEY_LEN
            )));
        }
        let mut k = Kek::new([0u8; crate::crypto::KEY_LEN]);
        k[..].copy_from_slice(secret.as_bytes());
        Ok(k)
    }
}

/// FIDO2 `hmac-secret` → KEK. **Design-only.** A resident FIDO2 credential's
/// `hmac-secret` extension would derive the KEK from a per-vault salt + a touch.
/// TODO: pick a portable CTAP2 binding.
pub struct Fido2Provider;
impl VaultKeyProvider for Fido2Provider {
    fn method(&self) -> AuthMethod {
        AuthMethod::OsKeyring
    }
    fn provision(&self) -> Result<ProviderParams> {
        Err(VaultError::ProviderUnimplemented(AuthMethod::OsKeyring))
    }
    fn kek(&self, _params: &ProviderParams) -> Result<Kek> {
        Err(VaultError::ProviderUnimplemented(AuthMethod::OsKeyring))
    }
}

#[cfg(all(test, feature = "os-keyring"))]
mod keyring_live_tests {
    use super::*;

    /// LIVE round-trip against the real OS keyring. `#[ignore]`d: it needs a
    /// running Secret Service / keyring AND a `keyring` backend feature enabled
    /// (see [`OsKeyringProvider`] docs). Run explicitly on a desktop session with:
    /// `cargo test -p nornir-vault --features os-keyring,keyring/sync-secret-service -- --ignored keyring_provision_then_fetch`
    #[test]
    #[ignore = "needs a live OS keyring + a keyring backend feature"]
    fn keyring_provision_then_fetch() {
        let p = OsKeyringProvider::new("nornir-vault-selftest", "kek");
        let params = p.provision().expect("provision stores a KEK in the keyring");
        let kek = p.kek(&params).expect("fetch the same KEK back");
        assert_eq!(kek.len(), KEY_LEN);
    }
}

/// Known-answer test for the PIN KEK derivation (2026-10-02, W3 crypto bump).
/// `testdata/argon2-kat/kek.bin` was derived by the PRE-bump stack (argon2
/// 0.6.0-rc.8) from a fixed PIN + salt + the production cost parameters. Every
/// vault header on disk names its salt and costs and expects exactly this KEK;
/// a later argon2 must derive the same bytes or every PIN-locked vault stops
/// opening. Regenerate ONLY on purpose:
/// `NORNIR_VAULT_ARGON2_KAT_REGEN=1 cargo test -p nornir-vault argon2_kat`.
#[cfg(test)]
mod argon2_kat {
    use super::*;

    #[test]
    fn argon2_kat_pin_kek_is_byte_identical() {
        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("testdata/argon2-kat/kek.bin");
        let kek = PinProvider::derive("424242", &[0x24u8; 16], ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST).unwrap();
        if std::env::var_os("NORNIR_VAULT_ARGON2_KAT_REGEN").is_some() {
            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
            std::fs::write(&path, &kek[..]).unwrap();
        }
        assert_eq!(&kek[..], std::fs::read(&path).unwrap().as_slice(), "Argon2id PIN KEK changed");
    }
}