eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Locked vaults β€” biometric encryption for the Vault app (and the operator vault)
2
3The Vault mini-app (`web/static/apps/vault.js`) already gives any thread an
4E2EE store of named secrets: values only ever exist as ciphertext under the
5thread key, the server sees nothing, and every add/change/remove is audited.
6This doc designs the **lock layer** on top: a vault whose *values* can only be
7read after a fresh **biometric unlock** β€” Touch ID / Face ID via a passkey in
8the browser, Touch ID via the keychain in the `eleven` CLI β€” plus the CLI
9verbs and the offline restore that let the team run its **operator secrets**
10on it, replacing the sops+age S3-bundle vault for day-to-day sharing.
11
12Two 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
23Everything in a thread is already sealed under the thread key β€” but every
24member'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
26vault. A biometric prompt drawn over that would be decoration.
27
28So 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
41The layering, outermost first: transport TLS β†’ thread-key AES-GCM (what every
42event already gets) β†’ per-value ECIES to the vault pubkey. A compromised
43server sees nothing (unchanged); a compromised *member session* now sees
44names and the audit trail but no values.
45
46## The keyring
47
48Vault state (already `{ items, log }`) gains:
49
50```
51keyring: {
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
58Access follows **thread membership**, not a request queue β€” so the keyring is
59just a flat list of `wraps`, with no pending-request state. Every wrap now
60carries a `uid` naming the member it belongs to: the reducer uses it to drop a
61member's soft handoff the instant they self-enrol a durable wrap, and the UI
62uses it to attribute keys to people.
63
64Each **wrap** is the vault private key AES-GCM-encrypted under one unlocker's
65KEK (`b64url(iv||ct)`) β€” *except* the `member` kind, which is the private key
66ECIES-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
94Access follows thread membership: **sharing a vault is adding the Vault app to
95a thread** (a DM or group), and everyone in that thread is entitled to the
96vault β€” 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
98time or an insider *gives* them one. (Personal vaults surface none of this: the
99header lock chip β€” lock / unlock / "lock now" β€” is the whole story.)
100
101Locking, enrolling, giving, and revoking are ordinary vault events, folded by
102the same deterministic reducer (malformed β†’ no-op, so hostile events stay safe
103inputs, 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
137Unlocked state is **memory only**: the web holds the unwrapped private key in
138module scope (today as raw pkcs8 bytes, re-imported per unseal β€” a follow-up
139should hold it as a non-extractable `CryptoKey` so an in-page script can't
140exfiltrate it) with an auto-forget timer and an explicit Lock control; the CLI
141holds it for the life of one command. Nothing unlocked is ever persisted. The
142window is same-origin-reachable until auto-forget β€” an XSS on the space could
143use the live key while it's cached; the ops space is bot-less and
144operators-only precisely to keep that surface near-zero.
145
146## The `eleven` CLI end
147
148New verbs on the existing CLI (`eleven/`), which is already a full E2EE
149member with sessions in `~/.eleven`:
150
151```
152eleven vault list # names + audit lines, never values
153eleven vault get <name> # one Touch ID β†’ prints the value
154eleven vault set <name> # hidden prompt (never argv/echo) β†’ seal to pub, no unlock needed
155eleven vault set -from-file f <name> # dotenv payloads (ops envs; UTF-8 text)
156eleven vault enroll # self-enrol: open your member handoff with your session key, wrap under a Touch ID keychain KEK, post vault.enroll
157eleven vault give <who> # enrolled member hands a thread member their own handoff (vault.give)
158eleven vault list/get -db <file> # OFFLINE, against a raw DB replica β€” no server
159eleven vault -chat ops … # a group chat's shared vault
160```
161
162`get` reads the keychain KEK behind a LocalAuthentication check (Touch ID,
163or the account password on Macs without it), unwraps the vault private key,
164unseals one value, prints it, forgets everything. `set` needs no unlock at
165all. Secret values never appear in argv, shell history, or a Claude
166transcript β€” same contract as the sops vault's `add-secret.sh` (hidden
167prompts, operator's own terminal).
168
169`enroll` is a **self-enrol**, not a request: it finds the `member` handoff your
170own session key can already open (no biometric to read it, since it's sealed to
171your identity), verifies it opens the vault, then mints a Touch ID keychain KEK,
172wraps the vault key under it, and posts `vault.enroll` β€” which drops that
173handoff, so a Touch ID is required from then on. `give <who>` is the insider
174side: an already-enrolled operator seals the vault key to a thread member's
175roster key so a latecomer gets a handoff to self-enrol from. Neither verb ever
176"requests" β€” access is a property of thread membership.
177
178Honest scope of the macOS gate: it is LocalAuthentication **in this
179process** plus keychain encryption at rest β€” not a Secure-Enclave-bound
180item. Enclave binding (`kSecAccessControlBiometryCurrentSet` in the
181data-protection keychain) requires a signed binary carrying an
182application-identifier entitlement, and a CLI that only works signed would
183break the anyone-can-`go build` rule; signed release builds can upgrade to
184the enclave path later without changing the wrap format (the KEK is 32 raw
185bytes 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
191The 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
193real secret moves, otherwise we've stored the keys to fix the box inside the
194box.
195
196`eleven vault list -db <file>` / `get -db <file>` read a raw sqlite replica β€”
197what the replication ladder streams to S3, or a copy of `/data/<host>.db` β€”
198directly: find the member row by the session's public key, unwrap the wrapped
199thread key with the private key from `~/.eleven`, decrypt the thread's
200messages, fold the vault reducer, unseal values via the keychain KEK (Touch
201ID still required β€” offline doesn't skip the lock; deliberately read-only β€”
202writes belong on the live thread). Zero network, zero server processes. All
203the crypto is client-side already; this is plumbing, not new trust. The
204crypto-complete version of this drill runs in CI (`vault_offline_test.go`
205seeds a real DB through db.go and reads it back with server code out of the
206loop).
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
210drill) before the operator vault becomes canonical. Until then sops remains
211the 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
274App threads render through the webview on both native apps, so the locked
275vault's *surface* (masked rows, sealed adds, self-enrol from a handoff, give)
276ships to native with the web change. The **unlock** path is the scoped exception: WebAuthn
277PRF inside WKWebView isn't dependable across OS versions, so native unlock
278ships as a follow-up β€” a `hostBridge` biometric provider backed by a
279Secure-Enclave-resident P-256 key (the vault keypair's curve, so an enclave
280key can hold a wrap directly; `LocalAuthentication` for the prompt). Until
281then, native members can read names/audit and *add* secrets (sealing needs no
282unlock); reading values needs a browser or the CLI. That narrowing is
283deliberate and temporary, not an omission.
284
285## Phasing
286
2871. **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).
2912. **CLI verbs** (#947) β€” `eleven vault list/get/set` incl. group-vault
292 discovery.
2933. **CLI biometrics** (#948) β€” keychain KEK + self-enrol (`enroll`) / `give`.
2944. **Offline restore** (#949) β€” `eleven vault restore --db` + drill runbook.
2955. **Native unlock** (#950) β€” hostBridge + Secure Enclave (follow-up).
2966. **Ops migration** (#951) β€” stand up the space, seed, drill, flip.
297 (Operational; RUNBOOK.md change, not repo code.)
298
299Known gotcha to carry: some authenticator/browser pairs disagree on PRF
300(the 1Password-on-macOS mismatch home hit) β€” enrolment must verify PRF works
301end-to-end *before* posting the wrap, and the failure copy steers to another
302enrolled method, matching home's `no-prf` handling.
303