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:
@@ -48,6 +48,8 @@ source "$SCRIPT_DIR/officer-setup/lib/layout.sh"
|
||||
source "$SCRIPT_DIR/officer-setup/lib/postgres.sh"
|
||||
# shellcheck source=officer-setup/lib/env.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/env.sh"
|
||||
# shellcheck source=officer-setup/lib/secrets.sh
|
||||
source "$SCRIPT_DIR/officer-setup/lib/secrets.sh"
|
||||
|
||||
trap 'echo ""; echo -e "${RED}╔══════════════════════════════════════════════════╗${NC}"; echo -e "${RED}║ OFFICER SETUP FAILED${NC}"; echo -e "${RED}║ Step: ${CURRENT_STEP:-unknown}${NC}"; echo -e "${RED}║ Line: $LINENO${NC}"; echo -e "${RED}║ Command: $BASH_COMMAND${NC}"; echo -e "${RED}╚══════════════════════════════════════════════════╝${NC}"' ERR
|
||||
|
||||
@@ -493,6 +495,55 @@ if ! skip; then
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# 7. Secrets
|
||||
# =============================================================================
|
||||
#
|
||||
# The store creates keys on demand, so this section is not strictly required —
|
||||
# the first `sign()` would mint the jwt key by itself. It runs anyway for two
|
||||
# reasons: the file should exist with the right owner and mode before anything
|
||||
# races to create it, and an install that finishes without ever saying the words
|
||||
# "back this up" is one where nobody learns the file matters until it is gone.
|
||||
|
||||
step "Secrets"
|
||||
if ! skip; then
|
||||
echo ""
|
||||
info "Secret store — $(secret_store_path)"
|
||||
echo " Every encryption and signing key the platform holds, one SQLite file,"
|
||||
echo " one key per purpose. Nothing goes in .env."
|
||||
echo ""
|
||||
echo " bootstrapped now:"
|
||||
echo " jwt signs every session token"
|
||||
echo " headscale encrypts the Headscale admin API key in Postgres"
|
||||
echo ""
|
||||
echo " Every other purpose — wallet, photos, jellyfin, invoiceshelf, vault,"
|
||||
echo " service-connections — is created when its plugin is installed. A"
|
||||
echo " plugin cannot read another plugin's key."
|
||||
echo ""
|
||||
|
||||
if confirm "Create it?"; then
|
||||
if bootstrap_secret_store; then
|
||||
ok "created, 0600, owned by ${USERNAME}"
|
||||
echo ""
|
||||
warn "back up $(secret_store_path) — and keep it OUT of the backup that holds your database dump."
|
||||
echo " Losing it signs everyone out and makes every encrypted column in"
|
||||
echo " Postgres unreadable. For the wallet seed that is unrecoverable:"
|
||||
echo " the passphrase opens the inner envelope, this is the outer one."
|
||||
echo ""
|
||||
echo " Keeping it beside a dump defeats it — the dump is the ciphertext"
|
||||
echo " and this is the key. Separate backups, or it is one theft."
|
||||
SUMMARY+=("Secrets: $(secret_store_path)")
|
||||
else
|
||||
warn "could not create the store — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: NOT created; the platform will do it on first use")
|
||||
fi
|
||||
else
|
||||
warn "skipped by request — the platform will create it on first use"
|
||||
SUMMARY+=("Secrets: SKIPPED; the platform will create it on first use")
|
||||
fi
|
||||
step_ok
|
||||
fi
|
||||
|
||||
# =============================================================================
|
||||
# NOT BUILT YET
|
||||
# =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user