diff --git a/.env.example b/.env.example index 4d7bbadc..eb9625a8 100644 --- a/.env.example +++ b/.env.example @@ -2,25 +2,21 @@ PORT=9000 POSTGRES_URL="postgres://postgres:password@localhost:5432/officer" -# ── Moving to the secret store ───────────────────────────────────────────────────────────────── -# Still REQUIRED — jwt.ts throws at module load without JWT_SECRET, and crypto.ts throws without -# VAULT_STORE_KEY — but officer-setup no longer writes either. They are moving into the SQLite key -# store (docs/secret-store.md), which is designed and not yet built, so an install made by the -# current script will not boot until it is. That is deliberate sequencing, not an oversight. -JWT_SECRET="" - -# NOT Vaultwarden's, despite the name and where it used to sit — it is the platform's at-rest key, -# encrypting every secret column in Postgres: Headscale admin API keys, app-store service -# credentials, Jellyfin tokens, wallet node credentials, and the wallet seed envelope on top of the -# owner passphrase that seals it. +# ── No secrets live here ─────────────────────────────────────────────────────────────────────── +# JWT_SECRET and VAULT_STORE_KEY were here until 2026-08-13. Every encryption and signing key now +# lives in the secret store — a 0600 SQLite file at $OFFICER_ROOT/secrets/officer-keys.db, one key +# per purpose, created on first use. See docs/secret-store.md. # -# CHANGING IT MAKES ALL OF THAT UNREADABLE AT ONCE, and for the seed that is unrecoverable: the -# passphrase opens the inner envelope and this is the outer one. -VAULT_STORE_KEY="" +# The reason is blast radius rather than secrecy: bun auto-loads this file into ALL of the pm2 +# processes, so a key here is readable from /proc//environ of twenty processes that mostly have +# no business with it — officer-music held the key that decrypts wallet seed envelopes. +# +# BACK UP THAT FILE. Losing it signs everyone out and makes every encrypted column in Postgres +# unreadable, and for the wallet seed that is unrecoverable. # ── Optional ─────────────────────────────────────────────────────────────────────────────────── -# Where Officer is reached from a browser. Read by origin validation, the task API host check, and -# the CalDAV iOS profile builder — which is the only one that hard-requires it, and demands https. +# Where Officer is reached from a browser. Read by the task API host check and the CalDAV iOS profile +# builder — the latter is the only thing that hard-requires it, and it demands https. # PUBLIC_URL=https://officer.example.com # Guards (CORS origin checks, rate limits, password-strength rules) are ON unless this is set to diff --git a/docs/secret-store.md b/docs/secret-store.md index 4c84a243..355b0c23 100644 --- a/docs/secret-store.md +++ b/docs/secret-store.md @@ -1,11 +1,19 @@ # The secret store -**Status: DESIGN, agreed in conversation 2026-08-12. Nothing implemented.** Every fact below about the -current code was checked against the tree on that date; the file:line references are live. +**Status: BUILT 2026-08-13.** `src/databases/officer_db/src/secret-store.ts`, with `jwt.ts` and +`crypto.ts` reading from it and `officer-setup.sh` section 7 bootstrapping it. Rotation is NOT built — +the schema carries `retired_at` and the API exposes `retiredKeys()`, but nothing retires or re-encrypts +yet. -A small SQLite database, created during setup, holding every encryption and signing key the platform -uses. It replaces `VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses -instead of inventing its own. +A small SQLite database holding every encryption and signing key the platform uses. It replaced +`VAULT_STORE_KEY` and `JWT_SECRET` in `.env`, and it is the facility a plugin uses instead of inventing +its own. + +**One change from the design below: keys are per PURPOSE, not one key for everything.** The original +plan moved a single at-rest key into the store. What shipped gives `headscale`, `wallet`, `photos`, +`jellyfin`, `invoiceshelf`, `vault` and `service-connections` a key each, so one leaked key opens one +plugin's columns rather than all seven. `jwt` is the eighth. A core install bootstraps two — `jwt` and +`headscale` — and every other purpose is created when its plugin first asks. --- @@ -69,7 +77,18 @@ both to still exist. That is a table with `id, purpose, key, created_at, retired as an environment variable or a single-value file. Concurrent access from several sidecars is the second reason; SQLite's locking is the part a hand-rolled file store gets wrong. -### 2. It is NOT encrypted at rest, for now +### 2. It is NOT encrypted at rest — and that decision changed shape + +**As built, the file IS the secret.** Keys are stored as they are used, with no second key unlocking +them, because a key sitting beside the store it opens buys nothing: whoever can read one can read the +other. The boundary is `0700` on the directory, `0600` on the file, owned by the service user. + +That answers open question 4 below — nothing stays outside, and `.env` holds no secret at all. + +The original reasoning for encrypted-values-in-a-plaintext-file is kept below because the SQLCipher +finding is still true and still the reason whole-file encryption is not on the table. + +#### The original note Checked rather than assumed, because `PRAGMA key` appears to work and does not: @@ -94,25 +113,30 @@ trade and it is written down here so nobody later assumes the file is opaque. ### 3. Where the file goes +**`$OFFICER_ROOT/secrets/officer-keys.db`** — a sibling of `platform/` and `data/`, decided 2026-08-13. + **Not in `$OFFICER_ROOT/data/`.** That directory holds managed homes and attachments — it is the one people back up. A key store that travels in the same tarball as a database dump rebuilds the exact problem this design exists to avoid. -`[open]` The location. It needs to be somewhere a routine backup does not sweep up, or somewhere -documented loudly enough that a backup script excludes it deliberately. +The setup script says so out loud when it creates the store, because "back this up, but not next to the +other thing you back up" is not a rule anyone infers. -### 4. One secret remains outside +### 4. ~~One secret remains outside~~ — none does -The store's own key — whatever unlocks the values inside it. That is unavoidable and is the point of the -whole exercise: **N secrets in twenty process environments becomes one secret, read on demand, by the -two processes that need it.** +Answered 2026-08-13: **no secret remains in `.env`.** The store file is the secret, per decision 2. -`[open]` Whether that one secret stays in `.env` — which reintroduces the auto-load problem for exactly -one value — or comes from a file read on demand. +The point of the exercise still holds, and it was always about blast radius rather than secrecy: **N +secrets in twenty process environments becomes a file read on demand by the few processes that need +it.** `.env` is auto-loaded by bun into every pm2 process, so a key there is readable from +`/proc//environ` of twenty processes — `officer-music` held the key that decrypts wallet seed +envelopes. A file opened by the two or three processes that actually use a key does not. ### 5. What moves in -- `VAULT_STORE_KEY` — the at-rest key for everything in the table above. +- `VAULT_STORE_KEY` — **split into one key per purpose**, rather than moved. See the status note at the + top: the table above is seven unrelated things, and one key for all of them meant one leak opened all + of them. - `JWT_SECRET` — a signing key rather than an encryption key, but it has the same properties: must survive restarts, must never be regenerated silently, and benefits from versioning during a rotation. Leaving one in a store and one in `.env` would be the scattering this is meant to end. @@ -208,8 +232,10 @@ wallet table and it cannot be interrupted safely, which argues for something tha ## What this does not change - Secrets stay in Postgres. This moves the **keys**, not the data. -- `crypto.ts`'s interface stays: `encryptSecret` / `decryptSecret`. Only where the key comes from - changes, so no caller is touched. +- ~~`crypto.ts`'s interface stays~~ — **it did not.** Per-purpose keys mean the purpose has to be named + at the call site, so it is `encryptSecret('headscale', plaintext)` now and all seven query modules + were touched. That was the cost of the split, and it is worth stating plainly because this line + originally promised the opposite. - The owner passphrase on wallet seeds is untouched and stays out of every store. Two independent secrets is the property that makes a stolen `.env` insufficient, and it survives this design. diff --git a/scripts/setup/officer-setup.sh b/scripts/setup/officer-setup.sh index df768686..d6109fca 100755 --- a/scripts/setup/officer-setup.sh +++ b/scripts/setup/officer-setup.sh @@ -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 # ============================================================================= diff --git a/scripts/setup/officer-setup/lib/env.sh b/scripts/setup/officer-setup/lib/env.sh index 8523c0a5..25c63065 100644 --- a/scripts/setup/officer-setup/lib/env.sh +++ b/scripts/setup/officer-setup/lib/env.sh @@ -5,16 +5,14 @@ # # Definitions only. # -# ── What is NOT here ── +# ── No secrets are written here ── # -# JWT_SECRET and VAULT_STORE_KEY are not written. They are moving into the SQLite -# key store (docs/secret-store.md), and writing them here in the meantime would -# mean generating a value that the store then has to be reconciled with — two -# origins for one secret, which is the failure the store exists to end. +# Every encryption and signing key lives in the secret store — a 0600 SQLite file +# at $OFFICER_ROOT/secrets/officer-keys.db, one key per purpose, created on first +# use. See docs/secret-store.md and the Secrets section of officer-setup.sh. # -# The consequence is honest and deliberate: jwt.ts throws at module load without -# JWT_SECRET, so an install made by this script does not boot until the store -# lands. That sequencing was chosen rather than stumbled into. +# So this file holds no credential except POSTGRES_URL, which is a connection +# string to a database bound to loopback. # # ── Derived, not asked ── # diff --git a/scripts/setup/officer-setup/lib/secrets.sh b/scripts/setup/officer-setup/lib/secrets.sh new file mode 100644 index 00000000..5097e430 --- /dev/null +++ b/scripts/setup/officer-setup/lib/secrets.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# ============================================================================= +# officer-setup — the secret store +# ============================================================================= +# +# Definitions only. +# +# The store is $OFFICER_ROOT/secrets/officer-keys.db, deliberately a sibling of +# the repo and NOT under data/ — that directory holds the managed homes and +# attachments people back up, and a key store travelling in the same tarball as a +# database dump rebuilds the exact problem it exists to avoid. +# +# Bootstrapping runs the platform's own module rather than reimplementing the +# schema in bash. There is exactly one writer of this file's format, and a second +# one in shell would drift the first time a column is added. + +[[ -n "${OFFICER_SETUP_SECRETS_LOADED:-}" ]] && return 0 +OFFICER_SETUP_SECRETS_LOADED=1 + +secret_store_dir() { echo "${OFFICER_ROOT}/secrets"; } +secret_store_path() { echo "$(secret_store_dir)/officer-keys.db"; } + +# Create the store and the two purposes a core install needs. +# +# Run AS the owner, not as root: the platform runs as them, and a store root +# created would be a store they cannot write. `install -d -o` sets the owner in +# one step rather than mkdir-then-chown, so it is never briefly root's. +bootstrap_secret_store() { + install -d -m 0700 -o "$USERNAME" -g "$(user_group)" "$(secret_store_dir)" || return 1 + + # From the repo, because the module derives the install root as the parent of + # the working directory — the same rule as src/servers/data-path.ts. + sudo -u "$USERNAME" bash -c "cd '$(platform_dir)' && bun --eval \" + const { getKey } = await import('officerdb/secret-store'); + getKey('jwt'); + getKey('headscale'); + \"" >/dev/null 2>&1 || return 1 + + [[ -f "$(secret_store_path)" ]] +} diff --git a/src/databases/officer_db/package.json b/src/databases/officer_db/package.json index 7937f848..03a455a6 100644 --- a/src/databases/officer_db/package.json +++ b/src/databases/officer_db/package.json @@ -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", diff --git a/src/databases/officer_db/src/crypto.ts b/src/databases/officer_db/src/crypto.ts index 71fcfd2b..481afcd8 100644 --- a/src/databases/officer_db/src/crypto.ts +++ b/src/databases/officer_db/src/crypto.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(); + +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'); } diff --git a/src/databases/officer_db/src/queries/headscale.ts b/src/databases/officer_db/src/queries/headscale.ts index 81adbdda..de6a7008 100644 --- a/src/databases/officer_db/src/queries/headscale.ts +++ b/src/databases/officer_db/src/queries/headscale.ts @@ -54,7 +54,7 @@ export async function getActiveHeadscaleCredentials(userId: number): Promise = { 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 diff --git a/src/databases/officer_db/src/queries/invoiceshelf.ts b/src/databases/officer_db/src/queries/invoiceshelf.ts index 6c07d100..40b5e547 100644 --- a/src/databases/officer_db/src/queries/invoiceshelf.ts +++ b/src/databases/officer_db/src/queries/invoiceshelf.ts @@ -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 = { 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; diff --git a/src/databases/officer_db/src/queries/jellyfin.ts b/src/databases/officer_db/src/queries/jellyfin.ts index 00e6650b..2431ddbc 100644 --- a/src/databases/officer_db/src/queries/jellyfin.ts +++ b/src/databases/officer_db/src/queries/jellyfin.ts @@ -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))) diff --git a/src/databases/officer_db/src/queries/photos.ts b/src/databases/officer_db/src/queries/photos.ts index 485edce7..a27f7ed7 100644 --- a/src/databases/officer_db/src/queries/photos.ts +++ b/src/databases/officer_db/src/queries/photos.ts @@ -50,7 +50,7 @@ export async function getActivePhotosCredentials(userId: number): Promise = { 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 diff --git a/src/databases/officer_db/src/queries/service-connections.ts b/src/databases/officer_db/src/queries/service-connections.ts index 6795842a..93b037e2 100644 --- a/src/databases/officer_db/src/queries/service-connections.ts +++ b/src/databases/officer_db/src/queries/service-connections.ts @@ -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 = { 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, diff --git a/src/databases/officer_db/src/queries/vault.ts b/src/databases/officer_db/src/queries/vault.ts index a6d19399..e449dd56 100644 --- a/src/databases/officer_db/src/queries/vault.ts +++ b/src/databases/officer_db/src/queries/vault.ts @@ -19,8 +19,8 @@ export async function getVaultTokens(userId: number): Promise { 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 { /** The owner's stored protector key (decrypted), or null. Officer-app unlock path only. */ export async function getVaultUnlockKey(userId: number): Promise { 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 { - 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) diff --git a/src/databases/officer_db/src/queries/wallet.ts b/src/databases/officer_db/src/queries/wallet.ts index cc8579aa..bf645eea 100644 --- a/src/databases/officer_db/src/queries/wallet.ts +++ b/src/databases/officer_db/src/queries/wallet.ts @@ -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) : null, + config: row.config ? (JSON.parse(decryptSecret('wallet', row.config)) as Record) : null, }; } @@ -131,7 +132,7 @@ export async function getSealedSeed(userId: number, id: number): Promise = { 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 { 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))); } diff --git a/src/databases/officer_db/src/schema/wallet.ts b/src/databases/officer_db/src/schema/wallet.ts index a00134fc..463cf9dc 100644 --- a/src/databases/officer_db/src/schema/wallet.ts +++ b/src/databases/officer_db/src/schema/wallet.ts @@ -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. // diff --git a/src/databases/officer_db/src/secret-store.ts b/src/databases/officer_db/src/secret-store.ts new file mode 100644 index 00000000..63d82e01 --- /dev/null +++ b/src/databases/officer_db/src/secret-store.ts @@ -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//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; +} diff --git a/src/servers/api/browser/relay-auth.ts b/src/servers/api/browser/relay-auth.ts index 52282734..e6d59445 100644 --- a/src/servers/api/browser/relay-auth.ts +++ b/src/servers/api/browser/relay-auth.ts @@ -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'); } diff --git a/src/servers/jwt.ts b/src/servers/jwt.ts index 70b3f8af..a7899b10 100644 --- a/src/servers/jwt.ts +++ b/src/servers/jwt.ts @@ -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 { - return jwtVerify(token, JWT_SECRET!, 'HS256'); + return jwtVerify(token, signingKey(), 'HS256'); } diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts index 734c0b80..7bca5c00 100644 --- a/src/servers/os-user.ts +++ b/src/servers/os-user.ts @@ -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 { + // 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 // 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; } diff --git a/src/servers/sidecar/wallet/index.ts b/src/servers/sidecar/wallet/index.ts index d71deef7..f0ebf90b 100644 --- a/src/servers/sidecar/wallet/index.ts +++ b/src/servers/sidecar/wallet/index.ts @@ -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; diff --git a/src/servers/sidecar/wallet/keys.ts b/src/servers/sidecar/wallet/keys.ts index 5c670f33..17901c06 100644 --- a/src/servers/sidecar/wallet/keys.ts +++ b/src/servers/sidecar/wallet/keys.ts @@ -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 diff --git a/src/servers/sidecar/wallet/upstream.ts b/src/servers/sidecar/wallet/upstream.ts index 59246d64..412bba91 100644 --- a/src/servers/sidecar/wallet/upstream.ts +++ b/src/servers/sidecar/wallet/upstream.ts @@ -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; + } } diff --git a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx index 33853868..92d851bc 100644 --- a/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx +++ b/src/workspaces/officerdev/src/apps/Wallet/dialogs/CreateWalletDialog.tsx @@ -37,7 +37,7 @@ import { // close until the owner confirms they wrote it down. // everything else — a connection to somebody's node or account, so it is a config blob, no seed at all. // -// Wallet creation is refused outright when VAULT_STORE_KEY is unconfigured. The form says so up front +// Wallet creation is refused outright when the wallet's at-rest key is unreachable. The form says so up front // rather than letting the submit fail with a crypto error. type CreateWalletDialogProps = { @@ -253,7 +253,7 @@ export const CreateWalletDialog = ({ open, onOpenChange, section }: CreateWallet
- VAULT_STORE_KEY is not configured. The sidecar refuses to store + The secret store is unreachable. The sidecar refuses to store wallet secrets without it, so creation is disabled until it is set.