From 05a50fcf53e484ed7c3dc71a9f5aa0ebc312af2b Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 26 Apr 2026 12:57:38 -0300 Subject: [PATCH] fix(encryption): prevent STORAGE_ENCRYPTION_KEY regeneration on upgrade (#1622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sync-env.mjs: add hasEncryptedCredentials() guard before generating STORAGE_ENCRYPTION_KEY, matching the existing guard in bootstrap-env.mjs - bootstrap-env.mjs: add decrypt-probe diagnostic on startup to detect key mismatch and log actionable recovery instructions - bin/omniroute.mjs: add 'reset-encrypted-columns' CLI recovery command that nulls encrypted credential columns while preserving provider config - tests/unit/sync-env.test.ts: isolate tests with DATA_DIR override Root cause: postinstall → syncEnv() generated fresh crypto secrets into the package-local .env on every 'npm install -g' upgrade, since the package directory is wiped and recreated. The bootstrap-env guard never triggered because sync-env already filled in the new keys. The DB still contained credentials encrypted under the previous key, making them permanently unrecoverable (AES-GCM auth-tag mismatch → silent 401s). --- CHANGELOG.md | 7 +++ bin/omniroute.mjs | 111 ++++++++++++++++++++++++++++++++-- scripts/bootstrap-env.mjs | 60 ++++++++++++++++++- scripts/sync-env.mjs | 115 +++++++++++++++++++++++++++++++++++- tests/unit/sync-env.test.ts | 11 ++++ 5 files changed, 296 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 257e015076..e227f4fd31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,13 @@ - **fix(codex):** Make `wreq-js` native module import lazy and optional to prevent server crash on startup when the platform-specific binary is missing — affects pnpm installs, Docker Alpine, macOS ARM, and Windows (#1612, #1613, #1616). - **fix(i18n):** Add 14 missing translation keys (`logs.runningRequests`, `logs.model`, `logs.provider`, `logs.account`, `logs.elapsed`, `logs.count`, `logs.payloads`, etc.) for the Active Requests panel across all 30 locales. +- **fix(encryption):** Prevent `STORAGE_ENCRYPTION_KEY` from being silently regenerated during `npm install -g` upgrades, which made all previously-encrypted provider credentials permanently unrecoverable due to AES-GCM auth-tag mismatch. `sync-env.mjs` now checks the SQLite database for existing encrypted credentials before generating a new key, matching the guard already present in `bootstrap-env.mjs` (#1622). +- **fix(startup):** Add decrypt-probe diagnostic at server bootstrap — if `STORAGE_ENCRYPTION_KEY` doesn't match encrypted credentials in the database, a prominent warning is logged directing users to restore the key or use the new recovery command. +- **fix(cli-tools):** Allow `null` API key values in `cliModelConfigSchema` to prevent 400 Bad Request errors when saving cloud-based CLI tool configurations. Fix error handling across all 10 ToolCard components to safely extract messages from structured error objects, preventing React Error #31 crashes. + +### ✨ New Features + +- **feat(cli):** Add `omniroute reset-encrypted-columns` recovery command — nulls encrypted credential columns (`api_key`, `access_token`, `refresh_token`, `id_token`) in `provider_connections` while preserving provider metadata, giving users affected by #1622 a clean recovery path without losing configurations. --- diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs index 2cc86f6554..79513bd63f 100644 --- a/bin/omniroute.mjs +++ b/bin/omniroute.mjs @@ -4,12 +4,13 @@ * OmniRoute CLI — Smart AI Router with Auto Fallback * * Usage: - * omniroute Start the server (default port 20128) - * omniroute --port 3000 Start on custom port - * omniroute --no-open Start without opening browser - * omniroute --mcp Start MCP server (stdio transport for IDEs) - * omniroute --help Show help - * omniroute --version Show version + * omniroute Start the server (default port 20128) + * omniroute --port 3000 Start on custom port + * omniroute --no-open Start without opening browser + * omniroute --mcp Start MCP server (stdio transport for IDEs) + * omniroute reset-encrypted-columns Reset broken encrypted credentials + * omniroute --help Show help + * omniroute --version Show version */ import { spawn } from "node:child_process"; @@ -82,6 +83,7 @@ if (args.includes("--help") || args.includes("-h")) { omniroute --port Use custom API port (default: 20128) omniroute --no-open Don't open browser automatically omniroute --mcp Start MCP server (stdio transport for IDEs) + omniroute reset-encrypted-columns Reset encrypted credentials (recovery) omniroute --help Show this help omniroute --version Show version @@ -117,6 +119,103 @@ if (args.includes("--version") || args.includes("-v")) { process.exit(0); } +// ── reset-encrypted-columns subcommand ────────────────────────────────────── +// Recovery tool for users who lost STORAGE_ENCRYPTION_KEY after upgrade (#1622) +if (args.includes("reset-encrypted-columns")) { + const dataDir = (() => { + const configured = process.env.DATA_DIR?.trim(); + if (configured) return configured; + if (platform() === "win32") { + const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming"); + return join(appData, "omniroute"); + } + const xdg = process.env.XDG_CONFIG_HOME?.trim(); + if (xdg) return join(xdg, "omniroute"); + return join(homedir(), ".omniroute"); + })(); + + const dbPath = join(dataDir, "storage.sqlite"); + + if (!existsSync(dbPath)) { + console.log(`\x1b[33m⚠ No database found at ${dbPath}\x1b[0m`); + process.exit(0); + } + + const force = args.includes("--force"); + if (!force) { + console.log(` + \x1b[1m\x1b[33m⚠ WARNING: This will erase all encrypted credentials\x1b[0m + + This command will NULL out the following columns in provider_connections: + • api_key + • access_token + • refresh_token + • id_token + + Provider metadata (name, provider_id, settings) will be preserved. + You will need to re-authenticate all providers after this operation. + + Database: ${dbPath} + + \x1b[1mTo confirm, run:\x1b[0m + omniroute reset-encrypted-columns --force + `); + process.exit(0); + } + + try { + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + const Database = require("better-sqlite3"); + const db = new Database(dbPath); + + const countResult = db + .prepare( + `SELECT COUNT(*) as cnt FROM provider_connections + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%'` + ) + .get(); + + const affected = countResult?.cnt ?? 0; + + if (affected === 0) { + console.log("\x1b[32m✔ No encrypted credentials found — nothing to reset.\x1b[0m"); + db.close(); + process.exit(0); + } + + const result = db + .prepare( + `UPDATE provider_connections + SET api_key = NULL, + access_token = NULL, + refresh_token = NULL, + id_token = NULL + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%'` + ) + .run(); + + db.close(); + + console.log( + `\x1b[32m✔ Reset ${result.changes} provider connection(s).\x1b[0m\n` + + ` Re-authenticate your providers in the dashboard or re-add API keys.\n` + ); + } catch (err) { + console.error( + `\x1b[31m✖ Failed to reset encrypted columns:\x1b[0m ${err.message || err}` + ); + process.exit(1); + } + process.exit(0); +} + if (args.includes("--mcp")) { try { const { startMcpCli } = await import(join(ROOT, "bin", "mcp-server.mjs")); diff --git a/scripts/bootstrap-env.mjs b/scripts/bootstrap-env.mjs index 4a5da81c4f..91e650aa9e 100644 --- a/scripts/bootstrap-env.mjs +++ b/scripts/bootstrap-env.mjs @@ -18,7 +18,7 @@ * 4. process.env (shell / Docker -e flags, highest priority) */ -import { randomBytes } from "node:crypto"; +import { randomBytes, createDecipheriv } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { createRequire } from "node:module"; import { homedir } from "node:os"; @@ -217,6 +217,64 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) { log("⚠️ INITIAL_PASSWORD is not set — using default 'CHANGEME'. Change it in Settings!"); } + // ── Decrypt-probe: verify STORAGE_ENCRYPTION_KEY matches encrypted data (#1622) ─ + if (merged.STORAGE_ENCRYPTION_KEY?.trim() && hasEncryptedCredentials(dataDir)) { + try { + const Database = require("better-sqlite3"); + const db = new Database(join(dataDir, "storage.sqlite"), { + readonly: true, + fileMustExist: true, + }); + try { + const row = db + .prepare( + `SELECT api_key, access_token, refresh_token, id_token + FROM provider_connections + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%' + LIMIT 1` + ) + .get(); + if (row) { + const ciphertext = row.api_key || row.access_token || row.refresh_token || row.id_token; + if (ciphertext?.startsWith("enc:v1:")) { + const parts = ciphertext.split(":"); + // enc:v1::: + if (parts.length >= 5) { + const iv = Buffer.from(parts[2], "hex"); + const ct = Buffer.from(parts[3], "hex"); + const tag = Buffer.from(parts[4], "hex"); + const key = Buffer.from(merged.STORAGE_ENCRYPTION_KEY, "hex"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(tag); + try { + decipher.update(ct); + decipher.final(); + // Decrypt succeeded — key matches + } catch { + log( + "⛔ STORAGE_ENCRYPTION_KEY does not match the key used to encrypt your stored credentials." + ); + log( + " Either restore your previous key via ~/.omniroute/server.env or ~/.omniroute/.env," + ); + log( + " or run: omniroute reset-encrypted-columns --force (wipes credentials, keeps provider config)" + ); + } + } + } + } + } finally { + db.close(); + } + } catch { + // Non-fatal — probe is best-effort + } + } + return merged; } diff --git a/scripts/sync-env.mjs b/scripts/sync-env.mjs index 10c6f19479..e7da240c62 100644 --- a/scripts/sync-env.mjs +++ b/scripts/sync-env.mjs @@ -14,9 +14,13 @@ import { copyFileSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { randomBytes } from "node:crypto"; -import { dirname, join } from "node:path"; +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { homedir } from "node:os"; import { fileURLToPath } from "node:url"; +const require = createRequire(import.meta.url); + const CRYPTO_SECRETS = { JWT_SECRET: () => randomBytes(64).toString("hex"), API_KEY_SECRET: () => randomBytes(32).toString("hex"), @@ -24,6 +28,64 @@ const CRYPTO_SECRETS = { MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`, }; +/** + * Keys that MUST NOT be regenerated when existing encrypted data exists in the DB. + * Generating a new key would make all previously-encrypted credentials unrecoverable. + * @see https://github.com/diegosouzapw/OmniRoute/issues/1622 + */ +const ENCRYPTION_BOUND_KEYS = new Set(["STORAGE_ENCRYPTION_KEY"]); + +// ── Resolve DATA_DIR (mirrors bootstrap-env.mjs / dataPaths.ts) ───────────── +function resolveDataDir(env = process.env) { + const configured = env.DATA_DIR?.trim(); + if (configured) return resolve(configured); + + if (process.platform === "win32") { + const appData = env.APPDATA || join(homedir(), "AppData", "Roaming"); + return join(appData, "omniroute"); + } + + const xdg = env.XDG_CONFIG_HOME?.trim(); + if (xdg) return join(resolve(xdg), "omniroute"); + + return join(homedir(), ".omniroute"); +} + +/** + * Check whether the SQLite database already contains credentials encrypted + * under a previous STORAGE_ENCRYPTION_KEY. If so, generating a new key would + * make them permanently unrecoverable (AES-GCM auth-tag mismatch). + */ +function hasEncryptedCredentials(dataDir) { + const dbPath = join(dataDir, "storage.sqlite"); + if (!existsSync(dbPath)) return false; + + try { + const Database = require("better-sqlite3"); + const db = new Database(dbPath, { readonly: true, fileMustExist: true }); + try { + const row = db + .prepare( + `SELECT 1 + FROM provider_connections + WHERE access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR api_key LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%' + LIMIT 1` + ) + .get(); + return !!row; + } finally { + db.close(); + } + } catch { + // If we can't open the DB (e.g. missing better-sqlite3 during install), + // err on the side of caution: don't block secret generation. + return false; + } +} + export function parseEnvFile(filePath) { if (!existsSync(filePath)) return new Map(); @@ -111,10 +173,34 @@ export function getEnvSyncPlan({ rootDir, scope = "full" } = {}) { const currentEntries = parseEnvFile(envPath); const missingEntries = []; + // Check once whether encrypted data exists — avoids repeated DB opens + let _encryptedDataExists; + function encryptedDataExists() { + if (_encryptedDataExists === undefined) { + try { + _encryptedDataExists = hasEncryptedCredentials(resolveDataDir()); + } catch { + _encryptedDataExists = false; + } + } + return _encryptedDataExists; + } + for (const [key, defaultValue] of exampleEntries) { if (currentEntries.has(key)) continue; if (CRYPTO_SECRETS[key] && !defaultValue) { + // Guard: never generate a new encryption key if the DB already has + // credentials encrypted under the previous key (#1622) + if (ENCRYPTION_BOUND_KEYS.has(key) && encryptedDataExists()) { + missingEntries.push({ + key, + value: "", + generated: false, + blocked: true, + }); + continue; + } missingEntries.push({ key, value: CRYPTO_SECRETS[key](), generated: true }); continue; } @@ -154,7 +240,26 @@ export function syncEnv({ rootDir, quiet = false, scope = "full" } = {}) { let content = readFileSync(envPath, "utf8"); let generated = 0; + + // Check once whether encrypted data exists — avoids repeated DB opens + let dbHasEncrypted; + try { + dbHasEncrypted = hasEncryptedCredentials(resolveDataDir()); + } catch { + dbHasEncrypted = false; + } + for (const [key, generator] of Object.entries(CRYPTO_SECRETS)) { + // Guard: never generate a new encryption key if the DB already has + // credentials encrypted under the previous key (#1622) + if (ENCRYPTION_BOUND_KEYS.has(key) && dbHasEncrypted) { + log( + `⚠️ ${key} NOT generated — encrypted credentials exist in DB. ` + + `Restore your previous key via ~/.omniroute/server.env, ~/.omniroute/.env, ` + + `or the STORAGE_ENCRYPTION_KEY environment variable.` + ); + continue; + } const nextContent = replaceBlankSecret(content, key, generator()); if (nextContent !== content) { content = nextContent; @@ -194,6 +299,14 @@ export function syncEnv({ rootDir, quiet = false, scope = "full" } = {}) { ]; for (const entry of missingEntries) { + if (entry.blocked) { + log( + `⚠️ ${entry.key} NOT generated — encrypted credentials exist in DB. ` + + `Restore your previous key via ~/.omniroute/server.env, ~/.omniroute/.env, ` + + `or the STORAGE_ENCRYPTION_KEY environment variable.` + ); + continue; + } appendLines.push(`${entry.key}=${entry.value}`); log( `${entry.generated ? "✨" : "📦"} ${entry.key}${entry.generated ? " (auto-generated)" : ""}` diff --git a/tests/unit/sync-env.test.ts b/tests/unit/sync-env.test.ts index aaf0c2afe7..f8569c056a 100644 --- a/tests/unit/sync-env.test.ts +++ b/tests/unit/sync-env.test.ts @@ -49,9 +49,13 @@ function writeOauthEnvExample(rootDir) { test("syncEnv creates .env from .env.example and generates blank secrets", () => { const rootDir = createTempRoot(); + // Temporarily override DATA_DIR so the encrypted-credentials guard doesn't + // find the user's real DB at ~/.omniroute/ during tests + const origDataDir = process.env.DATA_DIR; try { writeEnvExample(rootDir); + process.env.DATA_DIR = rootDir; const result = syncEnv({ rootDir, quiet: true }); const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8"); @@ -64,6 +68,7 @@ test("syncEnv creates .env from .env.example and generates blank secrets", () => assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m); assert.doesNotMatch(envContent, /^COMMENTED_KEY=/m); } finally { + process.env.DATA_DIR = origDataDir; fs.rmSync(rootDir, { recursive: true, force: true }); } }); @@ -71,6 +76,7 @@ test("syncEnv creates .env from .env.example and generates blank secrets", () => test("syncEnv appends only missing keys and preserves existing values", () => { const rootDir = createTempRoot(); + const origDataDir = process.env.DATA_DIR; try { writeEnvExample(rootDir); fs.writeFileSync( @@ -83,6 +89,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => { "utf8" ); + process.env.DATA_DIR = rootDir; const result = syncEnv({ rootDir, quiet: true }); const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8"); @@ -95,6 +102,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => { assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m); assert.match(envContent, /Auto-added by sync-env/); } finally { + process.env.DATA_DIR = origDataDir; fs.rmSync(rootDir, { recursive: true, force: true }); } }); @@ -102,8 +110,10 @@ test("syncEnv appends only missing keys and preserves existing values", () => { test("syncEnv is idempotent when .env is already complete", () => { const rootDir = createTempRoot(); + const origDataDir = process.env.DATA_DIR; try { writeEnvExample(rootDir); + process.env.DATA_DIR = rootDir; syncEnv({ rootDir, quiet: true }); const before = fs.readFileSync(path.join(rootDir, ".env"), "utf8"); @@ -113,6 +123,7 @@ test("syncEnv is idempotent when .env is already complete", () => { assert.deepEqual(result, { created: false, added: 0 }); assert.equal(after, before); } finally { + process.env.DATA_DIR = origDataDir; fs.rmSync(rootDir, { recursive: true, force: true }); } });