nornir-vault — design
Class:
design· The client-side, at-rest-encrypted secrets vault for the nordisk thin clients (korp first). States what is built; the why and the korp-wiring plan live inworkspace_nordisk/.nornir/korp-client-vault-design-2026-07-30.md. Sibling:vault-guide.md.
What it is
nornir-vault is a local, at-rest-encrypted store a thin client keeps its own credentials in — an mTLS client cert+key, a Spark token. It is the sibling of the nornir-auth-* leaves and is distinct from the server-side OpenBao (that is infra); this is the client's local locked store. OpenBao may later be one remote key-provider, but the vault does not depend on it.
Threat model
The korp thin client runs inside an RDP-open container an operator watches from Windows; anyone at that session can read the container filesystem. The vault keeps those credentials encrypted at rest so an idle/naive RDP viewer cannot lift them off disk. It is a "not too open" measure, not a fortress.
Envelope encryption
PIN ──Argon2id(salt)──▶ KEK ──unwraps──▶ DEK ──AES-256-GCM──▶ entries
(or OS keyring ─fetch─▶ KEK) (random, per-vault) (cert/key, token)
- A random DEK seals each entry with AES-256-GCM (fresh random 96-bit nonce per seal; safe far below the 2³² birthday bound for a personal vault — XChaCha20-Poly1305 is the noted alternative if the DEK ever becomes shared).
- The DEK is wrapped by a KEK from a pluggable
VaultKeyProvider, chosen byAuthMethod. Each entry's seal binds its name+kind as AEAD associated data; the DEK-wrap binds a fixed domain string.
Providers
AuthMethod |
Provider | KEK source | Status |
PinCode |
PinProvider |
Argon2id(pin, salt) (m=19 MiB, t=2, p=1) |
implemented |
OsKeyring |
OsKeyringProvider |
random secret in the OS keyring (Secret Service) | implemented, feature os-keyring (backend + live test are the consumer's; live test #[ignore]d) |
Oidc |
OidcOpenBaoProvider |
OIDC login → OpenBao releases the key | design-only stub |
| (TPM) | TpmProvider |
TPM-sealed KEK | design-only stub |
| (FIDO2) | Fido2Provider |
FIDO2 hmac-secret → KEK |
design-only stub |
Mtls, StaticToken |
— | these are stored credential kinds, not unlock methods | n/a |
Stubs return VaultError::ProviderUnimplemented — the trait admits them; no crypto is faked.
The invariants
- Unlock = file → memory.
Vault::unlockdecrypts into RAM; plaintext is never written back to disk. - Zeroize.
SecretString/SecretEntrywipe on drop / onVault::lock. - No leaks. No secret type implements
Serialize, a revealingDebug, orDisplay. The only UI-facing view isVaultState— lock flags, method, attempts-remaining, and entry names + kinds, never a secret byte. Only that may go into astate_json. - Low-entropy PIN backstop. A persisted
Lockoutcounter (default 5) caps failed unlocks and survives process restarts; exhausting it refuses even the correct PIN.
On-disk format
A single JSON header (written 0600, temp-file + atomic rename) holding only non-secret metadata + ciphertext: version, method, provider params (Argon2 salt+cost, or keyring coordinates), the wrapped DEK, the lockout counter, and each entry's kind + sealed blob. A test asserts the file never contains a secret's plaintext bytes.
Entry types (extensible)
MtlsClientIdentity { cert_pem, key_pem }, SparkToken(String), and Opaque(Vec<u8>) (the extension point — e.g. a nornir-auth-client TokenStore bundle, which the vault is designed to back).