mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-31 04:12:10 +03:00
fix(encryption): prevent STORAGE_ENCRYPTION_KEY regeneration on upgrade (#1622)
- 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).
This commit is contained in:
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 <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"));
|
||||
|
||||
@@ -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:<iv>:<ct>:<tag>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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)" : ""}`
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user