diff --git a/CLAUDE.md b/CLAUDE.md index d64314d0..745a4cbd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -387,6 +387,9 @@ explaining why it was safe. mounted and what it knows, the URL-vs-channel split for panel-to-panel communication, and the persistence key families. Read before building a panel app. Its defect list is `docs/workspace-panel-todo.md`. +- `docs/secret-store.md` — **design, not built**: moving the encryption and signing keys out of `.env` + into a SQLite store, why they cannot live in Postgres, and key rotation. Also records the core/plugin + split it assumes — light plus `officer-headscale` is the core; Vaultwarden and the wallet are plugins - `docs/sidecar-topology.md` — where the sidecar architecture is going, and what was considered and dropped - `docs/working-on-officer.md` — how to run, restart and check your work on this machine - `docs/wallet-key-custody.md` — what the platform can and cannot see of the wallet diff --git a/docs/secret-store.md b/docs/secret-store.md new file mode 100644 index 00000000..5f674757 --- /dev/null +++ b/docs/secret-store.md @@ -0,0 +1,206 @@ +# The secret store + +**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the +current code was checked against the tree on that date; the file:line references are live. + +A small SQLite database, created during setup, holding every encryption and signing key the platform +uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses +instead of inventing its own. + +--- + +## What is wrong with today + +Nothing is insecure. The separation is already right — the thing worth keeping is stated first so it is +not lost in a refactor: + +> **Secrets live in Postgres. The key that opens them does not.** + +That is why `officer_db/src/crypto.ts` reads `VAULT_STORE_KEY` from the environment, and it is what +makes `keys.ts:26` true: *"a stolen database dump is useless without .env, a stolen .env is useless +without the passphrase"*. + +What is wrong is narrower, and it is about **blast radius across processes**. + +`.env` sits in the repository root, and Bun auto-loads it. `ecosystem.config.cjs` says so in as many +words — it is the reason the Anthropic credential was moved out of the main process. So today +`VAULT_STORE_KEY` is present in the environment of **all twenty pm2 processes**. `officer-music` holds +the key that decrypts wallet seed envelopes. Anything that can read `/proc//environ` for those +processes has it, and nineteen of them have no reason to. + +The second problem is that changing the key is currently unrecoverable rather than an operation. See +[Rotation](#rotation). + +--- + +## What the key actually protects + +Worth listing, because it is wider than the name suggests. Everything below is AES-256-GCM ciphertext in +Postgres, encrypted through `officer_db/src/crypto.ts` with a key derived as `SHA-256(VAULT_STORE_KEY)`: + +| column | what it is | +| --- | --- | +| `headscale_servers.api_key` | a Headscale **admin** credential — the schema notes it "can delete every node on a tailnet" | +| `service_connections.secret` | every upstream credential the app store stores: gitea, memos, slskd, transmission | +| `jellyfin_servers.access_token` | Jellyfin session token | +| `wallets.config` | node credentials — macaroon, rune, LNDHub password, NWC URI. Spending authority | +| `wallets.seed_envelope` | a BIP39 mnemonic, already sealed under an owner passphrase, encrypted **again** with this key | + +`decryptSecret` throws when the key does not verify, so a wrong key is not a degraded mode — it is every +one of those becoming unreadable at once. + +The seed envelope is the only one protected by a second, independent secret (the owner passphrase, never +persisted). Everything else in that table has exactly one lock. + +--- + +## Decisions + +### 1. The store is SQLite, in the install, outside Postgres + +Keys cannot live in the database they unlock. A dump would then contain both the ciphertext and the +thing that opens it, and the property quoted at the top stops being true. Encrypting the key with a +second key only moves the question — eventually exactly one secret has to be readable without any other +secret, and the only real decision is *where it lives*. + +SQLite rather than a flat file, for one reason that is not secrecy: **rotation needs key versions.** A +rotation has to decrypt with the old key and re-encrypt with the new, and an interrupted rotation needs +both to still exist. That is a table with `id, purpose, key, created_at, retired_at`, and it is awkward +as an environment variable or a single-value file. Concurrent access from several sidecars is the second +reason; SQLite's locking is the part a hand-rolled file store gets wrong. + +### 2. It is NOT encrypted at rest, for now + +Checked rather than assumed, because `PRAGMA key` appears to work and does not: + +``` +$ bun --eval 'db.exec("PRAGMA key = \"supersecret\""); … insert …' + read without key: THE-SECRET-VALUE + strings enc.db | grep THE-SECRET-VALUE -> found +``` + +Stock SQLite **silently ignores unknown pragmas**, so `PRAGMA key` succeeds, encrypts nothing, and the +value sits in the file in plaintext. `bun:sqlite` ships stock SQLite 3.53.0, not SQLCipher. + +Whole-file encryption therefore needs SQLCipher, which means a native module — and this project already +knows what one of those costs, since node-pty has no Linux prebuild and compiles from source on every +machine. + +So the store holds **encrypted values in an unencrypted file**, the same shape as the Postgres columns. +What leaks is metadata: which purposes have keys, and when they were rotated. That is an acceptable +trade and it is written down here so nobody later assumes the file is opaque. + +`[open]` SQLCipher, if the native-dependency cost ever becomes worth paying. + +### 3. Where the file goes + +**Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one +people back up. A key store that travels in the same tarball as a database dump rebuilds the exact +problem this design exists to avoid. + +`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere +documented loudly enough that a backup script excludes it deliberately. + +### 4. One secret remains outside + +The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the +whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the +two processes that need it.** + +`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly +one value — or comes from a file read on demand. + +### 5. What moves in + +- `VAULT_STORE_KEY` — the at-rest key for everything in the table above. +- `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must + survive restarts, must never be regenerated silently, and benefits from versioning during a rotation. + Leaving one in a store and one in `.env` would be the scattering this is meant to end. + +--- + +## Core, and plugins + +The store is core infrastructure, created at first boot. It is **not** a side effect of installing any +one sidecar — it exists on a machine that installs nothing, so that a plugin installed in six months +finds it already there. + +The core is what `ecosystem.light.config.cjs` runs today — `officer`, `officer-anthropic-proxy`, +`officer-agent`, `officer-opencode`, `officer-pty` — **plus `officer-headscale`**. + +Headscale is core for a stated reason rather than by preference: `CLAUDE.md` says the tailnet *is* the +perimeter, and that `ALLOW_ANY_ORIGIN` defaulting on is only defensible because of it. A security model +that rests on the tailnet cannot treat administering the tailnet as an optional extra. Vaultwarden and +the wallet are not load-bearing that way — nothing else stops working without them — so they become +plugins. + +Moving headscale into the light profile also removes it from the app store automatically: +`catalogue.test.ts` asserts the catalogue equals `full − light`, so the test fails until the entry is +deleted. That derivation is doing its job and should not be worked around. + +Headscale is then the store's **first user**, not its creator — `headscale_servers.api_key` is the first +core credential needing a key. + +--- + +## The contract + +What a plugin gets, and is bound by. To be written properly when the first one uses it; the shape is: + +- **Ask for a key by purpose**, not by name. `getKey('vault')` returns the active key for that purpose, + creating one on first use. +- **Never hold it.** Read it at the point of use. A key cached in a long-lived process is the + process-environment problem in a different container. +- **Never write to another plugin's purpose.** Same rule `service_connections` already has for rows. +- **Tolerate rotation.** A key may change between two calls. Anything that decrypts must be prepared to + be handed the retired key for data written before a rotation. + +--- + +## Rotation + +The feature that makes the store worth building, and the reason versions exist. + +Today, changing `VAULT_STORE_KEY` is not an operation — it is data loss. Every column above becomes +unreadable, and for `wallets.seed_envelope` that is unrecoverable: the owner passphrase does not help, +because it opens the inner envelope and the outer one is gone. Unless the mnemonic was written down +offline, the coins are gone with it. + +Rotation turns that into a supported action: + +1. Mint a new key for the purpose, leaving the old one in the store as retired. +2. For every ciphertext column belonging to that purpose: decrypt with the retired key, re-encrypt with + the new one. +3. Retire the old key only when every row has moved. + +Two properties it must have, both learned from the failure it replaces: + +- **Transactional.** A half-rotated table is worse than either end state, because nothing afterwards can + tell which rows are which. +- **Verify before writing.** Every row must decrypt with the retired key *before* anything is written. + A key that is already wrong should fail loudly on row one rather than produce a second layer of + unreadable data. + +`[open]` Whether rotation is a UI action, a CLI command, or both. It is a long operation on a large +wallet table and it cannot be interrupted safely, which argues for something that reports progress. + +--- + +## What this does not change + +- Secrets stay in Postgres. This moves the **keys**, not the data. +- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from + changes, so no caller is touched. +- The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent + secrets is the property that makes a stolen `.env` insufficient, and it survives this design. + +--- + +## Open questions + +1. Where the file lives, given it must not be swept up by a backup of `data/`. +2. Whether the store's own key stays in `.env` or moves to a file read on demand. +3. Whether rotation is UI, CLI, or both — and how it reports progress on a table that takes minutes. +4. SQLCipher, and whether whole-file encryption is ever worth a second native dependency. +5. What happens to a plugin's keys when it is uninstalled. The app store already decided that + uninstalling never deletes data; the same answer probably applies, but "probably" is not a decision.