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(¶ms).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");
}
}