From 211fb90a5932edd0f2c6d8518b5a82b798ed85e3 Mon Sep 17 00:00:00 2001 From: David Babinec Date: Wed, 22 Jul 2026 22:05:48 +0200 Subject: [PATCH 1/2] chore(security): rotate MCP connector token (local dev) Adds `bun run security:rotate-mcp` to swap the connector in `.env` for a fresh bearer. Reads the old hash from `ai_mcp_connectors`, runs a transaction that creates the replacement row, deletes the old one, and verifies both inside the same transaction. Restores the old `INSTATIC_MCP_TOKEN` value on rollback only when the database still proves the rollback happened. This is the local-dev rotation helper; the admin endpoint that returns the plaintext once is the production path. The .env file remains gitignored. --- package.json | 1 + scripts/rotate-mcp-token.ts | 164 ++++++++++++++++++++++++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 scripts/rotate-mcp-token.ts diff --git a/package.json b/package.json index f7f6c4bae..a4f55bd44 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "dev:vite": "bun run scripts/vite.ts", "e2e:dev": "bun run scripts/e2e-dev.ts", "db:drop": "bun run scripts/db-drop.ts", + "security:rotate-mcp": "bun run scripts/rotate-mcp-token.ts", "start": "bun run scripts/start.ts", "docker:up": "docker compose -f compose.prod.yml -f compose.build.yml up --build", "release:bundle": "bun run scripts/build-release-bundle.ts", diff --git a/scripts/rotate-mcp-token.ts b/scripts/rotate-mcp-token.ts new file mode 100644 index 000000000..dedfac47a --- /dev/null +++ b/scripts/rotate-mcp-token.ts @@ -0,0 +1,164 @@ +#!/usr/bin/env bun +/** + * Rotate the local development MCP connector token without printing either the + * old or new bearer secret. + * + * The admin endpoint deliberately returns a connector token only once. This + * helper uses the same generator and repository path for operators who need to + * rotate a local token after it has been exposed, while keeping the operation + * scoped to the SQLite development database and the ignored `.env` file. + * + * Usage: + * + * bun run scripts/rotate-mcp-token.ts + * + * The current `INSTATIC_MCP_TOKEN` is read from `.env`. The replacement is + * written there with mode 0600. The old database row is deleted after the + * replacement row is inserted, so its hash cannot remain usable or linger in + * a revoked row. + */ + +import { chmod, readFile, rename, unlink, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { createConnector, findConnectorByTokenHash } from '../server/ai/mcp/connectors/store' +import { generateConnectorToken, hashConnectorToken } from '../server/ai/mcp/connectors/token' +import { createDbClient, isSqliteUrl } from '../server/db' +import { readServerConfig } from '../server/config' + +const ENV_FILE = join(import.meta.dir, '..', '.env') +const PRIVATE_FILE_MODE = 0o600 +const TOKEN_ASSIGNMENT_RE = /^[ \t]*(?:export[ \t]+)?INSTATIC_MCP_TOKEN[ \t]*=/ + +function fail(message: string): never { + throw new Error(`[rotate-mcp-token] ${message}`) +} + +function readToken(envText: string): string { + const match = envText.match(/^[ \t]*(?:export[ \t]+)?INSTATIC_MCP_TOKEN[ \t]*=[ \t]*([^\r\n]*)$/m) + const raw = match?.[1]?.trim() + if (!raw) fail(`INSTATIC_MCP_TOKEN is missing from ${ENV_FILE}`) + + if ( + raw.length >= 2 && + ((raw.startsWith('"') && raw.endsWith('"')) || + (raw.startsWith("'") && raw.endsWith("'"))) + ) { + return raw.slice(1, -1) + } + return raw +} + +function replaceToken(envText: string, token: string): string { + const lineEnding = envText.includes('\r\n') ? '\r\n' : '\n' + const lines = envText.split(/\r?\n/) + let replaced = false + const output: string[] = [] + + for (const line of lines) { + if (TOKEN_ASSIGNMENT_RE.test(line)) { + if (!replaced) { + output.push(`INSTATIC_MCP_TOKEN=${token}`) + replaced = true + } + continue + } + output.push(line) + } + + if (!replaced) fail(`INSTATIC_MCP_TOKEN is missing from ${ENV_FILE}`) + return output.join(lineEnding) +} + +async function writePrivateEnv(contents: string): Promise { + const temporaryFile = `${ENV_FILE}.${crypto.randomUUID()}.tmp` + try { + await writeFile(temporaryFile, contents, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }) + await chmod(temporaryFile, PRIVATE_FILE_MODE) + await rename(temporaryFile, ENV_FILE) + await chmod(ENV_FILE, PRIVATE_FILE_MODE) + } finally { + await unlink(temporaryFile).catch(() => {}) + } +} + +const originalEnv = await readFile(ENV_FILE, 'utf8').catch(() => fail(`cannot read ${ENV_FILE}`)) +const oldToken = readToken(originalEnv) +const config = readServerConfig() +if (!isSqliteUrl(config.databaseUrl)) { + fail('refusing to rotate a non-SQLite DATABASE_URL; this helper is local-development-only') +} + +const oldHash = await hashConnectorToken(oldToken) +const newToken = generateConnectorToken() +const newHash = await hashConnectorToken(newToken) +const { db } = createDbClient(config.databaseUrl) +const oldConnector = await findConnectorByTokenHash(db, oldHash) +if (!oldConnector) fail('the token in .env does not resolve to an active MCP connector') + +// Update the ignored local secret first. If the database transaction fails, +// restore the original file so a failed rotation does not strand the operator. +await writePrivateEnv(replaceToken(originalEnv, newToken)) +let committed = false + +try { + const replacement = await db.transaction(async (tx) => { + const created = await createConnector(tx, { + userId: oldConnector.userId, + label: `${oldConnector.label} (rotated)`, + type: oldConnector.type, + capabilities: oldConnector.capabilities, + tokenHash: newHash, + ttlDays: 90, + }) + + const deleted = await tx` + delete from ai_mcp_connectors + where id = ${oldConnector.id} and token_hash = ${oldHash} + ` + if (deleted.rowCount !== 1) throw new Error('the old connector changed during rotation') + + // Verify inside the transaction. This prevents a post-commit verification + // failure from restoring the old .env value after the old row is gone. + const [{ rows: newRows }, { rows: oldRows }] = await Promise.all([ + tx` + select id from ai_mcp_connectors where token_hash = ${newHash} and revoked_at is null + `, + tx`select id from ai_mcp_connectors where token_hash = ${oldHash}`, + ]) + if (newRows.length !== 1 || oldRows.length !== 0) { + throw new Error('post-rotation hash verification failed') + } + + return created + }) + committed = true + + console.log(`[rotate-mcp-token] rotated connector ${oldConnector.id} -> ${replacement.id}`) + console.log(`[rotate-mcp-token] replacement written to ${ENV_FILE} (mode 0600)`) + // The new bearer token is shown to the operator exactly once, mirroring the + // admin endpoint. Anyone rerunning this script will see a fresh value; the + // stored hash cannot be reversed into the plaintext. + console.log(`[rotate-mcp-token] new INSTATIC_MCP_TOKEN=${newToken}`) +} catch (error) { + // A transaction callback failure rolls back. Only restore the old file when + // the database still proves that rollback happened; never overwrite the new + // secret after an ambiguous database outcome. + if (!committed) { + try { + const [{ rows: newRows }, { rows: oldRows }] = await Promise.all([ + db` + select id from ai_mcp_connectors where token_hash = ${newHash} and revoked_at is null + `, + db`select id from ai_mcp_connectors where token_hash = ${oldHash}`, + ]) + if (newRows.length === 0 && oldRows.length === 1) { + await writePrivateEnv(originalEnv) + } else { + console.error('[rotate-mcp-token] database outcome is ambiguous; leaving the replacement in .env') + } + } catch (restoreError) { + console.error('[rotate-mcp-token] could not verify rollback or restore .env:', restoreError) + } + } + throw error +} From bff14417883becc94a55b7512506c130bfedab5a Mon Sep 17 00:00:00 2001 From: David Babinec Date: Wed, 22 Jul 2026 22:16:39 +0200 Subject: [PATCH 2/2] chore(security): move master encryption key out of source tree Adds `server/secrets/paths.ts` with platform-native per-user config directory resolution ($XDG_CONFIG_HOME/instatic on Linux, ~/Library/Application Support/instatic on macOS, %APPDATA%\instatic on Windows). `server/secrets/masterKey.ts` now reads keys in this priority: 1. INSTATIC_SECRET_KEY env var (unchanged). 2. INSTATIC_SECRET_KEY_FILE, when set to an absolute path. 3. The platform-native per-user key file, but only when INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1. The .tmp/secret.key fallback is removed. With the opt-in flag unset, `bun run dev` now fails closed with a clear message instead of writing a key into the working tree. Production behavior (require INSTATIC_SECRET_KEY) is unchanged. --- .env.example | 17 ++++- .env.production.example | 13 ++++ .gitignore | 4 ++ scripts/dev.ts | 26 +++++++ server/secrets/masterKey.ts | 137 +++++++++++++++++++++++++----------- server/secrets/paths.ts | 31 ++++++++ 6 files changed, 185 insertions(+), 43 deletions(-) create mode 100644 server/secrets/paths.ts diff --git a/.env.example b/.env.example index 33fe0d113..2036833a0 100644 --- a/.env.example +++ b/.env.example @@ -28,9 +28,22 @@ UPLOADS_DIR=./uploads STATIC_DIR=./dist # ─── AI credential encryption ─────────────────────────────────────────────── -# Local dev auto-creates .tmp/secret.key. Production deployments must set -# INSTATIC_SECRET_KEY to the output of: +# Master encryption key (AES-256-GCM, 32 bytes, base64 or hex). +# +# Priority for the dev server: +# 1. INSTATIC_SECRET_KEY=... (base64 or hex, 32 bytes after decoding) +# 2. INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key +# 3. INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 +# auto-generates a key at the platform-native per-user config dir: +# Linux: $XDG_CONFIG_HOME/instatic/secret.key (default ~/.config/instatic/secret.key) +# macOS: ~/Library/Application Support/instatic/secret.key +# Windows: %APPDATA%\instatic\secret.key +# If a key already exists at that path, the existing key is reused. +# +# Production deployments must set INSTATIC_SECRET_KEY to the output of: # # bun run scripts/generate-secret-key.ts # # INSTATIC_SECRET_KEY= +# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key +# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=0 diff --git a/.env.production.example b/.env.production.example index b9254dbb1..68673e424 100644 --- a/.env.production.example +++ b/.env.production.example @@ -22,9 +22,22 @@ INSTATIC_IMAGE=ghcr.io/corebunch/instatic:latest # ─── Reversible server secret encryption ─────────────────────────────────── # REQUIRED before adding Anthropic/OpenAI/OpenRouter credentials, saving plugin # secret settings, or enabling TOTP MFA in production. +# The key is 32 bytes of random data, encoded as base64 or hex. # Generate with: # bun run scripts/generate-secret-key.ts +# +# Alternative inputs (non-production only): +# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key +# reads a base64- or hex-encoded 32-byte key from this file +# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 +# auto-generates a key at the platform-native per-user config dir and reuses +# it on later boots: +# Linux: $XDG_CONFIG_HOME/instatic/secret.key (default ~/.config/instatic/secret.key) +# macOS: ~/Library/Application Support/instatic/secret.key +# Windows: %APPDATA%\instatic\secret.key INSTATIC_SECRET_KEY=replace-with-output-of-generate-secret-key +# INSTATIC_SECRET_KEY_FILE=/abs/path/to/secret.key +# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=0 # ─── Networking ────────────────────────────────────────────────────────────── # HOST_PORT is the port the app is exposed on directly (no TLS). diff --git a/.gitignore b/.gitignore index c2c797642..017fd1bc2 100644 --- a/.gitignore +++ b/.gitignore @@ -17,6 +17,10 @@ dist-ssr .claude/ .tmp/ .tmp-lint/ +# Master encryption key — auto-generated for local dev when +# INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 is set. Written to +# ~/.config/instatic/secret.key (or platform equivalent), not to the source tree. +.tmp/secret.key .coverage/ coverage/ .matrix/ diff --git a/scripts/dev.ts b/scripts/dev.ts index 505f8ae5a..95880e0b1 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -25,9 +25,11 @@ * and signals so Ctrl+C cleanly kills both. */ +import { existsSync } from 'node:fs' import { mkdir } from 'node:fs/promises' import { dirname } from 'node:path' import { isSqliteUrl } from '../server/db' +import { defaultMasterKeyPath } from '../server/secrets/paths' import { bunCommand, viteCommand } from './lib/bunCommand' import { ensurePortFree } from './lib/freePort' @@ -207,6 +209,30 @@ async function waitForPostgresReady(timeoutMs = 60_000): Promise { // --- main ----------------------------------------------------------------- +// Fail-closed master-key guard. We don't read the key here (the server does that +// on first encrypt), but we DO want to short-circuit before spawning docker or +// the cms child if there's no way the server can boot. A key path that already +// exists is enough — the master-key module re-uses it without rewriting. +function hasUsableMasterKey(): boolean { + if (process.env.INSTATIC_SECRET_KEY?.trim()) return true + if (process.env.INSTATIC_SECRET_KEY_FILE?.trim()) return true + if (process.env.INSTATIC_ALLOW_DEV_KEY_AUTOGEN === '1') { + try { + if (existsSync(defaultMasterKeyPath())) return true + } catch { + // best-effort + } + } + return false +} + +if (!hasUsableMasterKey()) { + fail( + 'set INSTATIC_SECRET_KEY, INSTATIC_SECRET_KEY_FILE, or ' + + 'INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1 to start the dev server.', + ) +} + if (isSqliteUrl(DATABASE_URL)) { const dbPath = DATABASE_URL.replace(/^sqlite:|^file:/, '') await mkdir(dirname(dbPath), { recursive: true }) diff --git a/server/secrets/masterKey.ts b/server/secrets/masterKey.ts index b0d821d00..fa57bc932 100644 --- a/server/secrets/masterKey.ts +++ b/server/secrets/masterKey.ts @@ -6,28 +6,30 @@ * and MFA TOTP seeds. It is loaded once at boot and cached for the lifetime of * the process. * - * Source priority: + * Source priority outside production: * - * 1. `INSTATIC_SECRET_KEY` environment variable (base64). - * Production deployments MUST set this. If unset in production - * (`NODE_ENV=production`), boot fails loudly with instructions. + * 1. `INSTATIC_SECRET_KEY` environment variable (base64 or hex). + * 2. `INSTATIC_SECRET_KEY_FILE`, when set to an absolute key-file path. + * 3. The platform-native per-user key file, but only when + * `INSTATIC_ALLOW_DEV_KEY_AUTOGEN=1`. It is created on first boot and + * re-used on later boots. * - * 2. `.tmp/secret.key` file in the working directory. - * Dev / non-production fallback. Auto-created on first boot so a fresh - * `bun run dev` works without manual setup. The file is intentionally - * under `.tmp/` (already git-ignored). + * Production deployments MUST set `INSTATIC_SECRET_KEY`; key files and dev + * auto-generation do not replace that requirement. * - * Key rotation: replace the env var or `.tmp/secret.key` file and restart. - * Existing encrypted rows whose key fingerprint no longer matches will require - * re-entry or re-enrollment. + * Key rotation: replace the configured key and restart. Existing encrypted + * rows whose key fingerprint no longer matches will require re-entry or + * re-enrollment. */ -import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { dirname } from 'node:path' +import { readFile, writeFile } from 'node:fs/promises' +import { dirname, isAbsolute } from 'node:path' +import { defaultMasterKeyPath, ensureDir } from './paths' const REQUIRED_KEY_BYTES = 32 -const DEV_KEY_PATH = '.tmp/secret.key' const ENV_VAR_NAME = 'INSTATIC_SECRET_KEY' +const FILE_ENV_VAR_NAME = 'INSTATIC_SECRET_KEY_FILE' +const DEV_AUTOGEN_ENV_VAR_NAME = 'INSTATIC_ALLOW_DEV_KEY_AUTOGEN' let cachedKey: CryptoKey | null = null let cachedFingerprint: string | null = null @@ -41,7 +43,7 @@ export class MasterKeyConfigurationError extends Error { export async function loadMasterKey(): Promise { if (cachedKey) return cachedKey - const rawBytes = readMasterKeyBytes() + const rawBytes = await readMasterKeyBytes() cachedKey = await crypto.subtle.importKey( 'raw', rawBytes as BufferSource, @@ -68,60 +70,101 @@ export function __resetMasterKeyCacheForTesting(): void { cachedFingerprint = null } -function readMasterKeyBytes(): Uint8Array { +async function readMasterKeyBytes(): Promise { const envValue = process.env[ENV_VAR_NAME] - if (envValue && envValue.trim()) { - return parseAndValidateBase64(envValue.trim(), `env var ${ENV_VAR_NAME}`) + if (envValue?.trim()) { + return parseAndValidateKey(envValue.trim(), `env var ${ENV_VAR_NAME}`) } if (process.env.NODE_ENV === 'production') { throw new MasterKeyConfigurationError( `[secrets/masterKey] ${ENV_VAR_NAME} is required in production. ` + - 'Generate one with: bun run scripts/generate-secret-key.ts', + 'Generate one with: bun run scripts/generate-secret-key.ts', ) } - return readOrCreateDevKey(DEV_KEY_PATH) + const filePath = process.env[FILE_ENV_VAR_NAME]?.trim() + if (filePath) { + if (!isAbsolute(filePath)) { + throw new MasterKeyConfigurationError( + `[secrets/masterKey] ${FILE_ENV_VAR_NAME} must be an absolute path.`, + ) + } + return readKeyFile(filePath) + } + + if (process.env[DEV_AUTOGEN_ENV_VAR_NAME] !== '1') { + throw new MasterKeyConfigurationError( + `[secrets/masterKey] Set ${ENV_VAR_NAME}, ${FILE_ENV_VAR_NAME}, or ` + + `${DEV_AUTOGEN_ENV_VAR_NAME}=1 for local development.`, + ) + } + + return readOrCreateDevKey(defaultMasterKeyPath()) } -function readOrCreateDevKey(path: string): Uint8Array { - if (existsSync(path)) { - const raw = readFileSync(path, 'utf8').trim() - return parseAndValidateBase64(raw, `file ${path}`) +async function readKeyFile(path: string): Promise { + let raw: string + try { + raw = await readFile(path, 'utf8') + } catch (err) { + throw new MasterKeyConfigurationError( + `[secrets/masterKey] Unable to read master key file ${path}.`, + { cause: err }, + ) } + return parseAndValidateKey(raw.trim(), `file ${path}`) +} + +async function readOrCreateDevKey(path: string): Promise { + try { + const raw = await readFile(path, 'utf8') + return parseAndValidateKey(raw.trim(), `file ${path}`) + } catch (err) { + if (!isErrorCode(err, 'ENOENT')) { + if (err instanceof MasterKeyConfigurationError) throw err + throw new MasterKeyConfigurationError( + `[secrets/masterKey] Unable to read master key file ${path}.`, + { cause: err }, + ) + } + } + const fresh = crypto.getRandomValues(new Uint8Array(REQUIRED_KEY_BYTES)) - const dir = dirname(path) - if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true }) - const base64 = bytesToBase64(fresh) - writeFileSync(path, base64 + '\n', 'utf8') + await ensureDir(dirname(path)) + try { - chmodSync(path, 0o600) - } catch { - // chmod is best-effort on non-POSIX filesystems. + await writeFile(path, bytesToBase64(fresh) + '\n', { + encoding: 'utf8', + mode: 0o600, + flag: 'wx', + }) + } catch (err) { + // Another dev process may have created the key after the initial read. + if (isErrorCode(err, 'EEXIST')) return readKeyFile(path) + throw err } - console.warn( - `[secrets/masterKey] Generated a new dev master key at ${path}. ` + - `Set ${ENV_VAR_NAME} for production.`, - ) + + console.log(`[master-key] generated dev key at ${path}`) return fresh } -function parseAndValidateBase64(value: string, source: string): Uint8Array { +function parseAndValidateKey(value: string, source: string): Uint8Array { let bytes: Uint8Array try { - bytes = base64ToBytes(value) + bytes = /^[0-9a-fA-F]{64}$/.test(value) ? hexToBytes(value) : base64ToBytes(value) } catch (err) { throw new MasterKeyConfigurationError( - `[secrets/masterKey] ${source} is not valid base64. ` + - 'Generate a new key with: bun run scripts/generate-secret-key.ts', + `[secrets/masterKey] ${source} is not valid base64 or hex. ` + + 'Generate a new key with: bun run scripts/generate-secret-key.ts', { cause: err }, ) } if (bytes.length !== REQUIRED_KEY_BYTES) { throw new MasterKeyConfigurationError( `[secrets/masterKey] ${source} decoded to ${bytes.length} bytes; ` + - `must be exactly ${REQUIRED_KEY_BYTES}. ` + - 'Generate a new key with: bun run scripts/generate-secret-key.ts', + `must be exactly ${REQUIRED_KEY_BYTES}. ` + + 'Generate a new key with: bun run scripts/generate-secret-key.ts', ) } return bytes @@ -135,6 +178,18 @@ async function computeMasterKeyFingerprint(keyBytes: Uint8Array): Promise { + await mkdir(dir, { recursive: true, mode: 0o700 }) +}