eleven / messenger Public

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>
Paul Campbell 25d440de81bada12d4e8f22a0d5973bda7b6bc13 parent 9b6887e
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: