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:
+12
-16
@@ -2,25 +2,21 @@
|
||||
PORT=9000
|
||||
POSTGRES_URL="postgres://postgres:password@localhost:5432/officer"
|
||||
|
||||
# ── Moving to the secret store ─────────────────────────────────────────────────────────────────
|
||||
# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without
|
||||
# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key
|
||||
# store (docs/secret-store.md), which is designed and not yet built, so an install made by the
|
||||
# current script will not boot until it is. That is deliberate sequencing, not an oversight.
|
||||
JWT_SECRET="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key,
|
||||
# encrypting every secret column in Postgres: Headscale admin API keys, app-store service
|
||||
# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the
|
||||
# owner passphrase that seals it.
|
||||
# ── No secrets live here ───────────────────────────────────────────────────────────────────────
|
||||
# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now
|
||||
# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key
|
||||
# per purpose, created on first use. See docs/secret-store.md.
|
||||
#
|
||||
# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the
|
||||
# passphrase opens the inner envelope and this is the outer one.
|
||||
VAULT_STORE_KEY="<generate with: openssl rand -base64 32>"
|
||||
# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2
|
||||
# processes, so a key here is readable from /proc/<pid>/environ of twenty processes that mostly have
|
||||
# no business with it — officer-music held the key that decrypts wallet seed envelopes.
|
||||
#
|
||||
# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres
|
||||
# unreadable, and for the wallet seed that is unrecoverable.
|
||||
|
||||
# ── Optional ───────────────────────────────────────────────────────────────────────────────────
|
||||
# Where Officer is reached from a browser. Read by origin validation, the task API host check, and
|
||||
# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https.
|
||||
# Where Officer is reached from a browser. Read by the task API host check and the CalDAV iOS profile
|
||||
# builder — the latter is the only thing that hard-requires it, and it demands https.
|
||||
# PUBLIC_URL=https://officer.example.com
|
||||
|
||||
# Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to
|
||||
|
||||
Reference in New Issue
Block a user