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
+1 -1
View File
@@ -175,7 +175,7 @@ const server = Bun.serve({
// No esplora URL in the banner: it is per-owner state read from the database now, not a boot constant.
console.log(`[wallet] listening on 127.0.0.1:${port} — network=${getConfig().network}`);
if (!hasStoreKey()) {
console.warn('[wallet] VAULT_STORE_KEY is unset — wallet creation will be refused until it is configured');
console.warn('[wallet] the secret store is unreachable — wallet creation will be refused until it is');
}
type ReplyFn = (msg: SidecarEvent) => void;
+1 -1
View File
@@ -20,7 +20,7 @@ import { BackendError, WalletLockedError } from './types';
//
// 1. An owner passphrase, which is never persisted anywhere. It derives a KEK via scrypt and that
// KEK wraps the random per-wallet DEK that actually encrypts the mnemonic.
// 2. VAULT_STORE_KEY from the environment, applied by queries/wallet.ts (../../databases/officer_db)
// 2. the 'wallet' key from the secret store, applied by queries/wallet.ts (../../databases/officer_db)
// over the already-encrypted envelope before it touches Postgres.
//
// Consequence: a stolen database dump is useless without .env, a stolen .env is useless without the
+14 -5
View File
@@ -1,5 +1,6 @@
import type { BitcoinNetwork } from './types';
import { getServiceCredentials } from 'officerdb';
import { getKey } from 'officerdb/secret-store';
// The ONLY reader of WALLET_* env in the tree. Everything else — node URLs, macaroons, runes, LNDHub
// credentials, NWC URIs — is per-wallet configuration the owner enters at runtime and lives encrypted in
@@ -105,11 +106,19 @@ export function invalidateChainSource(userId: number): void {
}
/**
* Whether VAULT_STORE_KEY is present. The sidecar can serve a locked, watch-only view without it, but
* every write path that touches an encrypted column will throw, so /_health reports it explicitly rather
* than letting the first wallet creation fail with a confusing crypto error.
* Whether the wallet's at-rest key is usable. The sidecar can serve a locked, watch-only view without
* one, but every write path touching an encrypted column throws, so /_health reports it explicitly
* rather than letting the first wallet creation fail with a confusing crypto error.
*
* This tested `VAULT_STORE_KEY` in the environment until 2026-08-13. The key now lives in the secret
* store under the `wallet` purpose and is CREATED ON FIRST USE — so the honest question is no longer
* "did somebody set a variable" but "can this process open the store at all". A false here means the
* file is unreachable or unwritable, which is an install problem rather than a configuration one.
*/
export function hasStoreKey(): boolean {
const k = process.env.VAULT_STORE_KEY;
return Boolean(k && k.length >= 16);
try {
return getKey('wallet').length > 0;
} catch {
return false;
}
}