eleven / messenger Public
π€ 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.shbundle 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)||ctformat ofcrypto.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'shome/web/static/crypto.jspattern; PRF eval input"eleven-vault-kek-v1").credIdis stored so unlock can put the credential inallowCredentials. The ceremony is fully client-side β PRF evaluation happens on the authenticator against a local challenge (home'sbackup-core.jsalready 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-boundkSecAccessControlBiometryCurrentSetACL, which would need a signed binary with an application-identifier entitlement and so break the anyone-can-go buildrule (that's the #950 hardening upgrade; the CLI'svaultkek_darwin.godoc-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.labelnames 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 (byuid) the moment they self-enrol a durablepasskey/keychainwrap. 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 ahandoffsarray: onemember-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'svalueforsealed. 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 amemberhandoff 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'smemberhandoff (matched byuid) 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 freshmemberhandoff 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.changecarrysealedinstead ofvalueonce a keyring exists. The reducer accepts exactly one of the two; a plaintextvaluearriving 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 vaultget 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.revokeis 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.giveand a member's self-enrol, that member'smemberhandoff 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
memberhandoff 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/*.envfiles 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
~/.elevensession 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
- Web lock layer (#946) β
vaultcrypto.js(pure, Node-tested like home's crypto) +vault.jsevents/UI: lock (with per-member handoffs), passkey enroll/unlock, sealed add/read, self-enrol + give. Changelog entry (user-visible). - CLI verbs (#947) β
eleven vault list/get/setincl. group-vault discovery. - CLI biometrics (#948) β keychain KEK + self-enrol (
enroll) /give. - Offline restore (#949) β
eleven vault restore --db+ drill runbook. - Native unlock (#950) β hostBridge + Secure Enclave (follow-up).
- 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.