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:
@@ -5,16 +5,14 @@
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# ── What is NOT here ──
|
||||
# ── No secrets are written here ──
|
||||
#
|
||||
# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite
|
||||
# key store (docs/secret-store.md), and writing them here in the meantime would
|
||||
# mean generating a value that the store then has to be reconciled with — two
|
||||
# origins for one secret, which is the failure the store exists to end.
|
||||
# Every encryption and signing key 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 and the Secrets section of officer-setup.sh.
|
||||
#
|
||||
# The consequence is honest and deliberate: jwt.ts throws at module load without
|
||||
# JWT_SECRET, so an install made by this script does not boot until the store
|
||||
# lands. That sequencing was chosen rather than stumbled into.
|
||||
# So this file holds no credential except POSTGRES_URL, which is a connection
|
||||
# string to a database bound to loopback.
|
||||
#
|
||||
# ── Derived, not asked ──
|
||||
#
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/bin/bash
|
||||
# =============================================================================
|
||||
# officer-setup — the secret store
|
||||
# =============================================================================
|
||||
#
|
||||
# Definitions only.
|
||||
#
|
||||
# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of
|
||||
# the repo and NOT under data/ — that directory holds the managed homes and
|
||||
# attachments people back up, and a key store travelling in the same tarball as a
|
||||
# database dump rebuilds the exact problem it exists to avoid.
|
||||
#
|
||||
# Bootstrapping runs the platform's own module rather than reimplementing the
|
||||
# schema in bash. There is exactly one writer of this file's format, and a second
|
||||
# one in shell would drift the first time a column is added.
|
||||
|
||||
[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0
|
||||
OFFICER_SETUP_SECRETS_LOADED=1
|
||||
|
||||
secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; }
|
||||
secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; }
|
||||
|
||||
# Create the store and the two purposes a core install needs.
|
||||
#
|
||||
# Run AS the owner, not as root: the platform runs as them, and a store root
|
||||
# created would be a store they cannot write. `install -d -o` sets the owner in
|
||||
# one step rather than mkdir-then-chown, so it is never briefly root's.
|
||||
bootstrap_secret_store() {
|
||||
install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1
|
||||
|
||||
# From the repo, because the module derives the install root as the parent of
|
||||
# the working directory — the same rule as src/servers/data-path.ts.
|
||||
sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \"
|
||||
const { getKey } = await import('officerdb/secret-store');
|
||||
getKey('jwt');
|
||||
getKey('headscale');
|
||||
\"" >/dev/null 2>&1 || return 1
|
||||
|
||||
[[ -f "$(secret_store_path)" ]]
|
||||
}
|
||||
Reference in New Issue
Block a user