# 🤖 The secrets vault

Where the operator's server-side secrets are stored and recovered from.
`docs/secrets.md` is the inventory — *what* each secret authorizes, its blast
radius, how to rotate it; this doc is the storage scheme. Per the inventory's
rule, operator specifics (the actual remote, box paths, key locations) live in
`CLAUDE.local.md`, not here.

## The scheme: sealed env files in a small git repo

The vault is a tiny private git repository holding one file per service —
mirroring the env files the services actually run from — with every value
sealed by [SOPS](https://github.com/getsops/sops) using an
[age](https://age-encryption.org) recipient. The vault is the **copy of
record**: the env files deployed on a box are decryptions of it. The editing
flow is `sops <file>` (decrypts into your editor, re-seals on save), commit,
copy the decrypted result to the box, restart the service.

Why this shape and not a secrets-manager service (Vault/OpenBao/cloud KMS):

- **No new always-on service.** A secrets daemon is itself a security-critical
  process with its own unseal problem, and it puts every service's boot behind
  its availability. Env files on disk have no failure modes; the vault only
  changes where they're *kept*, not how they're *consumed*.
- **Two single-binary tools + git.** `sops` and `age` are static binaries; the
  whole flow is readable in one sitting and operable by hand — the second
  rule (built to outlive AI) applied to operations.
- **Versioned and diffable.** SOPS seals values but leaves names readable, so
  a diff shows *which* secret changed without showing what it changed to.
  Rotation history, review, and "what did this env look like last month" come
  free from git. This replaces hand-maintained password-manager items as the
  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 **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

**In:** every secret from `docs/secrets.md` whose rotation story is "swap the
env and restart" — instance admin tokens, relay tokens, Stripe keys, OAuth
client secrets, provisioner tokens, the DNS-01 token, sidecar Anthropic keys.

**Never:**

- **The release signing key and the Sparkle key.** The inventory keeps these
  offline, used only at publish time, and the vault must not quietly undo
  that: aggregating them here would mean a single age-key compromise could
  become a ship-code-to-the-fleet compromise. They stay out, permanently.
- **The age private key itself** — the vault can't hold its own root of trust.
- **Human credentials** — passkeys, SSH keys, sign-in sessions. Those belong
  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

**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
reconstruct every service env; separately each is useless.

## Why the vault is private, even though every value is sealed

The crypto would survive publication — age is sound, and files are sealed *as
if* the repo were public (that discipline is free, and covers an accidental
visibility flip). It stays private anyway, deliberately:

- **Publishing is irreversible, so security would collapse to one key,
  forever.** A public repo is forked, mirrored, and archived within minutes
  and can never be unpublished. If the age key is *ever* compromised — years
  later, via a stolen laptop or a bad backup — every version of every secret
  ever committed becomes readable, and rotation can't recall ciphertext the
  internet already has. Private, an attacker needs the repo *and* the key:
  two independent failures instead of one.
- **Mistake tolerance would drop to zero.** The realistic failure isn't broken
  crypto; it's a plaintext slip — a file committed before sealing, a
  `.sops.yaml` rule that misses a key. Private, that's a calm rotation.
  Public, bots watching the commit firehose for `sk_live_`/`sk-ant-` patterns
  harvest it in seconds — faster than a revert.
- **Sealed values still leak the map.** SOPS leaves structure readable by
  design (that's what makes diffs reviewable): env var names, service
  filenames, and commit history would advertise which providers we depend on
  and when keys rotate — and when they don't. Useful diff metadata for us is
  reconnaissance for an attacker.
- **Public buys nothing.** The repo's only audience is the operator and their
  deploy tooling. The product's openness story lives in the product's repos;
  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 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:
  - age: <public key from age-keygen>
EOF
sops <service>.env                    # create/edit — seals on save
git add -A && git commit -m 'seed' && git push
```

Deploying a change: `sops -d <service>.env` to a root-only temp file, copy it
to the box's env path, restart the unit, delete the temp file. Adding a
secret: seal it here **and** add it to `docs/secrets.md` in the same change —
the inventory's same-PR rule is what keeps the two in step.
