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:
@@ -7,7 +7,8 @@
|
||||
".": "./src/index.ts",
|
||||
"./types": "./src/types.ts",
|
||||
"./db": "./src/db.ts",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./secret-store": "./src/secret-store.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"generate": "drizzle-kit generate --config=drizzle.config.ts",
|
||||
|
||||
@@ -1,40 +1,51 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
import { getKey } from './secret-store';
|
||||
|
||||
// AES-256-GCM at-rest encryption for vault secrets (the brokered Vaultwarden token set + the Officer-app
|
||||
// protector key). The whole point of the vault store is that a DB dump must not hand over the keys to the
|
||||
// vault, so these columns are never stored plaintext.
|
||||
// AES-256-GCM at-rest encryption for every secret column in Postgres. The property this exists to hold is
|
||||
// that a database dump must not hand over the credentials in it, so these columns are never plaintext.
|
||||
//
|
||||
// Key = SHA-256(VAULT_STORE_KEY) so any sufficiently strong secret works (mirrors the JWT_SECRET style).
|
||||
// Format = base64(iv[12] | authTag[16] | ciphertext). The key is read LAZILY so the platform still boots
|
||||
// without a vault configured — vault storage ops then throw a clear error instead of crashing startup.
|
||||
// Format = base64(iv[12] | authTag[16] | ciphertext).
|
||||
//
|
||||
// ── One key per purpose ──
|
||||
//
|
||||
// This took a single VAULT_STORE_KEY from the environment until 2026-08-13. That key encrypted seven
|
||||
// unrelated things — the Headscale admin credential, the wallet seed, Vaultwarden's token set, Jellyfin,
|
||||
// Immich, InvoiceShelf, and every app-store upstream secret — so one leak opened all of them, and its
|
||||
// name pointed at whichever plugin happened to need it first.
|
||||
//
|
||||
// `purpose` is now the first argument everywhere, and the caller passes the one that owns the data. A
|
||||
// plugin's key is created on first use and cannot decrypt another plugin's column, because the AES key
|
||||
// derives from a different stored secret. See ./secret-store.ts and docs/secret-store.md.
|
||||
//
|
||||
// SHA-256 over the stored key rather than using its bytes directly, so the store is free to change how it
|
||||
// represents a key without every ciphertext in the database becoming unreadable.
|
||||
|
||||
let cachedKey: Buffer | null = null;
|
||||
function key(): Buffer {
|
||||
if (cachedKey) return cachedKey;
|
||||
const secret = process.env.VAULT_STORE_KEY;
|
||||
if (!secret || secret.length < 16) {
|
||||
throw new Error('VAULT_STORE_KEY must be set (>=16 chars) to store vault secrets');
|
||||
}
|
||||
cachedKey = createHash('sha256').update(secret).digest();
|
||||
return cachedKey;
|
||||
const cache = new Map<string, Buffer>();
|
||||
|
||||
function key(purpose: string): Buffer {
|
||||
const hit = cache.get(purpose);
|
||||
if (hit) return hit;
|
||||
const derived = createHash('sha256').update(getKey(purpose)).digest();
|
||||
cache.set(purpose, derived);
|
||||
return derived;
|
||||
}
|
||||
|
||||
/** Encrypt a UTF-8 secret for at-rest storage → base64(iv|tag|ciphertext). */
|
||||
export function encryptSecret(plaintext: string): string {
|
||||
export function encryptSecret(purpose: string, plaintext: string): string {
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(), iv);
|
||||
const cipher = createCipheriv('aes-256-gcm', key(purpose), iv);
|
||||
const ct = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
return Buffer.concat([iv, tag, ct]).toString('base64');
|
||||
}
|
||||
|
||||
/** Decrypt a value produced by encryptSecret. Throws if the ciphertext/tag/key don't verify. */
|
||||
export function decryptSecret(blob: string): string {
|
||||
/** Decrypt a value produced by encryptSecret under the SAME purpose. Throws if it does not verify. */
|
||||
export function decryptSecret(purpose: string, blob: string): string {
|
||||
const buf = Buffer.from(blob, 'base64');
|
||||
const iv = buf.subarray(0, 12);
|
||||
const tag = buf.subarray(12, 28);
|
||||
const ct = buf.subarray(28);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(), iv);
|
||||
const decipher = createDecipheriv('aes-256-gcm', key(purpose), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
@@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise<Hea
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
}
|
||||
|
||||
/** One server's credentials by id — for probing a specific server rather than the active one. */
|
||||
@@ -64,7 +64,7 @@ export async function getHeadscaleCredentials(userId: number, id: number): Promi
|
||||
.from(headscaleServers)
|
||||
.where(and(eq(headscaleServers.userId, userId), eq(headscaleServers.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, name: row.name, url: row.url, apiKey: decryptSecret('headscale', row.apiKey) };
|
||||
}
|
||||
|
||||
type CreateHeadscaleServerParams = {
|
||||
@@ -95,7 +95,7 @@ export async function createHeadscaleServer(params: CreateHeadscaleServerParams)
|
||||
userId,
|
||||
name,
|
||||
url,
|
||||
apiKey: encryptSecret(apiKey),
|
||||
apiKey: encryptSecret('headscale', apiKey),
|
||||
version,
|
||||
sshHost,
|
||||
isActive: activate,
|
||||
@@ -119,7 +119,7 @@ export async function updateHeadscaleServer(
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.name !== undefined) set.name = params.name;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret('headscale', params.apiKey);
|
||||
if (params.sshHost !== undefined) set.sshHost = params.sshHost;
|
||||
|
||||
const [row] = await db
|
||||
|
||||
@@ -60,7 +60,7 @@ export async function getActiveInvoiceshelfCredentials(userId: number): Promise<
|
||||
.from(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
|
||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
|
||||
}
|
||||
|
||||
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||
@@ -70,7 +70,7 @@ export async function getInvoiceshelfCredentials(userId: number, id: number): Pr
|
||||
.from(invoiceshelfAccounts)
|
||||
.where(and(eq(invoiceshelfAccounts.userId, userId), eq(invoiceshelfAccounts.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret(row.token), companyId: row.companyId };
|
||||
return { id: row.id, label: row.label, url: row.url, token: decryptSecret('invoiceshelf', row.token), companyId: row.companyId };
|
||||
}
|
||||
|
||||
type CreateInvoiceshelfAccountParams = {
|
||||
@@ -101,7 +101,7 @@ export async function createInvoiceshelfAccount(params: CreateInvoiceshelfAccoun
|
||||
userId,
|
||||
label,
|
||||
url,
|
||||
token: encryptSecret(token),
|
||||
token: encryptSecret('invoiceshelf', token),
|
||||
companyId,
|
||||
companyName,
|
||||
version,
|
||||
@@ -131,7 +131,7 @@ export async function updateInvoiceshelfAccount(
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.label !== undefined) set.label = params.label;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.token !== undefined) set.token = encryptSecret(params.token);
|
||||
if (params.token !== undefined) set.token = encryptSecret('invoiceshelf', params.token);
|
||||
if (params.companyId !== undefined) set.companyId = params.companyId;
|
||||
if (params.companyName !== undefined) set.companyName = params.companyName;
|
||||
if (params.version !== undefined) set.version = params.version;
|
||||
|
||||
@@ -51,7 +51,7 @@ const toCredentials = (row: typeof jellyfinServers.$inferSelect): JellyfinCreden
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
url: row.url,
|
||||
accessToken: decryptSecret(row.accessToken),
|
||||
accessToken: decryptSecret('jellyfin', row.accessToken),
|
||||
jellyfinUserId: row.jellyfinUserId,
|
||||
deviceId: row.deviceId,
|
||||
});
|
||||
@@ -112,7 +112,7 @@ export async function createJellyfinServer(params: CreateJellyfinServerParams):
|
||||
.values({
|
||||
userId,
|
||||
...rest,
|
||||
accessToken: encryptSecret(accessToken),
|
||||
accessToken: encryptSecret('jellyfin', accessToken),
|
||||
isActive: activate,
|
||||
lastSeenAt: rest.version ? new Date() : null,
|
||||
})
|
||||
@@ -142,7 +142,7 @@ export async function updateJellyfinServer(
|
||||
.update(jellyfinServers)
|
||||
.set({
|
||||
...rest,
|
||||
...(accessToken ? { accessToken: encryptSecret(accessToken) } : {}),
|
||||
...(accessToken ? { accessToken: encryptSecret('jellyfin', accessToken) } : {}),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(jellyfinServers.userId, userId), eq(jellyfinServers.id, id)))
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function getActivePhotosCredentials(userId: number): Promise<Photos
|
||||
.from(photosConfig)
|
||||
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.isActive, true)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||
}
|
||||
|
||||
/** One account's credentials by id — for probing a specific account rather than the active one. */
|
||||
@@ -60,7 +60,7 @@ export async function getPhotosCredentials(userId: number, id: number): Promise<
|
||||
.from(photosConfig)
|
||||
.where(and(eq(photosConfig.userId, userId), eq(photosConfig.id, id)));
|
||||
if (!row) return null;
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret(row.apiKey) };
|
||||
return { id: row.id, label: row.label, url: row.url, apiKey: decryptSecret('photos', row.apiKey) };
|
||||
}
|
||||
|
||||
type CreatePhotosAccountParams = {
|
||||
@@ -89,7 +89,7 @@ export async function createPhotosAccount(params: CreatePhotosAccountParams): Pr
|
||||
userId,
|
||||
label,
|
||||
url,
|
||||
apiKey: encryptSecret(apiKey),
|
||||
apiKey: encryptSecret('photos', apiKey),
|
||||
version,
|
||||
isActive: activate,
|
||||
lastSeenAt: version ? new Date() : null,
|
||||
@@ -110,7 +110,7 @@ export async function updatePhotosAccount(
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (params.label !== undefined) set.label = params.label;
|
||||
if (params.url !== undefined) set.url = params.url;
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret(params.apiKey);
|
||||
if (params.apiKey !== undefined) set.apiKey = encryptSecret('photos', params.apiKey);
|
||||
if (params.version !== undefined) set.version = params.version;
|
||||
|
||||
const [row] = await db
|
||||
|
||||
@@ -86,7 +86,7 @@ export async function getServiceCredentials(userId: number, service: ServiceName
|
||||
id: row.id,
|
||||
url: row.url,
|
||||
username: row.username,
|
||||
secret: row.secret ? decryptSecret(row.secret) : null,
|
||||
secret: row.secret ? decryptSecret('service-connections', row.secret) : null,
|
||||
path: row.path,
|
||||
};
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export async function saveServiceConnection(params: SaveServiceConnectionParams)
|
||||
|
||||
const set: Partial<Row> = { url, updatedAt: now };
|
||||
if (username !== undefined) set.username = username;
|
||||
if (secret !== undefined) set.secret = secret === null ? null : encryptSecret(secret);
|
||||
if (secret !== undefined) set.secret = secret === null ? null : encryptSecret('service-connections', secret);
|
||||
if (path !== undefined) set.path = path;
|
||||
if (version !== undefined) {
|
||||
set.version = version;
|
||||
@@ -133,7 +133,7 @@ export async function saveServiceConnection(params: SaveServiceConnectionParams)
|
||||
service,
|
||||
url,
|
||||
username: username ?? null,
|
||||
secret: secret ? encryptSecret(secret) : null,
|
||||
secret: secret ? encryptSecret('service-connections', secret) : null,
|
||||
path: path ?? null,
|
||||
version: version ?? null,
|
||||
lastSeenAt: version ? now : null,
|
||||
|
||||
@@ -19,8 +19,8 @@ export async function getVaultTokens(userId: number): Promise<VaultTokenSet | nu
|
||||
const [row] = await db.select().from(vaultTokens).where(eq(vaultTokens.userId, userId));
|
||||
if (!row) return null;
|
||||
return {
|
||||
accessToken: decryptSecret(row.accessToken),
|
||||
refreshToken: decryptSecret(row.refreshToken),
|
||||
accessToken: decryptSecret('vault', row.accessToken),
|
||||
refreshToken: decryptSecret('vault', row.refreshToken),
|
||||
expiresAt: row.expiresAt,
|
||||
deviceIdentifier: row.deviceIdentifier,
|
||||
clientId: row.clientId,
|
||||
@@ -31,8 +31,8 @@ export async function getVaultTokens(userId: number): Promise<VaultTokenSet | nu
|
||||
export async function setVaultTokens(userId: number, t: VaultTokenSet): Promise<void> {
|
||||
const values = {
|
||||
userId,
|
||||
accessToken: encryptSecret(t.accessToken),
|
||||
refreshToken: encryptSecret(t.refreshToken),
|
||||
accessToken: encryptSecret('vault', t.accessToken),
|
||||
refreshToken: encryptSecret('vault', t.refreshToken),
|
||||
expiresAt: t.expiresAt,
|
||||
deviceIdentifier: t.deviceIdentifier,
|
||||
clientId: t.clientId,
|
||||
@@ -64,8 +64,8 @@ export async function updateVaultAccess(
|
||||
await db
|
||||
.update(vaultTokens)
|
||||
.set({
|
||||
accessToken: encryptSecret(accessToken),
|
||||
refreshToken: encryptSecret(refreshToken),
|
||||
accessToken: encryptSecret('vault', accessToken),
|
||||
refreshToken: encryptSecret('vault', refreshToken),
|
||||
expiresAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
@@ -80,12 +80,12 @@ export async function clearVaultTokens(userId: number): Promise<void> {
|
||||
/** The owner's stored protector key (decrypted), or null. Officer-app unlock path only. */
|
||||
export async function getVaultUnlockKey(userId: number): Promise<string | null> {
|
||||
const [row] = await db.select().from(vaultUnlockKeys).where(eq(vaultUnlockKeys.userId, userId));
|
||||
return row ? decryptSecret(row.wrappedKey) : null;
|
||||
return row ? decryptSecret('vault', row.wrappedKey) : null;
|
||||
}
|
||||
|
||||
/** Store/replace the protector key (encrypted). Set once at setup; persists across normal logout. */
|
||||
export async function setVaultUnlockKey(userId: number, wrappedKey: string): Promise<void> {
|
||||
const values = { userId, wrappedKey: encryptSecret(wrappedKey), updatedAt: new Date() };
|
||||
const values = { userId, wrappedKey: encryptSecret('vault', wrappedKey), updatedAt: new Date() };
|
||||
await db
|
||||
.insert(vaultUnlockKeys)
|
||||
.values(values)
|
||||
|
||||
@@ -4,7 +4,8 @@ import { db } from '../db';
|
||||
import { walletWallets, walletLabels, walletFrozenUtxos, walletChainCache } from '../schema';
|
||||
import { encryptSecret, decryptSecret } from '../crypto';
|
||||
|
||||
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the VAULT_STORE_KEY layer is
|
||||
// Wallet access for the officer-wallet sidecar. Callers deal in PLAINTEXT — the at-rest layer ('wallet'
|
||||
// purpose in the secret store) is
|
||||
// applied and stripped here, so route handlers never touch crypto. See ../crypto.ts, ../schema/wallet.ts.
|
||||
//
|
||||
// Note what "plaintext" means for `seedEnvelope`: it is the passphrase-sealed envelope, which is itself
|
||||
@@ -117,7 +118,7 @@ export async function getWalletSecrets(userId: number, id: number): Promise<Wall
|
||||
id: row.id,
|
||||
kind: row.kind as WalletKind,
|
||||
network: row.network,
|
||||
config: row.config ? (JSON.parse(decryptSecret(row.config)) as Record<string, unknown>) : null,
|
||||
config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record<string, unknown>) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,7 +132,7 @@ export async function getSealedSeed(userId: number, id: number): Promise<string
|
||||
.from(walletWallets)
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
if (!row?.seedEnvelope) return null;
|
||||
return decryptSecret(row.seedEnvelope);
|
||||
return decryptSecret('wallet', row.seedEnvelope);
|
||||
}
|
||||
|
||||
export type CreateWalletParams = {
|
||||
@@ -164,8 +165,8 @@ export async function createWallet(params: CreateWalletParams): Promise<WalletSu
|
||||
name: params.name,
|
||||
kind: params.kind,
|
||||
network: params.network,
|
||||
config: params.config ? encryptSecret(JSON.stringify(params.config)) : null,
|
||||
seedEnvelope: params.sealedSeed ? encryptSecret(params.sealedSeed) : null,
|
||||
config: params.config ? encryptSecret('wallet', JSON.stringify(params.config)) : null,
|
||||
seedEnvelope: params.sealedSeed ? encryptSecret('wallet', params.sealedSeed) : null,
|
||||
fingerprint: params.fingerprint ?? null,
|
||||
xpubs: params.xpubs ?? null,
|
||||
defaultBip: params.defaultBip ?? 84,
|
||||
@@ -188,7 +189,7 @@ export async function updateWallet(
|
||||
const set: Record<string, unknown> = { updatedAt: new Date() };
|
||||
if (patch.name !== undefined) set.name = patch.name;
|
||||
if (patch.defaultBip !== undefined) set.defaultBip = patch.defaultBip;
|
||||
if (patch.config !== undefined) set.config = encryptSecret(JSON.stringify(patch.config));
|
||||
if (patch.config !== undefined) set.config = encryptSecret('wallet', JSON.stringify(patch.config));
|
||||
|
||||
const [row] = await db
|
||||
.update(walletWallets)
|
||||
@@ -212,7 +213,7 @@ export async function updateWallet(
|
||||
export async function replaceSealedSeed(userId: number, id: number, sealedSeed: string): Promise<void> {
|
||||
await db
|
||||
.update(walletWallets)
|
||||
.set({ seedEnvelope: encryptSecret(sealedSeed), updatedAt: new Date() })
|
||||
.set({ seedEnvelope: encryptSecret('wallet', sealedSeed), updatedAt: new Date() })
|
||||
.where(and(eq(walletWallets.userId, userId), eq(walletWallets.id, id)));
|
||||
}
|
||||
|
||||
|
||||
@@ -9,12 +9,13 @@ import { users } from './auth';
|
||||
// TWO COLUMNS HOLD SPENDING AUTHORITY AND THEY ARE PROTECTED DIFFERENTLY. This asymmetry is deliberate:
|
||||
//
|
||||
// `config` — node credentials (macaroon, rune, LNDHub password, NWC URI). Encrypted at rest with
|
||||
// VAULT_STORE_KEY via ../crypto.ts, same as headscale_servers.api_key. It CANNOT be
|
||||
// the 'wallet' store key via ../crypto.ts, the way headscale_servers.api_key uses its
|
||||
// own. It CANNOT be
|
||||
// passphrase-protected: background balance polling needs it without the owner present.
|
||||
//
|
||||
// `seed_envelope` — a BIP39 mnemonic that is ALREADY sealed under an owner passphrase by the sidecar
|
||||
// (see servers/sidecar/wallet/keys.ts) before it ever arrives here, and is then
|
||||
// encrypted AGAIN with VAULT_STORE_KEY on the way into this column. Two independent
|
||||
// encrypted AGAIN with the 'wallet' store key on the way into this column. Two independent
|
||||
// secrets, neither sufficient alone. A database dump does not spend; a leaked .env
|
||||
// does not spend.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
import { Database } from 'bun:sqlite';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync } from 'node:fs';
|
||||
import { join, resolve } from 'node:path';
|
||||
|
||||
// Every encryption and signing key the platform holds, one SQLite file, one key per purpose.
|
||||
//
|
||||
// Design and rationale: docs/secret-store.md. What follows is only what a caller needs.
|
||||
//
|
||||
// ── The file IS the secret ──
|
||||
//
|
||||
// Keys are stored as they are used. There is no second key that unlocks this file, because a key sitting
|
||||
// beside the store it opens buys nothing — whoever can read one can read the other. The boundary is
|
||||
// `0600`, owned by the service user, and the fact that only the processes that need a key ever open it.
|
||||
//
|
||||
// That is the point of the whole exercise, and it is about blast radius rather than secrecy: `.env` is
|
||||
// auto-loaded by bun into ALL of the pm2 processes, so a key there is readable from `/proc/<pid>/environ`
|
||||
// of twenty processes that mostly have no business with it. `officer-music` held the key that decrypts
|
||||
// wallet seed envelopes. Read on demand, by the few that need it, is the fix.
|
||||
//
|
||||
// ── One key per purpose, not one key for everything ──
|
||||
//
|
||||
// This replaced a single VAULT_STORE_KEY that encrypted seven unrelated things — the Headscale admin
|
||||
// credential, the wallet seed, Jellyfin tokens, Immich, InvoiceShelf, Vaultwarden's token set and every
|
||||
// app-store upstream secret. One leaked key opened all of them, and the name pointed at whichever plugin
|
||||
// happened to need it first, so nobody reading it could tell what changing it would destroy.
|
||||
//
|
||||
// Purposes are created on first use, so a plugin installed in six months finds the store already there
|
||||
// and simply asks for its own. Nothing has to be provisioned in advance, and no plugin can read another's.
|
||||
//
|
||||
// ── Rotation ──
|
||||
//
|
||||
// Not implemented, but the schema is shaped for it: keys are rows with `retired_at`, and the partial
|
||||
// unique index permits exactly one ACTIVE key per purpose while keeping the retired ones. A rotation
|
||||
// retires the current key, inserts a new one, and re-encrypts; `retiredKeys()` is what lets a decrypt
|
||||
// still succeed for rows written before it finished.
|
||||
|
||||
const STORE_DIR_MODE = 0o700;
|
||||
const STORE_FILE_MODE = 0o600;
|
||||
|
||||
// The same derivation as src/servers/data-path.ts, and deliberately a second copy of that one line.
|
||||
// It cannot be imported: this module is inside the `officerdb` package, and a package reaching back into
|
||||
// `src/servers` inverts the dependency. Importing the other direction is worse — `officerdb`'s index
|
||||
// pulls in db.ts, which opens a Postgres client at module load and throws without POSTGRES_URL, so
|
||||
// `jwt.ts` asking for a key would drag a database connection into every process that signs a token.
|
||||
const OFFICER_ROOT = resolve(process.cwd(), '..');
|
||||
|
||||
// NOT under `data/`. That directory holds managed homes and attachments — it is the one people back up,
|
||||
// and a key store travelling in the same tarball as a database dump rebuilds the exact problem this
|
||||
// exists to avoid. See docs/secret-store.md, decision 3.
|
||||
const STORE_DIR = join(OFFICER_ROOT, 'secrets');
|
||||
const STORE_PATH = join(STORE_DIR, 'officer-keys.db');
|
||||
|
||||
let db: Database | null = null;
|
||||
|
||||
function open(): Database {
|
||||
if (db) return db;
|
||||
|
||||
mkdirSync(STORE_DIR, { recursive: true, mode: STORE_DIR_MODE });
|
||||
chmodSync(STORE_DIR, STORE_DIR_MODE);
|
||||
|
||||
const handle = new Database(STORE_PATH, { create: true });
|
||||
|
||||
// busy_timeout FIRST, and the order is the whole point. `journal_mode = WAL` takes an exclusive lock,
|
||||
// so with the default zero timeout it throws SQLITE_BUSY the moment another process holds the file —
|
||||
// measured with eight concurrent openers on bun 1.3.14, six died on this line. Every sidecar opens this
|
||||
// store at boot, so they open it simultaneously by definition, and the failure would have been most of
|
||||
// them refusing to start on a cold boot and none of them on a warm one.
|
||||
handle.exec('PRAGMA busy_timeout = 5000');
|
||||
|
||||
// WAL because several sidecars hold this open at once, and the default rollback journal makes a reader
|
||||
// block a writer. Persistent once set, so this is a no-op on every open after the first.
|
||||
handle.exec('PRAGMA journal_mode = WAL');
|
||||
|
||||
handle.exec(`
|
||||
CREATE TABLE IF NOT EXISTS keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
purpose TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
retired_at INTEGER
|
||||
)
|
||||
`);
|
||||
|
||||
// One ACTIVE key per purpose. Partial, so retired keys accumulate beside it for rotation. This is also
|
||||
// what makes concurrent first-use safe: two processes racing to create the same purpose means one INSERT
|
||||
// fails, and the loser re-reads rather than minting a second key that would decrypt nothing.
|
||||
handle.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_keys_active_purpose ON keys(purpose) WHERE retired_at IS NULL');
|
||||
|
||||
// Applied after creation: the file does not exist until the first statement runs.
|
||||
//
|
||||
// The sidecar files matter as much as the database. Measured on bun 1.3.10: enabling WAL creates
|
||||
// `-wal` and `-shm` at 0644 rather than inheriting the database's mode, and the WAL is where a freshly
|
||||
// written key actually lives — so a 0600 database beside a world-readable WAL protects nothing. The
|
||||
// 0700 directory is the real boundary and would cover it either way; these are set so that loosening
|
||||
// the directory later does not silently expose the keys.
|
||||
for (const path of [STORE_PATH, `${STORE_PATH}-wal`, `${STORE_PATH}-shm`]) {
|
||||
if (existsSync(path)) chmodSync(path, STORE_FILE_MODE);
|
||||
}
|
||||
|
||||
db = handle;
|
||||
return db;
|
||||
}
|
||||
|
||||
/** 32 bytes, base64url so it survives being put anywhere without quoting. */
|
||||
function generate(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
function readActive(purpose: string): string | null {
|
||||
const row = open().query('SELECT key FROM keys WHERE purpose = ? AND retired_at IS NULL').get(purpose) as
|
||||
| { key: string }
|
||||
| undefined;
|
||||
return row?.key ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The active key for a purpose, created on first use.
|
||||
*
|
||||
* Purposes are plain strings and belong to whoever owns the data they protect: `jwt`, `headscale`,
|
||||
* `wallet`, `photos`, `jellyfin`, `invoiceshelf`, `vault`, `service-connections`. A plugin asks for its
|
||||
* own and never another's — same rule `service_connections` rows already follow.
|
||||
*
|
||||
* Creating on demand means losing this file does not fail loudly, it mints new keys: every session is
|
||||
* invalidated and every encrypted column becomes unreadable. Back the file up, and see `hasKey` for the
|
||||
* callers that need to distinguish "no key yet" from "key exists".
|
||||
*/
|
||||
export function getKey(purpose: string): string {
|
||||
const existing = readActive(purpose);
|
||||
if (existing) return existing;
|
||||
|
||||
const key = generate();
|
||||
try {
|
||||
open()
|
||||
.query('INSERT INTO keys (purpose, key, created_at) VALUES (?, ?, ?)')
|
||||
.run(purpose, key, Math.floor(Date.now() / 1000));
|
||||
return key;
|
||||
} catch {
|
||||
// Lost the race against another process. Its key is the real one — ours was never written and never
|
||||
// encrypted anything, so there is nothing to reconcile.
|
||||
const won = readActive(purpose);
|
||||
if (won) return won;
|
||||
throw new Error(`secret-store: could not create or read a key for '${purpose}'`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether a purpose has an active key, without creating one. */
|
||||
export function hasKey(purpose: string): boolean {
|
||||
return readActive(purpose) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retired keys for a purpose, newest first. Empty until something rotates.
|
||||
*
|
||||
* A decrypt that fails against the active key should try these before giving up: during a rotation, rows
|
||||
* written before it started are still under the previous key.
|
||||
*/
|
||||
export function retiredKeys(purpose: string): string[] {
|
||||
const rows = open()
|
||||
.query('SELECT key FROM keys WHERE purpose = ? AND retired_at IS NOT NULL ORDER BY retired_at DESC')
|
||||
.all(purpose) as Array<{ key: string }>;
|
||||
return rows.map((r) => r.key);
|
||||
}
|
||||
|
||||
/** Where the store lives. For the setup script and for error messages that need to name the file. */
|
||||
export function secretStorePath(): string {
|
||||
return STORE_PATH;
|
||||
}
|
||||
Reference in New Issue
Block a user