# 🤖 Locked vaults — biometric encryption for the Vault app (and the operator vault)

The Vault mini-app (`web/static/apps/vault.js`) already gives any thread an
E2EE store of named secrets: values only ever exist as ciphertext under the
thread key, the server sees nothing, and every add/change/remove is audited.
This doc designs the **lock layer** on top: a vault whose *values* can only be
read after a fresh **biometric unlock** — Touch ID / Face ID via a passkey in
the browser, Touch ID via the keychain in the `eleven` CLI — plus the CLI
verbs and the offline restore that let the team run its **operator secrets**
on it, replacing the sops+age S3-bundle vault for day-to-day sharing.

Two audiences, one feature:

- **Members**: "lock this vault" turns a family's shared-passwords vault into
  something a stolen, signed-in phone can't read.
- **Operators**: a locked group vault in a bot-less ops space becomes the
  shared operator vault — the sharing pain of `sync.sh` bundle slots
  disappears, and the root of trust moves from a plaintext age key on disk to
  keys behind biometrics.

## Why the thread key alone isn't a lock

Everything in a thread is already sealed under the thread key — but every
member's *signed-in session* holds that key. A dumped IndexedDB, a copied
`~/.eleven` directory, or malware in a logged-in browser reads the whole
vault. A biometric prompt drawn over that would be decoration.

So a locked vault introduces key material that the session **does not hold**:

- A **vault keypair** (ECDH P-256, the curve everything here already uses).
  The public half sits in the vault's state (inside the E2EE thread, so only
  members ever see even that). The private half exists *only* inside keyring
  wraps, each openable solely by a biometric-gated key.
- Values are **ECIES-sealed to the vault public key** (the exact
  `ephPub(65)||iv(12)||ct` format of `crypto.js`'s thread-key wrap, which the
  Go side and home's crypto already speak). Sealing needs no unlock —
  **anyone in the thread can add or update a secret without biometrics**,
  because encrypting to a public key is free. Only *reading* needs the
  private half.

The layering, outermost first: transport TLS → thread-key AES-GCM (what every
event already gets) → per-value ECIES to the vault pubkey. A compromised
server sees nothing (unchanged); a compromised *member session* now sees
names and the audit trail but no values.

## The keyring

Vault state (already `{ items, log }`) gains:

```
keyring: {
  v: 1,
  pub: <b64url raw P-256 public key>,
  wraps: [ { id, kind, uid, label, credId?, wrapped, by, byName, at } ],
}
```

Access follows **thread membership**, not a request queue — so the keyring is
just a flat list of `wraps`, with no pending-request state. Every wrap now
carries a `uid` naming the member it belongs to: the reducer uses it to drop a
member's soft handoff the instant they self-enrol a durable wrap, and the UI
uses it to attribute keys to people.

Each **wrap** is the vault private key AES-GCM-encrypted under one unlocker's
KEK (`b64url(iv||ct)`) — *except* the `member` kind, which is the private key
ECIES-sealed to a member's identity, not KEK-wrapped:

- `kind: "passkey"` — the KEK is HKDF-derived from the passkey's **WebAuthn
  PRF output** (`kekFromPRF`, exactly home's `home/web/static/crypto.js`
  pattern; PRF eval input `"eleven-vault-kek-v1"`). `credId` is stored so
  unlock can put the credential in `allowCredentials`. The ceremony is fully
  client-side — PRF evaluation happens on the authenticator against a local
  challenge (home's `backup-core.js` already does this); **no server-side
  WebAuthn endpoints, no server change at all**.
- `kind: "keychain"` — the CLI: the KEK is 32 random bytes stored in the
  macOS login keychain, released only after an **in-process
  LocalAuthentication check** (Touch ID, or the account password) — *not* an
  enclave-bound `kSecAccessControlBiometryCurrentSet` ACL, which would need a
  signed binary with an application-identifier entitlement and so break the
  anyone-can-`go build` rule (that's the #950 hardening upgrade; the CLI's
  `vaultkek_darwin.go` doc-comment carries the honest detail). The item is
  encrypted at rest by the login keychain, and the LA gate is an in-process
  check — so it raises the bar against a locked/stolen machine, but not against
  code already running as the operator. `label` names the operator/machine
  ("paul @ mac-studio").
- `kind: "member"` — a **soft handoff**, not a biometric wrap at all: the vault
  private key **ECIES-sealed to a member's roster (identity) public key**
  (`sealVaultKeyTo`), openable by that member's own signed-in session key with
  no biometric, because it's sealed to *who they are*, not to a KEK. It exists
  only to bootstrap a member into a locked vault — it's dropped (by `uid`) the
  moment they self-enrol a durable `passkey`/`keychain` wrap. *Once a passkey is
  set up, it requires the passkey thereafter.*

Access follows thread membership: **sharing a vault is adding the Vault app to
a thread** (a DM or group), and everyone in that thread is entitled to the
vault — a personal vault is just your own single-member thread. There is **no
"request access" anywhere**; a member either already holds a handoff from lock
time or an insider *gives* them one. (Personal vaults surface none of this: the
header lock chip — lock / unlock / "lock now" — is the whole story.)

Locking, enrolling, giving, and revoking are ordinary vault events, folded by
the same deterministic reducer (malformed → no-op, so hostile events stay safe
inputs, which `groupCapable` requires):

- `vault.lock { aid, pub, wrap, handoffs: [ <member wrap>, … ], sealed: { <itemId>: <sealedValue> } }`
  — the locker generates the keypair, re-seals every existing value
  client-side, ships the keyring's first (durable) wrap **and** a `handoffs`
  array: one `member`-kind wrap for every *other* current thread member, each
  the vault private key sealed to that member's roster public key
  (`sealVaultKeyTo`). **Bots (`is_agent`) are excluded** — a locked vault must
  never hand its key to a machine member, whose private key lives on a sidecar
  box; keyless pending invitees are skipped too. Reducer: install the keyring
  (the durable wrap + every handoff), swap each item's `value` for `sealed`. A
  vault with a keyring never folds plaintext values back in.
- `vault.enroll { aid, wrap }` — a member **self-enrols** a durable wrap. A
  member holding a `member` handoff opens it with their own session (roster)
  private key — no biometric, it's sealed to their identity — verifies it opens
  the vault, mints their own passkey/keychain KEK, wraps the vault key under it,
  and emits this event. The reducer **drops that member's `member` handoff**
  (matched by `uid`) as it adds the durable wrap, so from then on they need
  their passkey/Touch ID. Enrolling an *additional* device of your own works the
  same way from a device that already holds the key.
- `vault.give { aid, wrap: <member handoff> }` — an already-unlocked member
  seals the vault key to a *thread member who has no wrap* (joined after lock
  time, or lost every device): one biometric to unwrap the vault key, then
  re-seal it to that member's roster public key as a fresh `member` handoff they
  self-enrol from. This is an insider **giving** access, never an outsider
  requesting it — it replaces the old request+grant round-trip.
- `vault.revoke { aid, wrapId }` — drop a wrap. Never the last wrap (it would
  strand the vault keyless). Housekeeping, **not** crypto revocation (see "What
  this does not do").
- `vault.add` / `vault.change` carry `sealed` instead of `value` once a
  keyring exists. The reducer accepts exactly one of the two; a plaintext
  `value` arriving at a locked vault (a stale client) folds but renders
  flagged as unsealed, so an unlocked member can re-seal it.

Unlocked state is **memory only**: the web holds the unwrapped private key in
module scope (today as raw pkcs8 bytes, re-imported per unseal — a follow-up
should hold it as a non-extractable `CryptoKey` so an in-page script can't
exfiltrate it) with an auto-forget timer and an explicit Lock control; the CLI
holds it for the life of one command. Nothing unlocked is ever persisted. The
window is same-origin-reachable until auto-forget — an XSS on the space could
use the live key while it's cached; the ops space is bot-less and
operators-only precisely to keep that surface near-zero.

## The `eleven` CLI end

New verbs on the existing CLI (`eleven/`), which is already a full E2EE
member with sessions in `~/.eleven`:

```
eleven vault list                     # names + audit lines, never values
eleven vault get <name>               # one Touch ID → prints the value
eleven vault set <name>               # hidden prompt (never argv/echo) → seal to pub, no unlock needed
eleven vault set -from-file f <name>  # dotenv payloads (ops envs; UTF-8 text)
eleven vault enroll                   # self-enrol: open your member handoff with your session key, wrap under a Touch ID keychain KEK, post vault.enroll
eleven vault give <who>               # enrolled member hands a thread member their own handoff (vault.give)
eleven vault list/get -db <file>      # OFFLINE, against a raw DB replica — no server
eleven vault -chat ops …              # a group chat's shared vault
```

`get` reads the keychain KEK behind a LocalAuthentication check (Touch ID,
or the account password on Macs without it), unwraps the vault private key,
unseals one value, prints it, forgets everything. `set` needs no unlock at
all. Secret values never appear in argv, shell history, or a Claude
transcript — same contract as the sops vault's `add-secret.sh` (hidden
prompts, operator's own terminal).

`enroll` is a **self-enrol**, not a request: it finds the `member` handoff your
own session key can already open (no biometric to read it, since it's sealed to
your identity), verifies it opens the vault, then mints a Touch ID keychain KEK,
wraps the vault key under it, and posts `vault.enroll` — which drops that
handoff, so a Touch ID is required from then on. `give <who>` is the insider
side: an already-enrolled operator seals the vault key to a thread member's
roster key so a latecomer gets a handoff to self-enrol from. Neither verb ever
"requests" — access is a property of thread membership.

Honest scope of the macOS gate: it is LocalAuthentication **in this
process** plus keychain encryption at rest — not a Secure-Enclave-bound
item. Enclave binding (`kSecAccessControlBiometryCurrentSet` in the
data-protection keychain) requires a signed binary carrying an
application-identifier entitlement, and a CLI that only works signed would
break the anyone-can-`go build` rule; signed release builds can upgrade to
the enclave path later without changing the wrap format (the KEK is 32 raw
bytes either way — #950's hardening track). The shim is cgo, gated
`//go:build darwin && cgo` with a refuse-loudly fallback, so
`CGO_ENABLED=0 go build ./...` — the server's no-cgo rule — stays green.

## Offline restore — the hard gate

The sops vault's unbeatable property is that it works with every server dead:
`git clone` + age key = secrets. The operator vault must match it before any
real secret moves, otherwise we've stored the keys to fix the box inside the
box.

`eleven vault list -db <file>` / `get -db <file>` read a raw sqlite replica —
what the replication ladder streams to S3, or a copy of `/data/<host>.db` —
directly: find the member row by the session's public key, unwrap the wrapped
thread key with the private key from `~/.eleven`, decrypt the thread's
messages, fold the vault reducer, unseal values via the keychain KEK (Touch
ID still required — offline doesn't skip the lock; deliberately read-only —
writes belong on the live thread). Zero network, zero server processes. All
the crypto is client-side already; this is plumbing, not new trust. The
crypto-complete version of this drill runs in CI (`vault_offline_test.go`
seeds a real DB through db.go and reads it back with server code out of the
loop).

**Gate:** the live drill — S3 fetch (litestream restore / a copied DB) →
`vault get -db` → values verified — must pass (like the 2026-07-12 sops
drill) before the operator vault becomes canonical. Until then sops remains
the vault of record and this runs in parallel.

## What this does not do

- **No rekey-on-leave, no retroactive revocation.** The thread key isn't
  rotated on removal (deliberate, `db.go`), and an ex-member *saw* the
  values. Removing an operator still means rotating the real secrets —
  identical to sops membership today. `vault.revoke` is hygiene, not crypto.
- **No protection from a hostile *unlocked* member** — same as any shared
  store. The audit trail (who touched what, when) is the mitigation.
- **No hiding of names/metadata from members.** Secret *names* and the audit
  trail stay readable by all thread members without biometrics — that's the
  useful directory. Values are the protected class.
- **The handoff window trades a biometric for no approval.** Between
  `vault.lock`/`vault.give` and a member's self-enrol, that member's `member`
  handoff is openable by their **session key alone** — no biometric — because
  it's sealed to their identity, not to a KEK. So in that brief window, anything
  that can use their signed-in session private key (a dumped `~/.eleven`,
  malware in their logged-in browser) can open the vault key. The moment they
  self-enrol, the handoff is dropped and a passkey/Touch ID is required
  thereafter. This is the deliberate cost of a no-approval, membership-based
  flow — the old request/grant model made you enrol a biometric *first*, this
  one lets you in with your identity and biometric-gates you on the way out.
- **Multi-device relies on passkey sync.** A durable wrap is bound to one
  authenticator, so a member reads values on a second device only if the passkey
  syncs (iCloud Keychain and the like) or they re-give / "Add a passkey on this
  device" there — the same limit the old model had.
- **A bogus handoff is griefing, not disclosure.** A member can seal a junk
  `member` handoff to you (same risk class as the old grant); self-enrol catches
  it, because it verifies the handoff actually opens a real sealed value before
  wrapping it under your KEK — a bad handoff fails loudly rather than enrolling a
  dead key. Confidentiality is never at stake, only your ability to enrol from
  that one handoff.
- **Rollback resistance is advisory.** A hostile server can drop or replay
  ciphertext events; key-transparency/tamper-evidence work (#573) is the
  broader answer, not this layer.

## The operator vault (ops migration)

- **A dedicated, bot-less space.** Bots are E2EE members whose keys live on
  the operator-run sidecar machine — today the same box as the instances. The
  ops vault thread must contain **no bot**, and the belt-and-braces call is a
  space with **no sidecar at all** (nothing on the box can ever hold even
  thread-level keys for it), member invites off, admins = operators. Bonus:
  no sidecar also means the instance can ride the hibernation/replication
  machinery to S3 unimpeded.
- **Replication class: streamed**, so the S3 replica trails writes by
  seconds, and the restore drill exercises the true recovery path.
- **What moves in**: the `/data/*.env` files and service tokens the sops
  vault holds today — one vault item per env file (`set --from-file`).
- **What NEVER moves in** (unchanged from the sops rule, plus the new roots
  of trust): the release-signing + Sparkle keys, human SSH/WireGuard keys and
  passkeys, the S3/restore credentials, each operator's `~/.eleven` session
  files and keychain KEKs, canary envs. Bootstrap material lives in
  1Password/offline — a vault must not contain the keys that boot it.
- **Sessions-are-public still binds**: values enter via the app's form or the
  CLI's hidden prompt in the operator's own terminal — never through a Claude
  session, never echoed. Claude is never a member of the ops vault thread.
- **Parallel run**: seed copies, drill the restore, only then flip
  canonical-ness; sops stays intact as the fallback for a full cycle.

## Native (iOS / Mac)

App threads render through the webview on both native apps, so the locked
vault's *surface* (masked rows, sealed adds, self-enrol from a handoff, give)
ships to native with the web change. The **unlock** path is the scoped exception: WebAuthn
PRF inside WKWebView isn't dependable across OS versions, so native unlock
ships as a follow-up — a `hostBridge` biometric provider backed by a
Secure-Enclave-resident P-256 key (the vault keypair's curve, so an enclave
key can hold a wrap directly; `LocalAuthentication` for the prompt). Until
then, native members can read names/audit and *add* secrets (sealing needs no
unlock); reading values needs a browser or the CLI. That narrowing is
deliberate and temporary, not an omission.

## Phasing

1. **Web lock layer** (#946) — `vaultcrypto.js` (pure, Node-tested like
   home's crypto) + `vault.js` events/UI: lock (with per-member handoffs),
   passkey enroll/unlock, sealed add/read, self-enrol + give. Changelog entry
   (user-visible).
2. **CLI verbs** (#947) — `eleven vault list/get/set` incl. group-vault
   discovery.
3. **CLI biometrics** (#948) — keychain KEK + self-enrol (`enroll`) / `give`.
4. **Offline restore** (#949) — `eleven vault restore --db` + drill runbook.
5. **Native unlock** (#950) — hostBridge + Secure Enclave (follow-up).
6. **Ops migration** (#951) — stand up the space, seed, drill, flip.
   (Operational; RUNBOOK.md change, not repo code.)

Known gotcha to carry: some authenticator/browser pairs disagree on PRF
(the 1Password-on-macOS mismatch home hit) — enrolment must verify PRF works
end-to-end *before* posting the wrap, and the failure copy steers to another
enrolled method, matching home's `no-prf` handling.
