| 1 | # π€ Secrets inventory |
| 2 | |
| 3 | The authoritative list of every server-side secret in the system (issue #550 |
| 4 | Part 12, operability item 11), with what each one authorizes, where it lives, |
| 5 | its blast radius, and how to rotate it. Update this doc in the same PR that |
| 6 | adds or retires a secret. |
| 7 | |
| 8 | **The boundary that matters most is what's *not* here.** User keypairs, thread |
| 9 | keys, and the instance key are client-side only β the server never holds them, |
| 10 | so they can't appear in this inventory. Every secret below is *auth plumbing*: |
| 11 | compromising it lets an attacker impersonate infrastructure or vandalize |
| 12 | state, but never decrypt a message. (The two scoped exceptions are bot |
| 13 | identity keys and the Anthropic key, held by sidecars β machines that are |
| 14 | deliberately *members*, never the server.) |
| 15 | |
| 16 | Operator-specific locations (which box, which env file, which 1Password item) |
| 17 | live in `CLAUDE.local.md` and 1Password, not here. The storage and recovery |
| 18 | scheme for these secrets β the sealed vault repo, and why it is deliberately |
| 19 | private β is `docs/secrets-vault.md`. |
| 20 | |
| 21 | ## Per-instance |
| 22 | |
| 23 | - **Admin token** (`LCHAT_ADMIN_TOKEN`) β authorizes roster management and the |
| 24 | admin console; explicitly not a decryption key. Server stores only its |
| 25 | hash. Lives in the instance's deployment env + 1Password. *Blast radius*: |
| 26 | one instance's membership (add/remove members, invites) β not its content. |
| 27 | *Rotate*: set a new token in the env and restart; saved admin links die, |
| 28 | recover with `lchat admin-link`. |
| 29 | - **Push relay token** (`LCHAT_PUSH_RELAY_TOKEN`) β bearer auth to the APNs |
| 30 | relay. *Blast radius*: can send junk pushes as that instance. *Rotate*: |
| 31 | mint a replacement via the relay's admin API, swap the env, restart, revoke |
| 32 | the old β no Apple interaction needed. |
| 33 | - **VAPID keypair** β Web Push self-identification, generated on first use |
| 34 | and stored in a 0600 sidecar keyfile next to the DB (`<db>.vapid`) β |
| 35 | deliberately *not* in the DB, so hibernation's S3 replica of `lchat.db` |
| 36 | never carries a private key (docs/no-plaintext.md Β§ 2; pre-keyfile |
| 37 | instances migrate the legacy `config` rows into the file on first boot). |
| 38 | Corollary: a hibernated instance restored onto a *different* box loses the |
| 39 | file and re-mints on wake β see *Rotate* for what that costs. *Blast |
| 40 | radius*: junk web pushes to that instance's subscribers. *Rotate*: delete |
| 41 | the keyfile and restart to regenerate; existing push subscriptions break |
| 42 | and members re-subscribe on next visit. |
| 43 | |
| 44 | ## Home |
| 45 | |
| 46 | - **Session key** (`LCHAT_HOME_SESSION_KEY`) β HMAC ceremony proofs + sealed |
| 47 | WebAuthn session params (both keys derived from it with distinct labels). |
| 48 | Cannot open any vault. *Blast radius*: forge a 5-minute ceremony proof β |
| 49 | i.e. the destructive-op gate (device revocation, blob delete, sign-in-method |
| 50 | add/remove) for a user whose uid you already know; vault *content* stays |
| 51 | ciphertext. *Rotate*: swap the env and restart; in-flight ceremonies fail |
| 52 | once, harmless. Unset, home mints an ephemeral key per boot (self-hoster |
| 53 | default). |
| 54 | - **Identity-anchor peppers** (`LCHAT_HOME_SIWA_PEPPER`, |
| 55 | `LCHAT_HOME_EMAIL_PEPPER`, per-provider `LCHAT_HOME_<PROVIDER>_PEPPER`) β |
| 56 | HMAC the provider's stable subject (or lowercased email) into the blind |
| 57 | index that maps a sign-in anchor to an account; identity anchors can never |
| 58 | decrypt. *Blast radius*: pepper plus a DB copy lets an attacker test |
| 59 | whether a *known* email/Apple ID has an account (membership probing) β |
| 60 | content stays sealed. *Rotate*: last-resort only β a new pepper orphans |
| 61 | every existing anchor of that kind; users re-link via a remaining sign-in |
| 62 | method. |
| 63 | - **SES sender key** (`AWS_*` on home when the email door is on; IAM user |
| 64 | `ses-sender-production`, dev twin `ses-sender-dev`) β scoped to |
| 65 | `ses:SendEmail` from `sign-in@elevenmessenger.com` only. *Blast radius*: |
| 66 | send mail as the sign-in address (phishing paper). *Rotate*: standard IAM |
| 67 | two-key rotation. Note it shares the `AWS_*` env with the S3 store below β |
| 68 | an instance running both needs one IAM user carrying both scopes. |
| 69 | - **GitHub OAuth client secret** (`LCHAT_HOME_GITHUB_CLIENT_SECRET`) β the |
| 70 | one sign-in door whose exchange home runs itself (github.go; the OIDC |
| 71 | providers verify id_tokens and need no secret). *Blast radius*: initiate |
| 72 | GitHub OAuth flows as the Eleven app β no stored user material to read. |
| 73 | *Rotate*: regenerate at the GitHub OAuth app, swap the env, restart; the |
| 74 | per-provider pepper (covered above) is untouched. |
| 75 | - **S3 backend credentials** (`AWS_*` when `LCHAT_HOME_STORE=s3`) β object |
| 76 | access to the home bucket. *Blast radius*: read/overwrite vault ciphertext |
| 77 | and WebAuthn public credentials; versioning keeps the last 5, so vandalism |
| 78 | is recoverable. *Rotate*: standard IAM key rotation (mint second key, swap, |
| 79 | delete first). |
| 80 | |
| 81 | ## Release & update chain |
| 82 | |
| 83 | - **Server release signing key** β signs the release feed the whole fleet |
| 84 | auto-updates from. **The highest-blast-radius secret in the system**: a |
| 85 | forger ships code to every instance. Kept offline (1Password), used only at |
| 86 | publish time via `LCHAT_RELEASE_KEY_FILE`. *Rotate*: instances verify |
| 87 | against a pinned public key, so rotation is a two-step β ship an update |
| 88 | (signed by the old key) that adds the new public key, then start signing |
| 89 | with the new one. Treat loss-of-control as an incident, not a rotation. |
| 90 | - **Sparkle EdDSA key** (Mac app updates) β same shape and severity for the |
| 91 | Mac app population; public key pinned in `ios/project.yml`. Same two-step |
| 92 | rotation via a dual-trusted interim release. |
| 93 | - **App Store Connect API key** β TestFlight uploads + notarization. |
| 94 | *Blast radius*: publish builds to the ASC account. *Rotate*: revoke + mint |
| 95 | in ASC β Integrations. |
| 96 | |
| 97 | ## Push relay |
| 98 | |
| 99 | - **APNs `.p8` signing key** β auth to Apple, not a decryption key; lives |
| 100 | only on the relay. *Blast radius*: send pushes as the app (Apple sees the |
| 101 | same ciphertext envelope regardless). *Rotate*: Apple allows two active |
| 102 | keys β mint the new one in the developer portal, swap the relay env, |
| 103 | restart, revoke the old. |
| 104 | - **Relay admin token** β mints/revokes per-instance relay tokens. *Rotate*: |
| 105 | swap the env and restart the relay; existing instance tokens keep working. |
| 106 | |
| 107 | ## Bots & integrations |
| 108 | |
| 109 | - **`ANTHROPIC_API_KEY`** β sidecar envs, plus (since the AI opt-in, #858) |
| 110 | `/data/control.env`: controld's attach-bot step writes it into each fresh |
| 111 | sidecar env it stages, so the provisioning daemon must hold it β bot code |
| 112 | still never runs in the server process, and no *instance* env ever carries |
| 113 | it. *Blast radius*: spend on the Anthropic account. *Rotate*: mint at the |
| 114 | Anthropic console, swap sidecar envs + control.env, restart sidecars and |
| 115 | `lchat-control` (note: a server release alone never restarts sidecars). |
| 116 | - **Bot identity keys** β the deliberate exception: real E2EE member keys, |
| 117 | held in sidecar session state, able to decrypt exactly the threads the bot |
| 118 | was added to. *Rotate*: there is no key rotation β remove the bot member |
| 119 | (admin API) and re-provision a fresh identity via `add-bot`. |
| 120 | - **Connect broker: admin token + provider OAuth client secrets + per-instance |
| 121 | broker tokens** β one client id/secret pair per OAuth-capable service in the |
| 122 | integrations registry (DNSimple under its legacy env names; every other |
| 123 | service β GitHub, Stripe, HubSpot, β¦ β via `LCHAT_OAUTH_<ID>_CLIENT_ID` / |
| 124 | `_CLIENT_SECRET`). The broker persists no OAuth user material, so the client |
| 125 | secrets gate only the ability to *initiate* flows. *Rotate*: provider |
| 126 | secrets at the provider dashboard + broker env swap; instance tokens via the |
| 127 | broker admin API. |
| 128 | |
| 129 | ## Provisioning & DNS |
| 130 | |
| 131 | - **DNS-01 token** (DNSimple, product account) β issues the wildcard cert |
| 132 | and controls product DNS. *Blast radius*: DNS takeover of product domains = |
| 133 | cert issuance for them; a top-tier secret. *Rotate*: mint a new account |
| 134 | token at DNSimple, swap the router env, restart the router, revoke the old. |
| 135 | - **Cloud provisioner: admin token** (mints caller tokens), **caller tokens** |
| 136 | (e.g. home's), and **controld's root-only control token**. *Blast radius*: |
| 137 | create spaces up to quota/capacity caps (the caps are the real defense). |
| 138 | *Rotate*: admin/control tokens by env swap + restart; caller tokens via |
| 139 | the cloud admin API. |
| 140 | |
| 141 | ## Billing (Stripe) |
| 142 | |
| 143 | - **Stripe secret key** (`LCHAT_STRIPE_SECRET_KEY`) β the **live** key the |
| 144 | cloud provisioner uses for space-upgrade Checkout, the Billing Portal, and |
| 145 | subscription webhook reconciliation. *Blast radius*: full API access to the |
| 146 | Stripe account β customer emails and subscription state, refunds, |
| 147 | cancellations. No message content (Stripe never sees the product), but the |
| 148 | worst non-signing secret to lose. *Rotate*: roll the key in the Stripe |
| 149 | dashboard (pick a grace window so the swap is zero-downtime), swap the cloud |
| 150 | env, restart. |
| 151 | - **Stripe webhook signing secret** (`LCHAT_STRIPE_WEBHOOK_SECRET`) β |
| 152 | authenticates Stripe's event posts to the cloud webhook endpoint. *Blast |
| 153 | radius*: forge checkout/subscription events β mint or revoke space |
| 154 | entitlements without payment; no access to the Stripe account itself. |
| 155 | *Rotate*: roll the endpoint's signing secret in the Stripe dashboard, swap |
| 156 | the cloud env, restart. |
| 157 | - **Stripe Connect platform key + webhook secret** |
| 158 | (`LCHAT_STRIPE_CONNECT_KEY`, `LCHAT_STRIPE_CONNECT_WEBHOOK_SECRET`) β |
| 159 | per-*instance* env powering paid access levels (member subscriptions via |
| 160 | Stripe Connect). Deliberately tri-state: both unset β feature off; a partial |
| 161 | config warns and stays off. *Blast radius*: the platform key can also act on |
| 162 | connected accounts (sellers' Stripe accounts) within the platform's |
| 163 | permissions; the webhook secret forges membership entitlements on that |
| 164 | instance. *Rotate*: same shapes as the cloud pair above. |
| 165 | - **Enterprise Edition checkout key** (`LCHAT_STRIPE_ENTERPRISE_KEY`) β the |
| 166 | cloud env's *separate* Stripe key for the elevenmessenger.com/enterprise/ |
| 167 | page (enterprise.go). Deliberately independent of |
| 168 | `LCHAT_STRIPE_SECRET_KEY` with no fallback: it holds the **sandbox** key |
| 169 | while real billing stays live, and the live flip is swapping this one |
| 170 | value. *Blast radius*: whatever account the key belongs to β sandbox β |
| 171 | none; if flipped live, same as the live key above. *Rotate*: roll in the |
| 172 | matching Stripe mode's dashboard, swap the cloud env, restart. |
| 173 | - Kept alongside but **not secrets**: `LCHAT_STRIPE_PRICES` (the price book β |
| 174 | price ids are public by the time Checkout renders) and |
| 175 | `LCHAT_STRIPE_PORTAL_CONFIG` (a portal configuration id). |
| 176 | |
| 177 | ## AWS (from the #550 build-out) |
| 178 | |
| 179 | - **`dev-agent` access key** β AdministratorAccess on the dev account, for |
| 180 | IaC; in 1Password. *Rotate*: IAM two-key rotation. To be replaced by scoped |
| 181 | roles once the OpenTofu skeleton defines them. |
| 182 | - **`dev-agent-james` access key** β James's own AdministratorAccess user on |
| 183 | the same account, same purpose (IaC/tofu runs), so each operator has a |
| 184 | revocable identity and CloudTrail attribution. Sealed in the secrets vault |
| 185 | (`aws-dev-agent-james.env`, bundled with the DNSimple token so one file |
| 186 | covers an `infra/` run). *Rotate*: IAM two-key rotation + re-seal. |
| 187 | *Revoke*: delete the IAM user β independent of `dev-agent`. |
| 188 | - **`litestream-fleet` access key** β object-rw on the fleet-backup bucket |
| 189 | only, lives in the router box's env file. *Blast radius*: read/delete |
| 190 | backup ciphertext β versioning (which this user cannot touch) keeps 14 days |
| 191 | of noncurrent versions, so deletion is recoverable. *Rotate*: IAM two-key |
| 192 | rotation + env swap + `systemctl restart lchat-litestream`. |
| 193 | - **EC2 pool boxes carry no AWS keys at all** β bucket access rides instance |
| 194 | roles; there is nothing to rotate or leak. This is the pattern all later |
| 195 | boxes follow. |
| 196 | |