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:
+12
-16
@@ -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="<generate with: openssl rand -base64 32>"
|
||||
|
||||
# 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="<generate with: openssl rand -base64 32>"
|
||||
# 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/<pid>/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
|
||||
|
||||
+43
-17
@@ -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/<pid>/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.
|
||||
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 ──
|
||||
#
|
||||
|
||||
@@ -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)" ]]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
<div className="mt-4 flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-xs">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0 text-destructive" />
|
||||
<span>
|
||||
<span className="font-medium">VAULT_STORE_KEY is not configured.</span> The sidecar refuses to store
|
||||
<span className="font-medium">The secret store is unreachable.</span> The sidecar refuses to store
|
||||
wallet secrets without it, so creation is disabled until it is set.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user