nordisk/edda
.nornir/vault-design.md
# 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 in `workspace_nordisk/.nornir/korp-client-vault-design-2026-07-30.md`. Sibling: [`vault-guide.md`](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 by
`AuthMethod`. 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::unlock` decrypts into RAM; plaintext is
**never** written back to disk.
* **Zeroize.** `SecretString` / `SecretEntry` wipe on drop / on `Vault::lock`.
* **No leaks.** No secret type implements `Serialize`, a revealing `Debug`, or
`Display`. The only UI-facing view is `VaultState` — lock flags, method,
attempts-remaining, and entry *names + kinds*, **never** a secret byte. Only
that may go into a `state_json`.
* **Low-entropy PIN backstop.** A persisted `Lockout` counter (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).