docs: secrets vault — co-operators with full access (#787)
* docs: secrets vault — co-operators with full access, membership is a ratchet James joins as a full co-operator (bus-factor backup), so the key-material section moves from one keypair to one-per-operator with every file encrypted to all recipients. States the two rules that matter when sharing: private keys never move (a new operator mints their own pair), and removal is not re-wrapping — it's rotating the underlying secrets. Also notes vault access alone isn't full continuity (offline signing keys + provider accounts need their own succession path). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: vault membership policy, plaintext runbook resident, OpenBao trigger Codifies Paul's calls from the sharing discussion: membership = production operators only (never contributor onboarding), RUNBOOK.md as the one plaintext resident under a no-values-ever rule, and the stated intent to migrate to OpenBao when the operator circle outgrows read-audit-less files-in-git. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: vault topology — operator machines only, tunnel-shared, sealed bundles to S3 Paul's revision: the production box holds no copy of the vault. Remote of record = a bare repo on the primary operator's machine; co-operators sync through the product's own relay fronting token-authenticated git smart-HTTP (the hostname is public because transcripts are public — the token is the access control); the only copy leaving operator machines is an age-sealed bundle in versioned object storage. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+74
−14
docs/secrets-vault.md+74 −14
diff --git a/docs/secrets-vault.md b/docs/secrets-vault.md| index 5d11bcc..d8848a0 100644 |
| --- a/docs/secrets-vault.md |
| +++ b/docs/secrets-vault.md |
| @@ -32,10 +32,26 @@ Why this shape and not a secrets-manager service (Vault/OpenBao/cloud KMS): |
| recovery path — the drift where a shipped secret never made it into the |
| backup story is what this design exists to end. |
| -Key material: one age keypair. The **public** (recipient) key is not a secret |
| -and is committed in the vault's `.sops.yaml`. The **private** key is the root |
| -of trust: it never enters the vault, lives in the operator's password manager |
| -plus operator machines, and is the one thing the vault's security reduces to. |
| +Key material: one age keypair **per operator**, and every file encrypts to |
| +all operator recipients — co-operators are peers with full access, which is |
| +the bus-factor point: either operator can recover and run the fleet from |
| +their own key alone. The **public** (recipient) keys are not secrets and are |
| +committed in the vault's `.sops.yaml` (comma-separated on the rule); each |
| +**private** key is that operator's root of trust — it never enters the vault |
| +and lives in their password manager plus their machines. Private keys are |
| +never shared, not even between operators: a new operator mints their own |
| +pair and sends only the public half, over an authenticated channel. |
| + |
| +Membership is a ratchet. Adding an operator is one commit (`.sops.yaml` + |
| +`sops updatekeys` over every file). Removing one is **not** the reverse: |
| +re-wrapping only stops future reads, so a departure or key compromise means |
| +rotating the underlying secrets themselves, per their `docs/secrets.md` |
| +entries. Grant accordingly. |
| + |
| +A co-operator's vault access is necessary but not sufficient for full |
| +continuity: the things deliberately kept *out* of the vault — the release and |
| +Sparkle signing keys, provider-account sign-ins — need their own succession |
| +path (password-manager sharing or emergency access), arranged operator-side. |
| ## What belongs in it — and what never does |
| @@ -54,16 +70,36 @@ client secrets, provisioner tokens, the DNS-01 token, sidecar Anthropic keys. |
| in a password manager and its SSH agent, which keep private keys off disk; |
| a file vault would be a downgrade for them. |
| +## Plaintext residents: the operator runbook |
| + |
| +Not everything in the vault is sealed. `RUNBOOK.md` — the shared operator |
| +runbook (hosting, instances, DNS, deploys, releases) — lives in the repo as |
| +plaintext, because it changes constantly, every operator edits it, and sealed |
| +markdown would make diffs opaque and merges unresolvable. What makes this |
| +safe is one absolute rule: **a plaintext resident holds locations and |
| +procedures only, never a secret value** — the same contract the runbook kept |
| +in its previous life as an operator's gitignored CLAUDE.local.md. Each |
| +operator's CLAUDE.local.md imports the runbook at session start |
| +(`@~/elevenmessenger/secrets-vault/RUNBOOK.md`), so agents on every operator |
| +machine load the one shared copy instead of drifting private ones. |
| + |
| ## Where it lives |
| -Not on GitHub, and not on any third-party forge. Git is distributed — the |
| -vault needs no host at all: the remote is a bare repository on |
| -operator-controlled infrastructure, reached over SSH, and **every clone is a |
| -complete backup**. The working copies on operator machines plus a periodic |
| -`git bundle` parked in off-site object storage are the redundancy; the forge |
| -features a host would add (issues, PRs, CI) are exactly the features a secrets |
| -repo must never use. Which box, which path, where the bundles land: |
| -`CLAUDE.local.md`. |
| +**Only on operator machines.** Not on GitHub, not on any third-party forge, |
| +and not on the production box either — the machine most exposed to attack |
| +holds no copy. Git is distributed, so the vault needs no host at all: the |
| +remote of record is a bare repository on the primary operator's machine, and |
| +**every clone is a complete backup**. Co-operators pull and push through an |
| +authenticated tunnel served off that machine (the product's own relay |
| +fronting git smart-HTTP behind a token — the tunnel hostname is public, |
| +since transcripts are public; the token, exchanged only over the messenger, |
| +is the entire access control). Redundancy that leaves operator machines is |
| +**ciphertext only**: a daily job bundles the repo, seals the bundle to the |
| +operator recipients with age, and parks it in versioned object storage — so |
| +even the plaintext runbook resident never leaves an operator's disk |
| +readable. The forge features a host would add (issues, PRs, CI) are exactly |
| +the features a secrets repo must never use. Which machine, which paths, |
| +where the bundles land: the runbook itself. |
| Disaster recovery reduces to two things from different places: the age private |
| key (password manager) and any clone or bundle of the vault. Together they |
| @@ -97,11 +133,35 @@ visibility flip). It stays private anyway, deliberately: |
| an ops vault gains no users, contributors, or auditors from being public — |
| there is no benefit on the other side of the scale. |
| +## Membership policy: operators, not contributors |
| + |
| +Vault membership tracks exactly one thing: **who operates production**. A |
| +recipient reads every value, so the grant is equivalent to root on the fleet |
| +— extend it only to someone you would hand root. Contributors are a |
| +different circle: they get the code repo, the docs, and disposable |
| +`test-*.11ch.at` canaries — never the vault. The two circles may coincide at |
| +any given moment; the policy exists so vault access never rides along with |
| +contributor onboarding by default. Every addition is deliberate, and every |
| +exit costs a full rotation (membership is a ratchet, above). |
| + |
| +## The OpenBao trigger |
| + |
| +Sealed-files-in-git has one honest gap: git records write history but no |
| +*read* audit, and grants are standing rather than short-lived leases. |
| +Acceptable at two operators; wrong at ten. **Stated intent: when the |
| +operator circle grows past a handful, this vault migrates to OpenBao** (the |
| +MPL-2.0, Linux Foundation fork of Vault — still a single Go binary), which |
| +buys read audit logs, leases, and per-path policy at the price of an |
| +always-on service with an unseal ceremony. The current design was chosen |
| +knowing this and doesn't resist the move: recipients map to policies, files |
| +map to secret paths, and the rotation recipes in `docs/secrets.md` are |
| +engine-agnostic. |
| + |
| ## Bootstrapping |
| ```sh |
| -age-keygen -o vault.agekey # store the key per CLAUDE.local.md, then shred the file |
| -git init --bare <remote-path> # on the operator box, over SSH |
| +age-keygen -o vault.agekey # store the key per the runbook, then shred the file |
| +git init --bare ~/path/vault.git # on the primary operator's machine — not a server |
| # in a fresh clone: |
| cat > .sops.yaml <<EOF |
| creation_rules: |