build the secret store: one key per purpose, none in .env

.env now holds PORT and POSTGRES_URL. Every encryption and signing key lives in
$OFFICER_ROOT/secrets/officer-keys.db — 0600, 0700 directory, owned by the
service user, created on first use.

The design doc planned to move ONE at-rest key into the store. What shipped
splits it: headscale, wallet, photos, jellyfin, invoiceshelf, vault and
service-connections each get their own, plus jwt. VAULT_STORE_KEY encrypted all
seven, so one leak opened all of them — and it was named after whichever plugin
needed it first, which is why it read as safe to change if you did not run a
vault. A core install bootstraps two, jwt and headscale; the rest appear when
their plugin first asks.

The file IS the secret. No second key unlocks it, because a key beside the store
it opens buys nothing. The gain was never secrecy, it is blast radius: bun
auto-loads .env into all twenty pm2 processes, so a key there is readable from
/proc/<pid>/environ of twenty processes — officer-music held the key that
decrypts wallet seed envelopes.

Two defects found by testing the store rather than reading it, both of which
would have shipped:

  The WAL was 0644. Enabling WAL creates -wal and -shm at 0644 rather than
  inheriting the database's mode, and a freshly written key lives in the WAL
  before checkpoint — so the 0600 on the database was decorative. The 0700
  directory covered it, but only until someone loosened the directory.

  PRAGMA journal_mode = WAL takes an exclusive lock, and busy_timeout was set
  AFTER it. With twelve concurrent openers, six died on that line with
  SQLITE_BUSY. Every sidecar opens this store at boot, so they open it
  simultaneously by definition: most of them would have failed to start on a cold
  boot and none on a warm one. Fixed by ordering the pragmas; re-tested with
  twelve racing processes, one key, one row.

crypto.ts takes a purpose as its first argument now, which the design doc had
explicitly promised would not happen — 32 call sites across seven query modules.
That promise is corrected in the doc rather than quietly dropped.

Also live, not just comments: wallet/upstream.ts gated wallet storage on
process.env.VAULT_STORE_KEY and would have reported "unconfigured" forever. It
asks the store now, and the question it answers changed — not "did somebody set a
variable" but "can this process open the store", since the key is created on
demand.

assertSecretsClosed covers the store, its directory and its WAL. The jwt key
mints owner tokens, so a member's shell reading it is strictly worse than the
.env leak that check was written for.

Not typechecked: node_modules is empty and installs are frozen, so the
officerdb/secret-store subpath could not be resolved at runtime here — verified
that officerdb/types fails identically, so it is the empty tree and not the new
export. The store module itself was tested directly: creation, idempotence across
processes, hasKey not creating, permissions, and the twelve-way race. Every
changed file parses; the setup section runs and degrades correctly when the
import is unavailable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-13 01:00:12 +00:00
co-authored by Claude Opus 5
parent 1ccb21e67f
commit 8207824a81
23 changed files with 453 additions and 119 deletions
+43 -17
View File
@@ -1,11 +1,19 @@
# 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.
**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and
`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built —
the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts
yet.
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.
A small SQLite database holding every encryption and signing key the platform uses. It replaced
`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing
its own.
**One change from the design below: keys are per PURPOSE, not one key for everything.** The original
plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`,
`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one
plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and
`headscale` — and every other purpose is created when its plugin first asks.
---
@@ -69,7 +77,18 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired
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
### 2. It is NOT encrypted at rest — and that decision changed shape
**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking
them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the
other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user.
That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all.
The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher
finding is still true and still the reason whole-file encryption is not on the table.
#### The original note
Checked rather than assumed, because `PRAGMA key` appears to work and does not:
@@ -94,25 +113,30 @@ trade and it is written down here so nobody later assumes the file is opaque.
### 3. Where the file goes
**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13.
**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.
The setup script says so out loud when it creates the store, because "back this up, but not next to the
other thing you back up" is not a rule anyone infers.
### 4. One secret remains outside
### 4. ~~One secret remains outside~~ — none does
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.**
Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2.
`[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.
The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N
secrets in twenty process environments becomes a file read on demand by the few processes that need
it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from
`/proc/<pid>/environ` of twenty processes — `officer-music` held the key that decrypts wallet seed
envelopes. A file opened by the two or three processes that actually use a key does not.
### 5. What moves in
- `VAULT_STORE_KEY`the at-rest key for everything in the table above.
- `VAULT_STORE_KEY`**split into one key per purpose**, rather than moved. See the status note at the
top: the table above is seven unrelated things, and one key for all of them meant one leak opened all
of them.
- `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.
@@ -208,8 +232,10 @@ wallet table and it cannot be interrupted safely, which argues for something tha
## 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.
- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named
at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules
were touched. That was the cost of the split, and it is worth stating plainly because this line
originally promised the opposite.
- 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.