| 1 | # π€ Locked vaults β biometric encryption for the Vault app (and the operator vault) |
| 2 | |
| 3 | The Vault mini-app (`web/static/apps/vault.js`) already gives any thread an |
| 4 | E2EE store of named secrets: values only ever exist as ciphertext under the |
| 5 | thread key, the server sees nothing, and every add/change/remove is audited. |
| 6 | This doc designs the **lock layer** on top: a vault whose *values* can only be |
| 7 | read after a fresh **biometric unlock** β Touch ID / Face ID via a passkey in |
| 8 | the browser, Touch ID via the keychain in the `eleven` CLI β plus the CLI |
| 9 | verbs and the offline restore that let the team run its **operator secrets** |
| 10 | on it, replacing the sops+age S3-bundle vault for day-to-day sharing. |
| 11 | |
| 12 | Two audiences, one feature: |
| 13 | |
| 14 | - **Members**: "lock this vault" turns a family's shared-passwords vault into |
| 15 | something a stolen, signed-in phone can't read. |
| 16 | - **Operators**: a locked group vault in a bot-less ops space becomes the |
| 17 | shared operator vault β the sharing pain of `sync.sh` bundle slots |
| 18 | disappears, and the root of trust moves from a plaintext age key on disk to |
| 19 | keys behind biometrics. |
| 20 | |
| 21 | ## Why the thread key alone isn't a lock |
| 22 | |
| 23 | Everything in a thread is already sealed under the thread key β but every |
| 24 | member's *signed-in session* holds that key. A dumped IndexedDB, a copied |
| 25 | `~/.eleven` directory, or malware in a logged-in browser reads the whole |
| 26 | vault. A biometric prompt drawn over that would be decoration. |
| 27 | |
| 28 | So a locked vault introduces key material that the session **does not hold**: |
| 29 | |
| 30 | - A **vault keypair** (ECDH P-256, the curve everything here already uses). |
| 31 | The public half sits in the vault's state (inside the E2EE thread, so only |
| 32 | members ever see even that). The private half exists *only* inside keyring |
| 33 | wraps, each openable solely by a biometric-gated key. |
| 34 | - Values are **ECIES-sealed to the vault public key** (the exact |
| 35 | `ephPub(65)||iv(12)||ct` format of `crypto.js`'s thread-key wrap, which the |
| 36 | Go side and home's crypto already speak). Sealing needs no unlock β |
| 37 | **anyone in the thread can add or update a secret without biometrics**, |
| 38 | because encrypting to a public key is free. Only *reading* needs the |
| 39 | private half. |
| 40 | |
| 41 | The layering, outermost first: transport TLS β thread-key AES-GCM (what every |
| 42 | event already gets) β per-value ECIES to the vault pubkey. A compromised |
| 43 | server sees nothing (unchanged); a compromised *member session* now sees |
| 44 | names and the audit trail but no values. |
| 45 | |
| 46 | ## The keyring |
| 47 | |
| 48 | Vault state (already `{ items, log }`) gains: |
| 49 | |
| 50 | ``` |
| 51 | keyring: { |
| 52 | v: 1, |
| 53 | pub: <b64url raw P-256 public key>, |
| 54 | wraps: [ { id, kind, uid, label, credId?, wrapped, by, byName, at } ], |
| 55 | } |
| 56 | ``` |
| 57 | |
| 58 | Access follows **thread membership**, not a request queue β so the keyring is |
| 59 | just a flat list of `wraps`, with no pending-request state. Every wrap now |
| 60 | carries a `uid` naming the member it belongs to: the reducer uses it to drop a |
| 61 | member's soft handoff the instant they self-enrol a durable wrap, and the UI |
| 62 | uses it to attribute keys to people. |
| 63 | |
| 64 | Each **wrap** is the vault private key AES-GCM-encrypted under one unlocker's |
| 65 | KEK (`b64url(iv||ct)`) β *except* the `member` kind, which is the private key |
| 66 | ECIES-sealed to a member's identity, not KEK-wrapped: |
| 67 | |
| 68 | - `kind: "passkey"` β the KEK is HKDF-derived from the passkey's **WebAuthn |
| 69 | PRF output** (`kekFromPRF`, exactly home's `home/web/static/crypto.js` |
| 70 | pattern; PRF eval input `"eleven-vault-kek-v1"`). `credId` is stored so |
| 71 | unlock can put the credential in `allowCredentials`. The ceremony is fully |
| 72 | client-side β PRF evaluation happens on the authenticator against a local |
| 73 | challenge (home's `backup-core.js` already does this); **no server-side |
| 74 | WebAuthn endpoints, no server change at all**. |
| 75 | - `kind: "keychain"` β the CLI: the KEK is 32 random bytes stored in the |
| 76 | macOS login keychain, released only after an **in-process |
| 77 | LocalAuthentication check** (Touch ID, or the account password) β *not* an |
| 78 | enclave-bound `kSecAccessControlBiometryCurrentSet` ACL, which would need a |
| 79 | signed binary with an application-identifier entitlement and so break the |
| 80 | anyone-can-`go build` rule (that's the #950 hardening upgrade; the CLI's |
| 81 | `vaultkek_darwin.go` doc-comment carries the honest detail). The item is |
| 82 | encrypted at rest by the login keychain, and the LA gate is an in-process |
| 83 | check β so it raises the bar against a locked/stolen machine, but not against |
| 84 | code already running as the operator. `label` names the operator/machine |
| 85 | ("paul @ mac-studio"). |
| 86 | - `kind: "member"` β a **soft handoff**, not a biometric wrap at all: the vault |
| 87 | private key **ECIES-sealed to a member's roster (identity) public key** |
| 88 | (`sealVaultKeyTo`), openable by that member's own signed-in session key with |
| 89 | no biometric, because it's sealed to *who they are*, not to a KEK. It exists |
| 90 | only to bootstrap a member into a locked vault β it's dropped (by `uid`) the |
| 91 | moment they self-enrol a durable `passkey`/`keychain` wrap. *Once a passkey is |
| 92 | set up, it requires the passkey thereafter.* |
| 93 | |
| 94 | Access follows thread membership: **sharing a vault is adding the Vault app to |
| 95 | a thread** (a DM or group), and everyone in that thread is entitled to the |
| 96 | vault β a personal vault is just your own single-member thread. There is **no |
| 97 | "request access" anywhere**; a member either already holds a handoff from lock |
| 98 | time or an insider *gives* them one. (Personal vaults surface none of this: the |
| 99 | header lock chip β lock / unlock / "lock now" β is the whole story.) |
| 100 | |
| 101 | Locking, enrolling, giving, and revoking are ordinary vault events, folded by |
| 102 | the same deterministic reducer (malformed β no-op, so hostile events stay safe |
| 103 | inputs, which `groupCapable` requires): |
| 104 | |
| 105 | - `vault.lock { aid, pub, wrap, handoffs: [ <member wrap>, β¦ ], sealed: { <itemId>: <sealedValue> } }` |
| 106 | β the locker generates the keypair, re-seals every existing value |
| 107 | client-side, ships the keyring's first (durable) wrap **and** a `handoffs` |
| 108 | array: one `member`-kind wrap for every *other* current thread member, each |
| 109 | the vault private key sealed to that member's roster public key |
| 110 | (`sealVaultKeyTo`). **Bots (`is_agent`) are excluded** β a locked vault must |
| 111 | never hand its key to a machine member, whose private key lives on a sidecar |
| 112 | box; keyless pending invitees are skipped too. Reducer: install the keyring |
| 113 | (the durable wrap + every handoff), swap each item's `value` for `sealed`. A |
| 114 | vault with a keyring never folds plaintext values back in. |
| 115 | - `vault.enroll { aid, wrap }` β a member **self-enrols** a durable wrap. A |
| 116 | member holding a `member` handoff opens it with their own session (roster) |
| 117 | private key β no biometric, it's sealed to their identity β verifies it opens |
| 118 | the vault, mints their own passkey/keychain KEK, wraps the vault key under it, |
| 119 | and emits this event. The reducer **drops that member's `member` handoff** |
| 120 | (matched by `uid`) as it adds the durable wrap, so from then on they need |
| 121 | their passkey/Touch ID. Enrolling an *additional* device of your own works the |
| 122 | same way from a device that already holds the key. |
| 123 | - `vault.give { aid, wrap: <member handoff> }` β an already-unlocked member |
| 124 | seals the vault key to a *thread member who has no wrap* (joined after lock |
| 125 | time, or lost every device): one biometric to unwrap the vault key, then |
| 126 | re-seal it to that member's roster public key as a fresh `member` handoff they |
| 127 | self-enrol from. This is an insider **giving** access, never an outsider |
| 128 | requesting it β it replaces the old request+grant round-trip. |
| 129 | - `vault.revoke { aid, wrapId }` β drop a wrap. Never the last wrap (it would |
| 130 | strand the vault keyless). Housekeeping, **not** crypto revocation (see "What |
| 131 | this does not do"). |
| 132 | - `vault.add` / `vault.change` carry `sealed` instead of `value` once a |
| 133 | keyring exists. The reducer accepts exactly one of the two; a plaintext |
| 134 | `value` arriving at a locked vault (a stale client) folds but renders |
| 135 | flagged as unsealed, so an unlocked member can re-seal it. |
| 136 | |
| 137 | Unlocked state is **memory only**: the web holds the unwrapped private key in |
| 138 | module scope (today as raw pkcs8 bytes, re-imported per unseal β a follow-up |
| 139 | should hold it as a non-extractable `CryptoKey` so an in-page script can't |
| 140 | exfiltrate it) with an auto-forget timer and an explicit Lock control; the CLI |
| 141 | holds it for the life of one command. Nothing unlocked is ever persisted. The |
| 142 | window is same-origin-reachable until auto-forget β an XSS on the space could |
| 143 | use the live key while it's cached; the ops space is bot-less and |
| 144 | operators-only precisely to keep that surface near-zero. |
| 145 | |
| 146 | ## The `eleven` CLI end |
| 147 | |
| 148 | New verbs on the existing CLI (`eleven/`), which is already a full E2EE |
| 149 | member with sessions in `~/.eleven`: |
| 150 | |
| 151 | ``` |
| 152 | eleven vault list # names + audit lines, never values |
| 153 | eleven vault get <name> # one Touch ID β prints the value |
| 154 | eleven vault set <name> # hidden prompt (never argv/echo) β seal to pub, no unlock needed |
| 155 | eleven vault set -from-file f <name> # dotenv payloads (ops envs; UTF-8 text) |
| 156 | eleven vault enroll # self-enrol: open your member handoff with your session key, wrap under a Touch ID keychain KEK, post vault.enroll |
| 157 | eleven vault give <who> # enrolled member hands a thread member their own handoff (vault.give) |
| 158 | eleven vault list/get -db <file> # OFFLINE, against a raw DB replica β no server |
| 159 | eleven vault -chat ops β¦ # a group chat's shared vault |
| 160 | ``` |
| 161 | |
| 162 | `get` reads the keychain KEK behind a LocalAuthentication check (Touch ID, |
| 163 | or the account password on Macs without it), unwraps the vault private key, |
| 164 | unseals one value, prints it, forgets everything. `set` needs no unlock at |
| 165 | all. Secret values never appear in argv, shell history, or a Claude |
| 166 | transcript β same contract as the sops vault's `add-secret.sh` (hidden |
| 167 | prompts, operator's own terminal). |
| 168 | |
| 169 | `enroll` is a **self-enrol**, not a request: it finds the `member` handoff your |
| 170 | own session key can already open (no biometric to read it, since it's sealed to |
| 171 | your identity), verifies it opens the vault, then mints a Touch ID keychain KEK, |
| 172 | wraps the vault key under it, and posts `vault.enroll` β which drops that |
| 173 | handoff, so a Touch ID is required from then on. `give <who>` is the insider |
| 174 | side: an already-enrolled operator seals the vault key to a thread member's |
| 175 | roster key so a latecomer gets a handoff to self-enrol from. Neither verb ever |
| 176 | "requests" β access is a property of thread membership. |
| 177 | |
| 178 | Honest scope of the macOS gate: it is LocalAuthentication **in this |
| 179 | process** plus keychain encryption at rest β not a Secure-Enclave-bound |
| 180 | item. Enclave binding (`kSecAccessControlBiometryCurrentSet` in the |
| 181 | data-protection keychain) requires a signed binary carrying an |
| 182 | application-identifier entitlement, and a CLI that only works signed would |
| 183 | break the anyone-can-`go build` rule; signed release builds can upgrade to |
| 184 | the enclave path later without changing the wrap format (the KEK is 32 raw |
| 185 | bytes either way β #950's hardening track). The shim is cgo, gated |
| 186 | `//go:build darwin && cgo` with a refuse-loudly fallback, so |
| 187 | `CGO_ENABLED=0 go build ./...` β the server's no-cgo rule β stays green. |
| 188 | |
| 189 | ## Offline restore β the hard gate |
| 190 | |
| 191 | The sops vault's unbeatable property is that it works with every server dead: |
| 192 | `git clone` + age key = secrets. The operator vault must match it before any |
| 193 | real secret moves, otherwise we've stored the keys to fix the box inside the |
| 194 | box. |
| 195 | |
| 196 | `eleven vault list -db <file>` / `get -db <file>` read a raw sqlite replica β |
| 197 | what the replication ladder streams to S3, or a copy of `/data/<host>.db` β |
| 198 | directly: find the member row by the session's public key, unwrap the wrapped |
| 199 | thread key with the private key from `~/.eleven`, decrypt the thread's |
| 200 | messages, fold the vault reducer, unseal values via the keychain KEK (Touch |
| 201 | ID still required β offline doesn't skip the lock; deliberately read-only β |
| 202 | writes belong on the live thread). Zero network, zero server processes. All |
| 203 | the crypto is client-side already; this is plumbing, not new trust. The |
| 204 | crypto-complete version of this drill runs in CI (`vault_offline_test.go` |
| 205 | seeds a real DB through db.go and reads it back with server code out of the |
| 206 | loop). |
| 207 | |
| 208 | **Gate:** the live drill β S3 fetch (litestream restore / a copied DB) β |
| 209 | `vault get -db` β values verified β must pass (like the 2026-07-12 sops |
| 210 | drill) before the operator vault becomes canonical. Until then sops remains |
| 211 | the vault of record and this runs in parallel. |
| 212 | |
| 213 | ## What this does not do |
| 214 | |
| 215 | - **No rekey-on-leave, no retroactive revocation.** The thread key isn't |
| 216 | rotated on removal (deliberate, `db.go`), and an ex-member *saw* the |
| 217 | values. Removing an operator still means rotating the real secrets β |
| 218 | identical to sops membership today. `vault.revoke` is hygiene, not crypto. |
| 219 | - **No protection from a hostile *unlocked* member** β same as any shared |
| 220 | store. The audit trail (who touched what, when) is the mitigation. |
| 221 | - **No hiding of names/metadata from members.** Secret *names* and the audit |
| 222 | trail stay readable by all thread members without biometrics β that's the |
| 223 | useful directory. Values are the protected class. |
| 224 | - **The handoff window trades a biometric for no approval.** Between |
| 225 | `vault.lock`/`vault.give` and a member's self-enrol, that member's `member` |
| 226 | handoff is openable by their **session key alone** β no biometric β because |
| 227 | it's sealed to their identity, not to a KEK. So in that brief window, anything |
| 228 | that can use their signed-in session private key (a dumped `~/.eleven`, |
| 229 | malware in their logged-in browser) can open the vault key. The moment they |
| 230 | self-enrol, the handoff is dropped and a passkey/Touch ID is required |
| 231 | thereafter. This is the deliberate cost of a no-approval, membership-based |
| 232 | flow β the old request/grant model made you enrol a biometric *first*, this |
| 233 | one lets you in with your identity and biometric-gates you on the way out. |
| 234 | - **Multi-device relies on passkey sync.** A durable wrap is bound to one |
| 235 | authenticator, so a member reads values on a second device only if the passkey |
| 236 | syncs (iCloud Keychain and the like) or they re-give / "Add a passkey on this |
| 237 | device" there β the same limit the old model had. |
| 238 | - **A bogus handoff is griefing, not disclosure.** A member can seal a junk |
| 239 | `member` handoff to you (same risk class as the old grant); self-enrol catches |
| 240 | it, because it verifies the handoff actually opens a real sealed value before |
| 241 | wrapping it under your KEK β a bad handoff fails loudly rather than enrolling a |
| 242 | dead key. Confidentiality is never at stake, only your ability to enrol from |
| 243 | that one handoff. |
| 244 | - **Rollback resistance is advisory.** A hostile server can drop or replay |
| 245 | ciphertext events; key-transparency/tamper-evidence work (#573) is the |
| 246 | broader answer, not this layer. |
| 247 | |
| 248 | ## The operator vault (ops migration) |
| 249 | |
| 250 | - **A dedicated, bot-less space.** Bots are E2EE members whose keys live on |
| 251 | the operator-run sidecar machine β today the same box as the instances. The |
| 252 | ops vault thread must contain **no bot**, and the belt-and-braces call is a |
| 253 | space with **no sidecar at all** (nothing on the box can ever hold even |
| 254 | thread-level keys for it), member invites off, admins = operators. Bonus: |
| 255 | no sidecar also means the instance can ride the hibernation/replication |
| 256 | machinery to S3 unimpeded. |
| 257 | - **Replication class: streamed**, so the S3 replica trails writes by |
| 258 | seconds, and the restore drill exercises the true recovery path. |
| 259 | - **What moves in**: the `/data/*.env` files and service tokens the sops |
| 260 | vault holds today β one vault item per env file (`set --from-file`). |
| 261 | - **What NEVER moves in** (unchanged from the sops rule, plus the new roots |
| 262 | of trust): the release-signing + Sparkle keys, human SSH/WireGuard keys and |
| 263 | passkeys, the S3/restore credentials, each operator's `~/.eleven` session |
| 264 | files and keychain KEKs, canary envs. Bootstrap material lives in |
| 265 | 1Password/offline β a vault must not contain the keys that boot it. |
| 266 | - **Sessions-are-public still binds**: values enter via the app's form or the |
| 267 | CLI's hidden prompt in the operator's own terminal β never through a Claude |
| 268 | session, never echoed. Claude is never a member of the ops vault thread. |
| 269 | - **Parallel run**: seed copies, drill the restore, only then flip |
| 270 | canonical-ness; sops stays intact as the fallback for a full cycle. |
| 271 | |
| 272 | ## Native (iOS / Mac) |
| 273 | |
| 274 | App threads render through the webview on both native apps, so the locked |
| 275 | vault's *surface* (masked rows, sealed adds, self-enrol from a handoff, give) |
| 276 | ships to native with the web change. The **unlock** path is the scoped exception: WebAuthn |
| 277 | PRF inside WKWebView isn't dependable across OS versions, so native unlock |
| 278 | ships as a follow-up β a `hostBridge` biometric provider backed by a |
| 279 | Secure-Enclave-resident P-256 key (the vault keypair's curve, so an enclave |
| 280 | key can hold a wrap directly; `LocalAuthentication` for the prompt). Until |
| 281 | then, native members can read names/audit and *add* secrets (sealing needs no |
| 282 | unlock); reading values needs a browser or the CLI. That narrowing is |
| 283 | deliberate and temporary, not an omission. |
| 284 | |
| 285 | ## Phasing |
| 286 | |
| 287 | 1. **Web lock layer** (#946) β `vaultcrypto.js` (pure, Node-tested like |
| 288 | home's crypto) + `vault.js` events/UI: lock (with per-member handoffs), |
| 289 | passkey enroll/unlock, sealed add/read, self-enrol + give. Changelog entry |
| 290 | (user-visible). |
| 291 | 2. **CLI verbs** (#947) β `eleven vault list/get/set` incl. group-vault |
| 292 | discovery. |
| 293 | 3. **CLI biometrics** (#948) β keychain KEK + self-enrol (`enroll`) / `give`. |
| 294 | 4. **Offline restore** (#949) β `eleven vault restore --db` + drill runbook. |
| 295 | 5. **Native unlock** (#950) β hostBridge + Secure Enclave (follow-up). |
| 296 | 6. **Ops migration** (#951) β stand up the space, seed, drill, flip. |
| 297 | (Operational; RUNBOOK.md change, not repo code.) |
| 298 | |
| 299 | Known gotcha to carry: some authenticator/browser pairs disagree on PRF |
| 300 | (the 1Password-on-macOS mismatch home hit) β enrolment must verify PRF works |
| 301 | end-to-end *before* posting the wrap, and the failure copy steers to another |
| 302 | enrolled method, matching home's `no-prf` handling. |
| 303 | |