| 1 | # π€ Retiring sops β the shared operator vault on Eleven |
| 2 | |
| 3 | > π€ **Historical record β not a procedure.** The sops vault is retired and |
| 4 | > the standing store is Bitwarden; what scripts read from it is pinned by |
| 5 | > the vault contract (`deploy/vault-items.sh`). Operate via `./deploy/ops`. |
| 6 | > The passphrase-derived recovery wrap this plan drove (R1/R2, #995β#997) was |
| 7 | > **removed from the product 2026-07-25** (Paul: recovery is a passkey, never |
| 8 | > a passphrase) β the reducers now reject `kind: "recovery"`, and the R1/R2 |
| 9 | > text below describes machinery that no longer exists. |
| 10 | |
| 11 | The plan of record for moving the fleet's operator secrets off the sops+age |
| 12 | S3-bundle vault (`docs/secrets-vault.md`, RUNBOOK.md "Secrets vault") and onto a |
| 13 | **shared, locked Eleven vault** β the Vault mini-app with the biometric lock |
| 14 | layer (`docs/locked-vaults.md`), running in a dedicated bot-less space. This |
| 15 | doc is the security analysis and the migration runbook; the design lives in |
| 16 | `docs/locked-vaults.md`, operator-specific bindings (which space, which |
| 17 | hostnames) go in RUNBOOK.md when the migration executes. |
| 18 | |
| 19 | **This plan was written, then adversarially red-teamed against the shipped code |
| 20 | (three independent reviewers: crypto/protocol, operational/DR, macOS-keychain). |
| 21 | The verdict shaped it: the confidentiality story is sound, but the first draft |
| 22 | overclaimed disaster-recovery parity and audit accountability, and the vault is |
| 23 | a *DR regression vs sops* until the recovery escrow (Β§"Build prerequisites") is |
| 24 | built. So the bar is explicit: no real secret moves until the prerequisites |
| 25 | ship AND the restore drill passes on a cold machine.** sops stays the vault of |
| 26 | record, in parallel, for a full cycle after that. |
| 27 | |
| 28 | The access flow was subsequently simplified from the reviewed request/grant |
| 29 | ceremony to the **membership/handoff** model of `docs/locked-vaults.md`: sharing |
| 30 | a vault is adding the Vault app to a thread; `vault.lock` hands a sealed |
| 31 | `member` handoff to every other member; each self-enrols a durable biometric |
| 32 | wrap; a latecomer gets a handoff via `vault.give`. This does **not** move the |
| 33 | security posture β confidentiality still rests on the same ECIES sealing and |
| 34 | biometric wraps β and dropping the request/grant round-trip actually *removes* |
| 35 | the grant-approval forge surface (there is no approval step to MITM). The |
| 36 | analysis below reflects the membership model. |
| 37 | |
| 38 | ## Why move at all |
| 39 | |
| 40 | sops+age works and is secure, but its *sharing* is painful: each operator seals |
| 41 | their whole repo into an age-sealed S3 bundle slot (`sync.sh push`) and pulls |
| 42 | the others' (`sync.sh pull`), an asynchronous, manual, merge-prone dance. The |
| 43 | Eleven vault replaces that with the product's own always-on E2EE transport: one |
| 44 | shared thread, every operator folds the same state live, with an append-only |
| 45 | change log. It is also load-bearing dogfooding β "our own ops secrets ride |
| 46 | Eleven" is the strongest statement of trust in the encryption. What the move |
| 47 | must **not** do is weaken secrecy or recoverability. The rest of this doc proves |
| 48 | where it does and doesn't, and what has to be built to close the gaps. |
| 49 | |
| 50 | ## Does the unlock work shared between two operators? (yes β this is the core) |
| 51 | |
| 52 | A locked vault has **one vault keypair**. Its private half is wrapped once per |
| 53 | enrolled unlocker into the keyring β N operators = N wraps of the *same* private |
| 54 | key, each openable only by that operator's own biometric-gated KEK: |
| 55 | |
| 56 | - **Paul** unlocks with his passkey (browser, WebAuthn PRF β KEK) or his Mac's |
| 57 | Touch ID keychain key (CLI). Two devices β two wraps, both his. |
| 58 | - **James** unlocks with *his* passkey / *his* Mac's keychain key β different |
| 59 | wraps, opening the same vault private key. |
| 60 | |
| 61 | Unlock is therefore **per-operator, per-device, and entirely local**: the |
| 62 | in-memory key and the "lock now" / auto-expiry re-mask are properties of *one |
| 63 | device's session*, never shared. Each operator seals new secrets to the public |
| 64 | key with no unlock at all (adding is free); each reads only after their own |
| 65 | biometric. **Onboarding the operators is membership-based** β access follows |
| 66 | thread membership, there is no "request access" step. Paul (or any operator) |
| 67 | creates the shared ops thread with the other operators and adds the Vault app, |
| 68 | then **locks** it. `vault.lock` carries, besides Paul's own durable wrap, a |
| 69 | `member` handoff for every other current member: the vault private key |
| 70 | ECIES-sealed to that operator's roster public key (`sealVaultKeyTo`; bots are |
| 71 | excluded, though this space is bot-less anyway). Each operator then |
| 72 | **self-enrols** β opens their handoff with their own session key (no biometric, |
| 73 | it's sealed to their identity), verifies it opens the vault, and wraps the vault |
| 74 | key under their own passkey (web) or Touch ID keychain key |
| 75 | (`eleven vault enroll`), which drops the handoff so a biometric is required |
| 76 | thereafter. An operator who joins later (or loses every device) gets a fresh |
| 77 | handoff from any enrolled operator via `vault.give`. The vault private key never |
| 78 | transits the server in the clear at any step β only wraps and identity-sealed |
| 79 | handoffs, all opaque blobs. **Sound** by the crypto review's verdict on the |
| 80 | same primitive it examined: a handoff is ECIES-sealed to a member's roster |
| 81 | public key β the identity the roster binds and verifies via the existing |
| 82 | MAC-bind mechanism, not a self-asserted name β so it can only open for that |
| 83 | member, and only an already-unlocked operator can create one. There is no |
| 84 | ephemeral-key grant to replay or misdirect, and no approval step to forge, |
| 85 | because there is no request. |
| 86 | |
| 87 | ## Security analysis |
| 88 | |
| 89 | Threat model is CLAUDE.md's: *if the server is compromised, the attacker gets |
| 90 | nothing.* Each property is stated against that bar and against "is this at least |
| 91 | as good as sops." Claims marked **[verified]** survived the code red-team; |
| 92 | claims marked **[gap]** are honest weaknesses the plan must carry. |
| 93 | |
| 94 | ### 1. Server / infrastructure compromise β **[verified] holds the rule, equal to sops** |
| 95 | |
| 96 | The server stores only the thread-key wraps (ECIES to each member's user key), |
| 97 | the sealed values (ECIES to the vault public key), and the keyring wraps (the |
| 98 | vault private key under each operator's KEK). None is a usable key; the vault |
| 99 | private key is never present on the server in any form. A full server compromise |
| 100 | yields ciphertext β the same nothing sops's S3 gives. A hostile server can drop |
| 101 | or replay whole ciphertext events (rollback β acknowledged as advisory; #573 key |
| 102 | transparency is the systemic answer) but cannot read, forge a value, or maul a |
| 103 | wrap (no key, GCM tag). The space is bot-less (Β§6), so no relay/push/announcer |
| 104 | path touches it. |
| 105 | |
| 106 | ### 2. Stolen or **locked/powered-off** operator device (no code-exec) β **[verified] better than sops** |
| 107 | |
| 108 | sops's root of trust is a **plaintext file on disk** (`~/.config/sops/age/ |
| 109 | keys.txt`, mode 600) β a stolen powered-off laptop, a disk image, or a backup |
| 110 | that captured the file reads the age key and then every secret. The Eleven |
| 111 | vault has **no plaintext *vault* key at rest**: the vault private key exists |
| 112 | only as wraps, and on macOS the CLI KEK sits in the login keychain, *encrypted |
| 113 | at rest*. A **locked or powered-off** Mac, a raw disk copy, or a `~/.eleven` |
| 114 | copy taken cold yields the operator's user key (hence names + the change log) |
| 115 | but **no values** β the KEK to open a wrap is unrecoverable without the login |
| 116 | password. **This is the class where the vault genuinely beats sops.** |
| 117 | |
| 118 | Two honest bounds on this win: |
| 119 | - The protection for a *locked* device is the login keychain's **at-rest |
| 120 | encryption**, not the biometric. On an **unlocked, unattended** Mac the KEK is |
| 121 | readable directly (the read path carries no LocalAuthentication context β |
| 122 | `eleven/vaultkek_darwin.go`), so an unlocked walk-up device reduces to Β§3. |
| 123 | - "No plaintext key at rest" is true of the **vault** key only. The operator's |
| 124 | **user** key is a plaintext 0600 file in `~/.eleven` (`store.go`: "every file |
| 125 | is a private key in JSON clothing") β same posture as sops's age key. It |
| 126 | exposes names/change-log/any-unsealed-item, never sealed values. |
| 127 | |
| 128 | ### 3. Malware running AS the operator β **[gap, = sops] no better, stated plainly** |
| 129 | |
| 130 | On macOS the CLI's KEK is a plain login-keychain item gated by an **in-process |
| 131 | LocalAuthentication check**, *not* a Secure-Enclave `kSecAccessControl` ACL |
| 132 | (enclave binding needs a signed binary with an application-identifier |
| 133 | entitlement, which breaks the anyone-can-`go build` rule β see |
| 134 | `docs/locked-vaults.md`; the read path in `vaultkek_darwin.go` sets no access |
| 135 | control). So code running as the operator can read the KEK from the keychain |
| 136 | *without* the LA prompt, combine it with `~/.eleven`, and decrypt everything. |
| 137 | **sops is exactly as exposed** β the same malware reads `keys.txt`. Not a |
| 138 | regression; both mitigate the same way (protect operator machines: WireGuard + |
| 139 | named accounts + sudoers allowlist on the box; release keys offline). *Upgrade |
| 140 | path (not a blocker):* a **signed** `eleven` build can move the KEK to a |
| 141 | genuinely enclave-bound item without changing the wrap format (#950). |
| 142 | |
| 143 | ### 4. Ex-operator / revocation β **[gap, = sops] ratchet unchanged** |
| 144 | |
| 145 | Removal does **not** retroactively protect anything an ex-operator saw: no |
| 146 | rekey-on-leave (`db.go`), and they *read* the values while enrolled. |
| 147 | `vault.revoke` drops their keyring wrap (hygiene), but removal means **rotating |
| 148 | the real secrets** β identical to sops's `.sops.yaml` recipient ratchet. Do not |
| 149 | mistake revoke for containment. (See Β§9 for the *offensive* use of revoke.) |
| 150 | |
| 151 | ### 5. Bootstrap circularity + DR β **[gap β closed only by the recovery escrow]** |
| 152 | |
| 153 | This is the finding that reshaped the plan. `eleven vault get -db <replica>` |
| 154 | reads a vault from a raw DB replica with **no server** (CI-proven, |
| 155 | `vault_offline_test.go`; the offline reader imports only `database/sql` + |
| 156 | `internal/e2ee` β genuinely network-free). The *crypto* loop is broken. But the |
| 157 | red-team showed the *operational* loop is **not** closed by the first draft: |
| 158 | offline restore requires three inputs that, as designed, have **no fresh-machine |
| 159 | source**, making the vault a **strict DR regression vs sops** in the |
| 160 | all-machines-lost corner: |
| 161 | |
| 162 | 1. **The keychain KEK** β Mac-local, in no backup (never synced), on the |
| 163 | never-in-vault list. On a fresh Mac, `get -db` finds no KEK and `enroll` |
| 164 | needs a live server β dead end. |
| 165 | 2. **`~/.eleven`** for the ops space β a fresh identity isn't a DB member, and |
| 166 | joining needs a live server. Required to unwrap the thread key at all. |
| 167 | 3. **The S3 read credentials** β today only in sops + on the box (the |
| 168 | `litestream` IAM key in `/data/litestream.env`); there is **no independent |
| 169 | 1Password copy**, despite the first draft asserting one. |
| 170 | |
| 171 | sops, by contrast, recovers from `1Password age key + any bundle`, on any fresh |
| 172 | **Linux** box, by one operator. The Eleven vault, in the same disaster, recovers |
| 173 | **only if a warm enrolled operator device survived** β worse than sops. That is |
| 174 | unacceptable for the fleet's root secrets, so the migration is **gated on a |
| 175 | recovery escrow** that restores sops-parity (Β§"Build prerequisites" R1βR3): |
| 176 | |
| 177 | - A **recovery wrap** (`kind: "recovery"`) of the vault private key under a KEK |
| 178 | derived (PBKDF2, reusing `crypto.js` `deriveBrandingKey`) from a |
| 179 | **high-entropy recovery passphrase** escrowed in 1Password β openable on |
| 180 | **any** machine (incl. Linux / `CGO_ENABLED=0`), no keychain, no biometric. |
| 181 | - A **dedicated recovery member identity** whose `~/.eleven` is escrowed in |
| 182 | 1Password β so a fresh machine can unwrap the thread key and read the log. |
| 183 | - The **S3 read credentials** escrowed as an independent 1Password resident (not |
| 184 | only sops + box). |
| 185 | |
| 186 | With those three in 1Password + the S3 replica, any operator recovers on any |
| 187 | machine β genuine parity with sops. **Never-in-the-vault (hard list):** the |
| 188 | recovery passphrase, the recovery identity's `~/.eleven`, the S3 read creds, |
| 189 | WireGuard/SSH/human passkeys, release + Sparkle signing keys, each operator's |
| 190 | personal `~/.eleven` and keychain KEKs, the box control token, canary envs. |
| 191 | Litmus: *"could I need this with every server, including this one, dead?"* β yes |
| 192 | means offline/1Password, never the vault. |
| 193 | |
| 194 | ### 6. Bot-key exposure β **[verified] eliminated by construction, but deployment-enforced only** |
| 195 | |
| 196 | A bot is an E2EE member whose key lives on the box; a bot in the ops vault would |
| 197 | put a decrypting key on the box and collapse Β§1. So the space runs **no bot and |
| 198 | no sidecar** (`add-instance` without `add-bot`; no `lchat-bot@` unit), member |
| 199 | invites off, admins = operators only. **Caveat the red-team raised:** this is |
| 200 | deployment discipline, not code β a later `add-bot` on this host would silently |
| 201 | re-introduce the exposure. The runbook adds a standing check (Β§runbook) that the |
| 202 | ops host has no `lchat-bot@` unit and no agent member in its roster. |
| 203 | |
| 204 | ### 7. Sessions-are-public (Claude transcripts) β **[gap on the read side] intake safe, read is a new footgun** |
| 205 | |
| 206 | Intake is airtight and matches sops: values enter only via the masked form or |
| 207 | `eleven vault set` (hidden prompt / `--from-file`) in the operator's **own** |
| 208 | terminal; **Claude is never a member of the ops thread** (bot-less, operators- |
| 209 | only). But the red-team flagged a *new* exposure sops doesn't have: `eleven |
| 210 | vault get` prints the value to **stdout** for command substitution, and box work |
| 211 | here is routinely Claude-driven. A `get X | ssh box β¦`, a `set --from-file |
| 212 | <file-Claude-just-wrote>`, or **the DR drill itself** (`get -db β¦ name`) run |
| 213 | inside a Claude-driven shell puts the secret in a public transcript β weaker |
| 214 | than sops, whose `sops -d` writes a root-only file, not stdout. **Mitigations |
| 215 | (R4 + rule):** add `eleven vault get -o <file>` writing a root-only file (never |
| 216 | stdout), make it the *only* form used in any Claude-adjacent or deploy context, |
| 217 | and keep the bare stdout `get` for interactive human use only. The gate/DR drill |
| 218 | uses `-o`. |
| 219 | |
| 220 | ### 8. Handoff targeting β **[verified] no approval to forge; sealing binds to identity** |
| 221 | |
| 222 | The membership model **removes the grant-approval round-trip entirely**, and |
| 223 | with it the forge surface the old model carried. There is no `vault.request` for |
| 224 | anyone to approve, so nothing takes an attacker-chosen ephemeral key *and* |
| 225 | display name and waits for a human to bless it. Instead, `vault.lock` and |
| 226 | `vault.give` seal the vault key **directly to a member's roster public key** |
| 227 | (`sealVaultKeyTo`) β the identity key the roster already binds and verifies via |
| 228 | the existing MAC-bind mechanism, not a self-asserted display name. So a handoff |
| 229 | can only ever open for the member it was sealed to, and only an already-unlocked |
| 230 | operator can create one. The residual insider risk is unchanged and is |
| 231 | *availability*, not confidentiality: a compromised session can seal a **bogus** |
| 232 | handoff to an operator (griefing), caught at self-enrol because it verifies the |
| 233 | handoff opens a real sealed value before wrapping it (Β§9). The out-of-band |
| 234 | discipline sops requires before adding a `.sops.yaml` recipient still applies β |
| 235 | but to the human decision of *whom to add to the ops thread*, since there is no |
| 236 | per-grant approval left to MITM. (Audit attribution is still self-asserted and |
| 237 | unauthenticated β see Β§9.) |
| 238 | |
| 239 | ### 9. Insider / compromised-session abuse β **[gap] worse than "nuisance"; audit is not accountable** |
| 240 | |
| 241 | Three real limits the first draft rounded off, all confirmed against the code: |
| 242 | |
| 243 | - **Audit attribution is self-asserted and unverifiable.** The actor |
| 244 | (`uid`/`name`) is written by the sender *inside* the encrypted event; the |
| 245 | server's authoritative `messages.user_id` is never exposed to any reader |
| 246 | (`hub.go`, `GET /api/messages`). Any member can post a `vault.*` event |
| 247 | attributed to another operator, and no shipped surface (web Access panel, |
| 248 | `eleven vault list`, offline `-db`) can detect or repair it. So the change log |
| 249 | records **what changed, with an *unauthenticated* actor hint** β it is **not** |
| 250 | the tamper-proof accountability the first draft's "attributed / better than |
| 251 | sops" implied. (It does not weaken Β§8: out-of-band verify goes around the |
| 252 | name.) |
| 253 | - **Revoke can permanently lock everyone out β including DR.** Nothing binds a |
| 254 | wrap to an operator or stops revoking *others'* wraps; the only guard is |
| 255 | "never the last wrap." A compromised session can enroll its own wrap and |
| 256 | revoke every other down to one β legitimate operators lose live *and* offline |
| 257 | read (`get -db` opens only surviving wraps). This is **permanent lockout**, |
| 258 | not a nuisance; recovery = re-provision, or fold the raw append-only log to |
| 259 | before the malicious revoke, or the **recovery escrow** (R1 β the recovery |
| 260 | wrap must be **un-revokable**, so griefing can never remove the escrow path). |
| 261 | - **Destructive overwrite / downgrade.** Any member can `vault.change` any item |
| 262 | without knowing its value, and on a locked vault a plaintext `value` downgrades |
| 263 | a sealed item (rendered flagged; CLI "!" marker). Recoverable from the raw log |
| 264 | / parallel sops, but it is *destroy/downgrade*, not just spam. |
| 265 | |
| 266 | Confidentiality is already lost the moment a session is compromised, so these |
| 267 | are integrity/availability harms on top of that β but they must be named, and |
| 268 | they are why R1's recovery wrap is un-revokable and why sops stays a live |
| 269 | parallel backstop through the cutover. |
| 270 | |
| 271 | ### 10. Offline read authenticates *value content*, not *metadata* β **[gap, bounded]** |
| 272 | |
| 273 | `get -db` has no signature over the DB file. AES-GCM authenticates each blob, but |
| 274 | ECIES-to-a-public-key is free: an attacker who tampers a raw replica can seal a |
| 275 | chosen thread key to the member's (public) `users.public_key` and re-author the |
| 276 | log. **Sealed values are safe** (a forged value / substituted vault key won't |
| 277 | open without the reader's KEK β fails loudly). **Names, the change log, and any |
| 278 | unsealed item are not** β a tampered replica returns attacker-controlled |
| 279 | metadata silently. Bounded by keeping the *fetch path* trustworthy (S3 creds |
| 280 | stay offline, Β§5), not by crypto. State it: offline read authenticates value |
| 281 | *content* when sealed, not metadata. |
| 282 | |
| 283 | ### Summary (honest) |
| 284 | |
| 285 | | Threat | sops+age | Eleven locked vault | |
| 286 | |---|---|---| |
| 287 | | Server / S3 compromise | ciphertext only | ciphertext only (**equal**) | |
| 288 | | Stolen **locked/off** device, disk/backup | plaintext age key β all secrets | KEK encrypted at rest β **no values** (**better**) | |
| 289 | | Unlocked walk-up / malware as operator | reads age key β all | reads KEK β all (**equal**) | |
| 290 | | Ex-operator | rotate real secrets | rotate real secrets (**equal**) | |
| 291 | | DR: all machines + fleet dead | 1Password age key, any Linux box, solo | **needs the recovery escrow (R1βR3)** to match; without it, **worse** | |
| 292 | | Insider audit / accountability | none (files) | change log with **unauthenticated** actor hint (**β equal**, not "attributed") | |
| 293 | | Insider lockout (revoke) | n/a (files) | permanent lockout unless recovery wrap is un-revokable (**gap β R1**) | |
| 294 | | Secret at deploy/read time | `sops -d` β root-only file | `get` β **stdout footgun**; needs `get -o` (**R4**) | |
| 295 | | Sharing ergonomics | manual sealed bundles | live shared thread (**better**) | |
| 296 | |
| 297 | Net: **better or equal on confidentiality and sharing; a DR regression and an |
| 298 | audit-accountability overclaim until the build prerequisites ship.** With |
| 299 | R1βR4, it reaches parity-or-better on every axis. |
| 300 | |
| 301 | ## Build prerequisites (BLOCKING β no real secret moves until these ship) |
| 302 | |
| 303 | Filed as issues; each is small and reuses existing primitives. |
| 304 | |
| 305 | - **R1 β Recovery wrap + un-revokable (#995).** A `kind: "recovery"` keyring wrap: the |
| 306 | vault private key AES-GCM-wrapped under a PBKDF2-derived KEK from a |
| 307 | high-entropy passphrase (reuse `crypto.js` `deriveBrandingKey`/PBKDF2, 600k |
| 308 | iters). Reducer: treat a `recovery` wrap like any wrap for opening, but make it |
| 309 | **un-revokable** (extend the last-wrap guard) so Β§9's revoke-lockout can't |
| 310 | remove it. Web: a "Create recovery key" flow (passphrase β wrap) in the Access |
| 311 | panel. CLI: `eleven vault recovery-wrap` to mint it. Cross-platform: the open |
| 312 | path must work under `CGO_ENABLED=0` (no keychain), so recovery never needs a |
| 313 | Mac. |
| 314 | - **R2 β `eleven vault get -db --recovery` (#996).** Offline read that prompts for the |
| 315 | recovery passphrase (PBKDF2 KEK) instead of the keychain, so a **cold machine |
| 316 | / Linux box** can recover. Pairs with R1. |
| 317 | - **R3 β Recovery-identity + S3-cred escrow (ops, not code).** A dedicated |
| 318 | recovery member enrolled in the ops space; its `~/.eleven` and the S3 read |
| 319 | creds sealed into 1Password as independent residents. Documented in RUNBOOK. |
| 320 | - **R4 β `eleven vault get -o <file>` (#997).** Write the value to a root-only file |
| 321 | (mode 600), never stdout; make it the only form used in deploy/DR/Claude- |
| 322 | adjacent contexts. (Optional companion: hold the web unlock key as a |
| 323 | non-extractable `CryptoKey` β closes the Β§2 XSS caveat; low priority given the |
| 324 | bot-less space.) |
| 325 | |
| 326 | ## The gate (nothing real moves before this passes) |
| 327 | |
| 328 | Revised per the DR review β it must exercise the *real* disaster, not a warm |
| 329 | shortcut: |
| 330 | |
| 331 | 1. Stand up the ops space bot-less; lock it; enrol both operators; **create the |
| 332 | R1 recovery wrap** and escrow R3. |
| 333 | 2. Seed a few **throwaway** secrets; confirm both operators read them. |
| 334 | 3. **From S3 only** (no `/data` copy shortcut), from the **correct bucket** β a |
| 335 | streamed space replicates to the activator/hibernation bucket under a lease, |
| 336 | which the fleet-backup recipe *excludes*; confirm the restore config points |
| 337 | at the streamed replica, not the fleet-backup bucket. |
| 338 | 4. **Assert freshness:** a secret written just before the fetch is present in the |
| 339 | restored replica (guards the "streamed DB never picked up" trap). |
| 340 | 5. `eleven vault get -db --recovery -o <file>` **on a cold machine with no |
| 341 | enrolled keychain KEK** (ideally a Linux box), using only the 1Password escrow |
| 342 | (recovery passphrase + recovery `~/.eleven` + S3 creds). The value comes back |
| 343 | with the ops server stopped. |
| 344 | 6. Confirm a **wrong** identity, **wrong** KEK, and **wrong** passphrase each |
| 345 | yield nothing. |
| 346 | |
| 347 | Only when 1β6 pass do real secrets move; sops stays canonical in parallel for a |
| 348 | full cycle afterward. |
| 349 | |
| 350 | ## Migration runbook |
| 351 | |
| 352 | 1. **Create the space, bot-less.** `lchat add-instance <ops-host>` (no |
| 353 | `add-bot`, invites off). Admin token β 1Password. Add a standing check that |
| 354 | the host has no `lchat-bot@` unit and no agent in its roster (Β§6). Space |
| 355 | specifics β RUNBOOK.md. |
| 356 | 2. **Streamed replication;** verify the S3 replica appears in the *correct* |
| 357 | bucket (gate step 3). |
| 358 | 3. **Share + lock + enrol + escrow.** Paul (or any operator) creates the shared |
| 359 | ops thread with the other operators and adds the Vault app, then **locks** it |
| 360 | β `vault.lock` hands a `member` handoff to each other operator automatically |
| 361 | (Β§"shared"). Each operator **self-enrols** from their handoff (web passkey, or |
| 362 | `eleven vault enroll` β CLI keychain key) on each recovery machine; a latecomer |
| 363 | gets a handoff via `vault.give`. **Mint the R1 recovery wrap** and place the R3 |
| 364 | escrow in 1Password. |
| 365 | 4. **Pass the gate** (above) with throwaway secrets, cold-machine recovery |
| 366 | included. |
| 367 | 5. **Seed for real.** For each `/data/*.env` / service token in sops **not** on |
| 368 | the never-in-vault list: `eleven vault set --from-file <file> <name>` (hidden, |
| 369 | own terminal). **`router.env` is split first** β its S3/AWS creds are |
| 370 | never-in-vault (they fetch the replica), while its DNS-01 token *is* vault- |
| 371 | worthy; migrate the DNS-01 token as its own item and keep the S3 creds out |
| 372 | (the red-team's file-vs-secret granularity trap). Binary bot envs stay base64 |
| 373 | for now. |
| 374 | 6. **Parallel run with reconciliation.** Both vaults hold the same secrets for a |
| 375 | full cycle; edits go to **both**. Because values are sealed (`list` shows |
| 376 | names only; each `get` costs a biometric), divergence is invisible to |
| 377 | inspection β so **before the flip, reconcile every item** `vault get -o` vs |
| 378 | `sops -d`, per secret, and record it. |
| 379 | 7. **Flip canonical** only after step 6's reconciliation passes. Update |
| 380 | `docs/secrets.md` / RUNBOOK.md to name the Eleven vault as vault of record; |
| 381 | keep sops intact as fallback. Announce to operators. |
| 382 | 8. **Decommission later, deliberately.** Only after a real operating cycle, never |
| 383 | by deleting the age key or bundles β the archived sops bundle remains the |
| 384 | independent home of R3's escrow *unless* R3 truly duplicated it into |
| 385 | 1Password (verify it did before pruning anything). |
| 386 | |
| 387 | ## Rollback |
| 388 | |
| 389 | sops runs untouched in parallel through the whole migration, so rollback is |
| 390 | "keep using sops" β no restore, no data motion. The Eleven ops space tears down |
| 391 | (`lchat remove <ops-host> --stop` + `/data` delete) with zero sops impact. The |
| 392 | only point of no return is step 8's deliberate decommission, gated on a real |
| 393 | operating cycle β never a deadline. |
| 394 | |
| 395 | ## What stays true afterward |
| 396 | |
| 397 | - The never-in-the-vault list (Β§5) is permanent, in this doc and the RUNBOOK, |
| 398 | and now explicitly includes the recovery passphrase and recovery `~/.eleven`. |
| 399 | - Adding/removing an operator is a ratchet (Β§4): removal = rotate secrets. |
| 400 | - Every secret enters via a hidden prompt in an operator's own terminal (Β§7); |
| 401 | reads in any deploy/Claude-adjacent context use `get -o` (R4), never stdout; |
| 402 | Claude never joins the thread. |
| 403 | - The recovery wrap is un-revokable (R1) and its escrow (R3) is the independent, |
| 404 | any-machine root of trust that keeps this from being a DR regression. |
| 405 | |