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:
diegosouzapw
2026-04-26 12:57:38 -03:00
parent 744a5606e4
commit 05a50fcf53
5 changed files with 296 additions and 8 deletions

View File

@@ -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.
---

View File

@@ -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"));

View File

@@ -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;
}

View File

@@ -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)" : ""}`

View File

@@ -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 });
}
});