.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>
47 lines
1.8 KiB
TypeScript
47 lines
1.8 KiB
TypeScript
import { sign as jwtSign, verify as jwtVerify } from 'hono/jwt';
|
|
import { getKey } from 'officerdb/secret-store';
|
|
|
|
// 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 {
|
|
const match = expiration.match(/^(\d+)([smhd])$/);
|
|
if (!match) {
|
|
throw new Error(`Invalid expiration format: ${expiration}`);
|
|
}
|
|
const value = parseInt(match[1]!, 10);
|
|
const unit = match[2];
|
|
const multipliers: Record<string, number> = {
|
|
s: 1,
|
|
m: 60,
|
|
h: 60 * 60,
|
|
d: 60 * 60 * 24,
|
|
};
|
|
return Math.floor(Date.now() / 1000) + value * multipliers[unit!]!;
|
|
}
|
|
|
|
export async function sign(payload: any, expiration = '30d') {
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const exp = parseExpiration(expiration);
|
|
const jti = crypto.randomUUID();
|
|
const data = { ...payload, exp, iat: now, jti };
|
|
return jwtSign(data, signingKey());
|
|
}
|
|
|
|
export async function verify(token: string): Promise<any> {
|
|
return jwtVerify(token, signingKey(), 'HS256');
|
|
}
|