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:
@@ -1,15 +1,17 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { getKey } from 'officerdb/secret-store';
|
||||
|
||||
const RELAY_TOKEN_CONTEXT = 'officer-browser-relay-v1';
|
||||
|
||||
const { JWT_SECRET } = process.env;
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
throw new Error('JWT_SECRET is required for browser relay auth');
|
||||
}
|
||||
// Nothing imports this file today — the browser relay is switched off pending extraction into a plugin
|
||||
// (see server.tsx). Updated with everything else so it is coherent when it comes back: it read
|
||||
// JWT_SECRET from the environment, which no longer exists, and would have thrown at module load.
|
||||
//
|
||||
// When it is extracted it should take its own purpose rather than borrowing 'jwt' — a plugin deriving
|
||||
// tokens from the platform's signing key is the coupling the per-purpose split exists to remove.
|
||||
|
||||
export function deriveRelayToken(userId: number, port: number, salt: string): string {
|
||||
return createHmac('sha256', JWT_SECRET!)
|
||||
return createHmac('sha256', getKey('jwt'))
|
||||
.update(`${RELAY_TOKEN_CONTEXT}:${port}:${userId}:${salt}`)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
+17
-6
@@ -1,9 +1,20 @@
|
||||
import { sign as jwtSign, verify as jwtVerify } from 'hono/jwt';
|
||||
import { getKey } from 'officerdb/secret-store';
|
||||
|
||||
const { JWT_SECRET } = process.env;
|
||||
|
||||
if (!JWT_SECRET || JWT_SECRET.length < 32) {
|
||||
throw new Error('JWT_SECRET must be set and at least 32 characters long. Generate one with: openssl rand -base64 32');
|
||||
// The signing key comes from the secret store, not from JWT_SECRET in .env — see docs/secret-store.md.
|
||||
//
|
||||
// Read lazily and cached: importing this module must not open the store, because plenty of files import
|
||||
// `sign`/`verify` for their types or for one code path they may never take.
|
||||
//
|
||||
// It is created on first use rather than required up front, which is a deliberate trade. There is no
|
||||
// longer a startup error telling you the secret is missing; instead, losing the store file silently mints
|
||||
// a new key and signs everyone out of every device. That is the same failure the old env var had — it was
|
||||
// regenerated by the setup script on every run that agreed to rewrite .env — except the store is a file
|
||||
// nobody edits by hand, so it is far less likely to happen by accident.
|
||||
let cachedKey: string | null = null;
|
||||
function signingKey(): string {
|
||||
if (!cachedKey) cachedKey = getKey('jwt');
|
||||
return cachedKey;
|
||||
}
|
||||
|
||||
function parseExpiration(expiration: string): number {
|
||||
@@ -27,9 +38,9 @@ export async function sign(payload: any, expiration = '30d') {
|
||||
const exp = parseExpiration(expiration);
|
||||
const jti = crypto.randomUUID();
|
||||
const data = { ...payload, exp, iat: now, jti };
|
||||
return jwtSign(data, JWT_SECRET!);
|
||||
return jwtSign(data, signingKey());
|
||||
}
|
||||
|
||||
export async function verify(token: string): Promise<any> {
|
||||
return jwtVerify(token, JWT_SECRET!, 'HS256');
|
||||
return jwtVerify(token, signingKey(), 'HS256');
|
||||
}
|
||||
|
||||
+20
-1
@@ -1,6 +1,7 @@
|
||||
import { chmod, mkdir, readdir, stat } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { secretStorePath } from 'officerdb/secret-store';
|
||||
import { DATA_PATH, USER_DIRS, toShellUsername } from './data-path';
|
||||
|
||||
// Real Linux accounts for members, so the surfaces that execute code can run as them.
|
||||
@@ -464,6 +465,9 @@ export async function confineUserTree(params: {
|
||||
* gets skipped. Returns the offending paths; the caller decides whether that is fatal.
|
||||
*/
|
||||
export async function findReadableSecrets(projectDir: string): Promise<string[]> {
|
||||
// The store is not in projectDir — it sits at $OFFICER_ROOT/secrets/, a sibling of the repo — so it is
|
||||
// checked separately below. Its keys are strictly worse to leak than .env ever was: the 'jwt' purpose
|
||||
// mints owner tokens, and every other purpose decrypts a credential column in Postgres.
|
||||
const candidates = ['.env', '.env.local', '.env.production'];
|
||||
const bad: string[] = [];
|
||||
for (const name of candidates) {
|
||||
@@ -477,6 +481,21 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
|
||||
// Unreadable to us is not a leak to them; nothing to report.
|
||||
}
|
||||
}
|
||||
|
||||
// The secret store and its directory. The WAL is included deliberately: a freshly written key lives
|
||||
// there before checkpoint, so a 0600 database beside a world-readable WAL protects nothing.
|
||||
const storeFile = secretStorePath();
|
||||
const storeDir = dirname(storeFile);
|
||||
for (const path of [storeDir, storeFile, `${storeFile}-wal`, `${storeFile}-shm`]) {
|
||||
if (!existsSync(path)) continue;
|
||||
try {
|
||||
const info = await stat(path);
|
||||
if (info.mode & 0o044) bad.push(path);
|
||||
} catch {
|
||||
// Same reasoning as above.
|
||||
}
|
||||
}
|
||||
|
||||
return bad;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user