eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

πŸ€– 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 using an age 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

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.