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:
+43
-17
@@ -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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user