eleven / ocho Public

docs: locked vaults — biometric encryption for the Vault app + the operator vault (#952)

* docs: locked vaults — biometric encryption for the Vault app + the operator vault

Design for the lock layer over the existing Vault mini-app: values
ECIES-sealed to a vault public key whose private half lives only in
biometric-gated keyring wraps (passkey PRF in the browser, Touch ID
keychain in the eleven CLI), plus the CLI verbs, the offline restore
gate, and the plan to run operator secrets on it. Phases: #946-#951.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: match the shipped CLI shape (-db on list/get, LocalAuthentication gate scope)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell ad506740f8052bf06ad270abf228763cc6007e36 parent 1263f46
1 file changed, +237 −0
  • docs/locked-vaults.md +237 −0
diff --git a/docs/locked-vaults.md b/docs/locked-vaults.md
new file mode 100644
index 0000000..65e2348
--- /dev/null
+++ b/docs/locked-vaults.md
@@ -0,0 +1,237 @@
+# 🤖 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, label, credId?, wrapped, by, byName, at } ],
+ requests: [ { rid, pub, ephWrapped, kind, credId?, label, by, byName, at,
+ granted? } ],
+}
+```
+
+Each **wrap** is the vault private key (pkcs8 bytes) AES-GCM-encrypted under
+one unlocker's KEK, `b64url(iv||ct)`:
+
+- `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 with a **biometry access control**
+ (`kSecAccessControlBiometryCurrentSet`), so every read is a Touch ID
+ prompt. `label` names the operator/machine ("paul @ mac-studio").
+
+Locking, enrolling, and granting 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, sealed: { <itemId>: <sealedValue> } }` — the
+ locker generates the keypair, re-seals every existing value client-side,
+ and ships the keyring's first wrap. Reducer: install keyring, swap each
+ item's `value` for `sealed`. A vault with a keyring never folds plaintext
+ values back in.
+- `vault.enroll { aid, wrap }` — add a wrap. Enrolling a *new device of your
+ own* requires being unlocked on an old one (you wrap the private key you
+ hold); a member with no access uses request/grant below.
+- `vault.revoke { aid, wrapId }` — drop a wrap. Housekeeping, **not** crypto
+ revocation (see "What this does not do").
+- `vault.request { aid, rid, pub, ephWrapped, kind, credId?, label }` — a
+ member wanting access enrols their unlocker *first* (creates their KEK),
+ generates an **ephemeral P-256 keypair**, publishes the public half, and
+ stores the ephemeral private half in the event itself sealed under their
+ own KEK (`ephWrapped`) — so the pending request survives reloads and other
+ devices, yet only they can complete it.
+- `vault.grant { aid, rid, granted: <ECIES(vaultPriv → requester ephPub)> }`
+ — any *unlocked* member approves (one biometric): unwrap vault key, wrap to
+ the requester's ephemeral pub. The requester's client then (their
+ biometric) opens `ephWrapped` → ephemeral priv → `granted` → vault priv,
+ and immediately emits its own durable `vault.enroll` wrap. Requests and
+ their grants clear from state once enrolment lands.
+- `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 imported (non-
+extractable) private key in module scope 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 `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 # mint the keychain KEK + post vault.request
+eleven vault approve / finish # the grant ceremony's two ends
+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).
+
+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.
+- **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, request/grant) 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, passkey enroll/unlock, sealed
+ add/read, request/grant. Changelog entry (user-visible).
+2. **CLI verbs** (#947) — `eleven vault list/get/set` incl. group-vault
+ discovery.
+3. **CLI biometrics** (#948) — keychain KEK + `enroll`/`approve` ceremony.
+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.