eleven / messenger Public
π€ Retiring sops β the shared operator vault on Eleven
> π€ Historical record β not a procedure. The sops vault is retired and > the standing store is Bitwarden; what scripts read from it is pinned by > the vault contract (deploy/vault-items.sh). Operate via ./deploy/ops. > The passphrase-derived recovery wrap this plan drove (R1/R2, #995β#997) was > removed from the product 2026-07-25 (Paul: recovery is a passkey, never > a passphrase) β the reducers now reject kind: "recovery", and the R1/R2 > text below describes machinery that no longer exists.
The plan of record for moving the fleet's operator secrets off the sops+age S3-bundle vault (docs/secrets-vault.md, RUNBOOK.md "Secrets vault") and onto a shared, locked Eleven vault β the Vault mini-app with the biometric lock layer (docs/locked-vaults.md), running in a dedicated bot-less space. This doc is the security analysis and the migration runbook; the design lives in docs/locked-vaults.md, operator-specific bindings (which space, which hostnames) go in RUNBOOK.md when the migration executes.
*This plan was written, then adversarially red-teamed against the shipped code (three independent reviewers: crypto/protocol, operational/DR, macOS-keychain). The verdict shaped it: the confidentiality story is sound, but the first draft overclaimed disaster-recovery parity and audit accountability, and the vault is a DR regression vs sops until the recovery escrow (Β§"Build prerequisites") is built. So the bar is explicit: no real secret moves until the prerequisites ship AND the restore drill passes on a cold machine.* sops stays the vault of record, in parallel, for a full cycle after that.
The access flow was subsequently simplified from the reviewed request/grant ceremony to the membership/handoff model of docs/locked-vaults.md: sharing a vault is adding the Vault app to a thread; vault.lock hands a sealed member handoff to every other member; each self-enrols a durable biometric wrap; a latecomer gets a handoff via vault.give. This does not move the security posture β confidentiality still rests on the same ECIES sealing and biometric wraps β and dropping the request/grant round-trip actually removes the grant-approval forge surface (there is no approval step to MITM). The analysis below reflects the membership model.
Why move at all
sops+age works and is secure, but its sharing is painful: each operator seals their whole repo into an age-sealed S3 bundle slot (sync.sh push) and pulls the others' (sync.sh pull), an asynchronous, manual, merge-prone dance. The Eleven vault replaces that with the product's own always-on E2EE transport: one shared thread, every operator folds the same state live, with an append-only change log. It is also load-bearing dogfooding β "our own ops secrets ride Eleven" is the strongest statement of trust in the encryption. What the move must not do is weaken secrecy or recoverability. The rest of this doc proves where it does and doesn't, and what has to be built to close the gaps.
Does the unlock work shared between two operators? (yes β this is the core)
A locked vault has one vault keypair. Its private half is wrapped once per enrolled unlocker into the keyring β N operators = N wraps of the same private key, each openable only by that operator's own biometric-gated KEK:
- Paul unlocks with his passkey (browser, WebAuthn PRF β KEK) or his Mac's Touch ID keychain key (CLI). Two devices β two wraps, both his.
- James unlocks with his passkey / his Mac's keychain key β different wraps, opening the same vault private key.
Unlock is therefore per-operator, per-device, and entirely local: the in-memory key and the "lock now" / auto-expiry re-mask are properties of one device's session, never shared. Each operator seals new secrets to the public key with no unlock at all (adding is free); each reads only after their own biometric. Onboarding the operators is membership-based β access follows thread membership, there is no "request access" step. Paul (or any operator) creates the shared ops thread with the other operators and adds the Vault app, then locks it. vault.lock carries, besides Paul's own durable wrap, a member handoff for every other current member: the vault private key ECIES-sealed to that operator's roster public key (sealVaultKeyTo; bots are excluded, though this space is bot-less anyway). Each operator then self-enrols β opens their handoff with their own session key (no biometric, it's sealed to their identity), verifies it opens the vault, and wraps the vault key under their own passkey (web) or Touch ID keychain key (eleven vault enroll), which drops the handoff so a biometric is required thereafter. An operator who joins later (or loses every device) gets a fresh handoff from any enrolled operator via vault.give. The vault private key never transits the server in the clear at any step β only wraps and identity-sealed handoffs, all opaque blobs. Sound by the crypto review's verdict on the same primitive it examined: a handoff is ECIES-sealed to a member's roster public key β the identity the roster binds and verifies via the existing MAC-bind mechanism, not a self-asserted name β so it can only open for that member, and only an already-unlocked operator can create one. There is no ephemeral-key grant to replay or misdirect, and no approval step to forge, because there is no request.
Security analysis
Threat model is CLAUDE.md's: if the server is compromised, the attacker gets nothing. Each property is stated against that bar and against "is this at least as good as sops." Claims marked [verified] survived the code red-team; claims marked [gap] are honest weaknesses the plan must carry.
1. Server / infrastructure compromise β [verified] holds the rule, equal to sops
The server stores only the thread-key wraps (ECIES to each member's user key), the sealed values (ECIES to the vault public key), and the keyring wraps (the vault private key under each operator's KEK). None is a usable key; the vault private key is never present on the server in any form. A full server compromise yields ciphertext β the same nothing sops's S3 gives. A hostile server can drop or replay whole ciphertext events (rollback β acknowledged as advisory; #573 key transparency is the systemic answer) but cannot read, forge a value, or maul a wrap (no key, GCM tag). The space is bot-less (Β§6), so no relay/push/announcer path touches it.
2. Stolen or locked/powered-off operator device (no code-exec) β [verified] better than sops
sops's root of trust is a plaintext file on disk (~/.config/sops/age/ keys.txt, mode 600) β a stolen powered-off laptop, a disk image, or a backup that captured the file reads the age key and then every secret. The Eleven vault has *no plaintext vault key at rest: the vault private key exists only as wraps, and on macOS the CLI KEK sits in the login keychain, encrypted at rest. A locked or powered-off Mac, a raw disk copy, or a ~/.eleven copy taken cold yields the operator's user key (hence names + the change log) but no values β the KEK to open a wrap is unrecoverable without the login password. This is the class where the vault genuinely beats sops.*
Two honest bounds on this win:
- The protection for a locked device is the login keychain's at-rest encryption, not the biometric. On an unlocked, unattended Mac the KEK is readable directly (the read path carries no LocalAuthentication context β
eleven/vaultkek_darwin.go), so an unlocked walk-up device reduces to Β§3. - "No plaintext key at rest" is true of the vault key only. The operator's user key is a plaintext 0600 file in
~/.eleven(store.go: "every file is a private key in JSON clothing") β same posture as sops's age key. It exposes names/change-log/any-unsealed-item, never sealed values.
3. Malware running AS the operator β [gap, = sops] no better, stated plainly
On macOS the CLI's KEK is a plain login-keychain item gated by an in-process LocalAuthentication check, not a Secure-Enclave kSecAccessControl ACL (enclave binding needs a signed binary with an application-identifier entitlement, which breaks the anyone-can-go build rule β see docs/locked-vaults.md; the read path in vaultkek_darwin.go sets no access control). So code running as the operator can read the KEK from the keychain without the LA prompt, combine it with ~/.eleven, and decrypt everything. sops is exactly as exposed β the same malware reads keys.txt. Not a regression; both mitigate the same way (protect operator machines: WireGuard + named accounts + sudoers allowlist on the box; release keys offline). Upgrade path (not a blocker): a signed eleven build can move the KEK to a genuinely enclave-bound item without changing the wrap format (#950).
4. Ex-operator / revocation β [gap, = sops] ratchet unchanged
Removal does not retroactively protect anything an ex-operator saw: no rekey-on-leave (db.go), and they read the values while enrolled. vault.revoke drops their keyring wrap (hygiene), but removal means rotating the real secrets β identical to sops's .sops.yaml recipient ratchet. Do not mistake revoke for containment. (See Β§9 for the offensive use of revoke.)
5. Bootstrap circularity + DR β [gap β closed only by the recovery escrow]
This is the finding that reshaped the plan. eleven vault get -db <replica> reads a vault from a raw DB replica with no server (CI-proven, vault_offline_test.go; the offline reader imports only database/sql + internal/e2ee β genuinely network-free). The crypto loop is broken. But the red-team showed the operational loop is not closed by the first draft: offline restore requires three inputs that, as designed, have no fresh-machine source, making the vault a strict DR regression vs sops in the all-machines-lost corner:
- The keychain KEK β Mac-local, in no backup (never synced), on the never-in-vault list. On a fresh Mac,
get -dbfinds no KEK andenrollneeds a live server β dead end. ~/.elevenfor the ops space β a fresh identity isn't a DB member, and joining needs a live server. Required to unwrap the thread key at all.- The S3 read credentials β today only in sops + on the box (the
litestreamIAM key in/data/litestream.env); there is no independent 1Password copy, despite the first draft asserting one.
sops, by contrast, recovers from 1Password age key + any bundle, on any fresh Linux box, by one operator. The Eleven vault, in the same disaster, recovers only if a warm enrolled operator device survived β worse than sops. That is unacceptable for the fleet's root secrets, so the migration is gated on a recovery escrow that restores sops-parity (Β§"Build prerequisites" R1βR3):
- A recovery wrap (
kind: "recovery") of the vault private key under a KEK derived (PBKDF2, reusingcrypto.jsderiveBrandingKey) from a high-entropy recovery passphrase escrowed in 1Password β openable on any machine (incl. Linux /CGO_ENABLED=0), no keychain, no biometric. - A dedicated recovery member identity whose
~/.elevenis escrowed in 1Password β so a fresh machine can unwrap the thread key and read the log. - The S3 read credentials escrowed as an independent 1Password resident (not only sops + box).
With those three in 1Password + the S3 replica, any operator recovers on any machine β genuine parity with sops. Never-in-the-vault (hard list): the recovery passphrase, the recovery identity's ~/.eleven, the S3 read creds, WireGuard/SSH/human passkeys, release + Sparkle signing keys, each operator's personal ~/.eleven and keychain KEKs, the box control token, canary envs. Litmus: "could I need this with every server, including this one, dead?" β yes means offline/1Password, never the vault.
6. Bot-key exposure β [verified] eliminated by construction, but deployment-enforced only
A bot is an E2EE member whose key lives on the box; a bot in the ops vault would put a decrypting key on the box and collapse Β§1. So the space runs no bot and no sidecar (add-instance without add-bot; no lchat-bot@ unit), member invites off, admins = operators only. Caveat the red-team raised: this is deployment discipline, not code β a later add-bot on this host would silently re-introduce the exposure. The runbook adds a standing check (Β§runbook) that the ops host has no lchat-bot@ unit and no agent member in its roster.
7. Sessions-are-public (Claude transcripts) β [gap on the read side] intake safe, read is a new footgun
Intake is airtight and matches sops: values enter only via the masked form or eleven vault set (hidden prompt / --from-file) in the operator's own terminal; Claude is never a member of the ops thread (bot-less, operators- only). But the red-team flagged a new exposure sops doesn't have: eleven vault get prints the value to stdout for command substitution, and box work here is routinely Claude-driven. A get X | ssh box β¦, a set --from-file <file-Claude-just-wrote>, or the DR drill itself (get -db β¦ name) run inside a Claude-driven shell puts the secret in a public transcript β weaker than sops, whose sops -d writes a root-only file, not stdout. Mitigations (R4 + rule): add eleven vault get -o <file> writing a root-only file (never stdout), make it the only form used in any Claude-adjacent or deploy context, and keep the bare stdout get for interactive human use only. The gate/DR drill uses -o.
8. Handoff targeting β [verified] no approval to forge; sealing binds to identity
The membership model removes the grant-approval round-trip entirely, and with it the forge surface the old model carried. There is no vault.request for anyone to approve, so nothing takes an attacker-chosen ephemeral key and display name and waits for a human to bless it. Instead, vault.lock and vault.give seal the vault key directly to a member's roster public key (sealVaultKeyTo) β the identity key the roster already binds and verifies via the existing MAC-bind mechanism, not a self-asserted display name. So a handoff can only ever open for the member it was sealed to, and only an already-unlocked operator can create one. The residual insider risk is unchanged and is availability, not confidentiality: a compromised session can seal a bogus handoff to an operator (griefing), caught at self-enrol because it verifies the handoff opens a real sealed value before wrapping it (Β§9). The out-of-band discipline sops requires before adding a .sops.yaml recipient still applies β but to the human decision of whom to add to the ops thread, since there is no per-grant approval left to MITM. (Audit attribution is still self-asserted and unauthenticated β see Β§9.)
9. Insider / compromised-session abuse β [gap] worse than "nuisance"; audit is not accountable
Three real limits the first draft rounded off, all confirmed against the code:
- Audit attribution is self-asserted and unverifiable. The actor (
uid/name) is written by the sender inside the encrypted event; the server's authoritativemessages.user_idis never exposed to any reader (hub.go,GET /api/messages). Any member can post avault.*event attributed to another operator, and no shipped surface (web Access panel,eleven vault list, offline-db) can detect or repair it. So the change log records *what changed, with an unauthenticated actor hint β it is not* the tamper-proof accountability the first draft's "attributed / better than sops" implied. (It does not weaken Β§8: out-of-band verify goes around the name.) - Revoke can permanently lock everyone out β including DR. Nothing binds a wrap to an operator or stops revoking others' wraps; the only guard is "never the last wrap." A compromised session can enroll its own wrap and revoke every other down to one β legitimate operators lose live and offline read (
get -dbopens only surviving wraps). This is permanent lockout, not a nuisance; recovery = re-provision, or fold the raw append-only log to before the malicious revoke, or the recovery escrow (R1 β the recovery wrap must be un-revokable, so griefing can never remove the escrow path). - Destructive overwrite / downgrade. Any member can
vault.changeany item without knowing its value, and on a locked vault a plaintextvaluedowngrades a sealed item (rendered flagged; CLI "!" marker). Recoverable from the raw log / parallel sops, but it is destroy/downgrade, not just spam.
Confidentiality is already lost the moment a session is compromised, so these are integrity/availability harms on top of that β but they must be named, and they are why R1's recovery wrap is un-revokable and why sops stays a live parallel backstop through the cutover.
10. Offline read authenticates value content, not metadata β [gap, bounded]
get -db has no signature over the DB file. AES-GCM authenticates each blob, but ECIES-to-a-public-key is free: an attacker who tampers a raw replica can seal a chosen thread key to the member's (public) users.public_key and re-author the log. Sealed values are safe (a forged value / substituted vault key won't open without the reader's KEK β fails loudly). Names, the change log, and any unsealed item are not β a tampered replica returns attacker-controlled metadata silently. Bounded by keeping the fetch path trustworthy (S3 creds stay offline, Β§5), not by crypto. State it: offline read authenticates value content when sealed, not metadata.
Summary (honest)
| Threat | sops+age | Eleven locked vault | |---|---|---| | Server / S3 compromise | ciphertext only | ciphertext only (equal) | | Stolen locked/off device, disk/backup | plaintext age key β all secrets | KEK encrypted at rest β no values (better) | | Unlocked walk-up / malware as operator | reads age key β all | reads KEK β all (equal) | | Ex-operator | rotate real secrets | rotate real secrets (equal) | | DR: all machines + fleet dead | 1Password age key, any Linux box, solo | needs the recovery escrow (R1βR3) to match; without it, worse | | Insider audit / accountability | none (files) | change log with unauthenticated actor hint (β equal, not "attributed") | | Insider lockout (revoke) | n/a (files) | permanent lockout unless recovery wrap is un-revokable (gap β R1) | | Secret at deploy/read time | sops -d β root-only file | get β stdout footgun; needs get -o (R4) | | Sharing ergonomics | manual sealed bundles | live shared thread (better) |
Net: better or equal on confidentiality and sharing; a DR regression and an audit-accountability overclaim until the build prerequisites ship. With R1βR4, it reaches parity-or-better on every axis.
Build prerequisites (BLOCKING β no real secret moves until these ship)
Filed as issues; each is small and reuses existing primitives.
- R1 β Recovery wrap + un-revokable (#995). A
kind: "recovery"keyring wrap: the vault private key AES-GCM-wrapped under a PBKDF2-derived KEK from a high-entropy passphrase (reusecrypto.jsderiveBrandingKey/PBKDF2, 600k iters). Reducer: treat arecoverywrap like any wrap for opening, but make it un-revokable (extend the last-wrap guard) so Β§9's revoke-lockout can't remove it. Web: a "Create recovery key" flow (passphrase β wrap) in the Access panel. CLI:eleven vault recovery-wrapto mint it. Cross-platform: the open path must work underCGO_ENABLED=0(no keychain), so recovery never needs a Mac. - R2 β
eleven vault get -db --recovery(#996). Offline read that prompts for the recovery passphrase (PBKDF2 KEK) instead of the keychain, so a cold machine / Linux box can recover. Pairs with R1. - R3 β Recovery-identity + S3-cred escrow (ops, not code). A dedicated recovery member enrolled in the ops space; its
~/.elevenand the S3 read creds sealed into 1Password as independent residents. Documented in RUNBOOK. - R4 β
eleven vault get -o <file>(#997). Write the value to a root-only file (mode 600), never stdout; make it the only form used in deploy/DR/Claude- adjacent contexts. (Optional companion: hold the web unlock key as a non-extractableCryptoKeyβ closes the Β§2 XSS caveat; low priority given the bot-less space.)
The gate (nothing real moves before this passes)
Revised per the DR review β it must exercise the real disaster, not a warm shortcut:
- Stand up the ops space bot-less; lock it; enrol both operators; create the R1 recovery wrap and escrow R3.
- Seed a few throwaway secrets; confirm both operators read them.
- From S3 only (no
/datacopy shortcut), from the correct bucket β a streamed space replicates to the activator/hibernation bucket under a lease, which the fleet-backup recipe excludes; confirm the restore config points at the streamed replica, not the fleet-backup bucket. - Assert freshness: a secret written just before the fetch is present in the restored replica (guards the "streamed DB never picked up" trap).
eleven vault get -db --recovery -o <file>on a cold machine with no enrolled keychain KEK (ideally a Linux box), using only the 1Password escrow (recovery passphrase + recovery~/.eleven+ S3 creds). The value comes back with the ops server stopped.- Confirm a wrong identity, wrong KEK, and wrong passphrase each yield nothing.
Only when 1β6 pass do real secrets move; sops stays canonical in parallel for a full cycle afterward.
Migration runbook
- Create the space, bot-less.
lchat add-instance <ops-host>(noadd-bot, invites off). Admin token β 1Password. Add a standing check that the host has nolchat-bot@unit and no agent in its roster (Β§6). Space specifics β RUNBOOK.md. - Streamed replication; verify the S3 replica appears in the correct bucket (gate step 3).
- Share + lock + enrol + escrow. Paul (or any operator) creates the shared ops thread with the other operators and adds the Vault app, then locks it β
vault.lockhands amemberhandoff to each other operator automatically (Β§"shared"). Each operator self-enrols from their handoff (web passkey, oreleven vault enrollβ CLI keychain key) on each recovery machine; a latecomer gets a handoff viavault.give. Mint the R1 recovery wrap and place the R3 escrow in 1Password. - Pass the gate (above) with throwaway secrets, cold-machine recovery included.
- Seed for real. For each
/data/*.env/ service token in sops not on the never-in-vault list:eleven vault set --from-file <file> <name>(hidden, own terminal).router.envis split first β its S3/AWS creds are never-in-vault (they fetch the replica), while its DNS-01 token is vault- worthy; migrate the DNS-01 token as its own item and keep the S3 creds out (the red-team's file-vs-secret granularity trap). Binary bot envs stay base64 for now. - Parallel run with reconciliation. Both vaults hold the same secrets for a full cycle; edits go to both. Because values are sealed (
listshows names only; eachgetcosts a biometric), divergence is invisible to inspection β so before the flip, reconcile every itemvault get -ovssops -d, per secret, and record it. - Flip canonical only after step 6's reconciliation passes. Update
docs/secrets.md/ RUNBOOK.md to name the Eleven vault as vault of record; keep sops intact as fallback. Announce to operators. - Decommission later, deliberately. Only after a real operating cycle, never by deleting the age key or bundles β the archived sops bundle remains the independent home of R3's escrow unless R3 truly duplicated it into 1Password (verify it did before pruning anything).
Rollback
sops runs untouched in parallel through the whole migration, so rollback is "keep using sops" β no restore, no data motion. The Eleven ops space tears down (lchat remove <ops-host> --stop + /data delete) with zero sops impact. The only point of no return is step 8's deliberate decommission, gated on a real operating cycle β never a deadline.
What stays true afterward
- The never-in-the-vault list (Β§5) is permanent, in this doc and the RUNBOOK, and now explicitly includes the recovery passphrase and recovery
~/.eleven. - Adding/removing an operator is a ratchet (Β§4): removal = rotate secrets.
- Every secret enters via a hidden prompt in an operator's own terminal (Β§7); reads in any deploy/Claude-adjacent context use
get -o(R4), never stdout; Claude never joins the thread. - The recovery wrap is un-revokable (R1) and its escrow (R3) is the independent, any-machine root of trust that keeps this from being a DR regression.