mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Merge pull request #369 from kfiramar/fix-startup-env-key-loading
Thanks @kfiramar! 🎉 Critical security fix — different startup paths were generating different `STORAGE_ENCRYPTION_KEY` values over the same SQLite database, causing `Unsupported state or unable to authenticate data` for all stored tokens. Improvements added on top: - Normalized `overridePath?.trim()` in `electron/main.js` to match `bootstrap-env.mjs` (addresses kilo-code-bot warning #1) - Added explanatory comment documenting the `preferredEnv` merge order intent in Electron startup (addresses kilo-code-bot warning #3) 4 commits + 113-line test file. The fail-closed behaviour (refusing to mint a new key when encrypted rows exist) is an excellent safeguard. Merged!
This commit is contained in:
@@ -64,6 +64,64 @@ let serverPort = 20128;
|
||||
|
||||
const getServerUrl = () => `http://localhost:${serverPort}`;
|
||||
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath && overridePath.trim()) return path.resolve(overridePath);
|
||||
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return path.resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = env.APPDATA || path.join(require("os").homedir(), "AppData", "Roaming");
|
||||
return path.join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return path.join(path.resolve(xdg), "omniroute");
|
||||
|
||||
return path.join(require("os").homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(path.join(path.resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(path.join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(path.join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => fs.existsSync(filePath)) || null;
|
||||
}
|
||||
|
||||
function hasEncryptedCredentials(dbPath) {
|
||||
if (!fs.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 (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auto-Updater Configuration ──────────────────────────────
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = true;
|
||||
@@ -386,12 +444,10 @@ function startNextServer() {
|
||||
// ── Zero-config bootstrap: auto-generate required secrets ─────────────────
|
||||
// Electron uses CJS — cannot dynamically import ESM bootstrap-env.mjs.
|
||||
// This mirrors bootstrap-env.mjs logic synchronously:
|
||||
// 1. Read persisted secrets from userData/server.env
|
||||
// 1. Read persisted secrets from the resolved DATA_DIR/server.env
|
||||
// 2. Generate missing secrets with crypto.randomBytes()
|
||||
// 3. Persist back to userData/server.env for future restarts
|
||||
// 3. Persist back to DATA_DIR/server.env for future restarts
|
||||
const crypto = require("crypto");
|
||||
const userDataDir = app.getPath("userData");
|
||||
const serverEnvPath = path.join(userDataDir, "server.env");
|
||||
|
||||
// Parse a simple KEY=VALUE file
|
||||
function parseEnvFile(filePath) {
|
||||
@@ -407,8 +463,12 @@ function startNextServer() {
|
||||
return env;
|
||||
}
|
||||
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(null, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = path.join(dataDir, "server.env");
|
||||
const persisted = parseEnvFile(serverEnvPath);
|
||||
const serverEnv = { ...process.env, ...persisted };
|
||||
const serverEnv = { ...persisted, ...preferredEnv, ...process.env };
|
||||
let changed = false;
|
||||
|
||||
if (!serverEnv.JWT_SECRET) {
|
||||
@@ -417,6 +477,16 @@ function startNextServer() {
|
||||
console.log("[Electron] ✨ JWT_SECRET auto-generated");
|
||||
}
|
||||
if (!serverEnv.STORAGE_ENCRYPTION_KEY) {
|
||||
if (hasEncryptedCredentials(path.join(dataDir, "storage.sqlite"))) {
|
||||
console.error(
|
||||
`[Electron] Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${path.join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath || "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
sendToRenderer("server-status", { status: "error", port: serverPort });
|
||||
return;
|
||||
}
|
||||
serverEnv.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY = crypto
|
||||
.randomBytes(32)
|
||||
.toString("hex");
|
||||
@@ -432,7 +502,7 @@ function startNextServer() {
|
||||
if (changed) {
|
||||
serverEnv.OMNIROUTE_BOOTSTRAPPED = "true";
|
||||
try {
|
||||
fs.mkdirSync(userDataDir, { recursive: true });
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const lines = [
|
||||
"# Auto-generated by OmniRoute bootstrap",
|
||||
"",
|
||||
@@ -454,6 +524,7 @@ function startNextServer() {
|
||||
cwd: NEXT_SERVER_PATH,
|
||||
env: {
|
||||
...serverEnv,
|
||||
DATA_DIR: dataDir,
|
||||
PORT: String(serverPort),
|
||||
NODE_ENV: "production",
|
||||
},
|
||||
|
||||
@@ -7,22 +7,25 @@
|
||||
* restarts, Docker volume remounts, and upgrades.
|
||||
*
|
||||
* Works across all deployment modes:
|
||||
* - npm / CLI: called from run-standalone.mjs and run-next.mjs
|
||||
* - npm / app runners: called from run-standalone.mjs and run-next.mjs
|
||||
* - Docker: same, secrets persisted in mounted volume
|
||||
* - Electron: called from main.js startup, persisted in userData
|
||||
* - Electron: called from main.js startup, persisted in DATA_DIR
|
||||
*
|
||||
* Priority (lowest → highest):
|
||||
* 1. Auto-generated defaults
|
||||
* 2. {DATA_DIR}/server.env (persisted on first boot)
|
||||
* 3. .env in CWD (user overrides)
|
||||
* 3. Preferred config .env (DATA_DIR/.env -> ~/.omniroute/.env -> ./.env)
|
||||
* 4. process.env (shell / Docker -e flags, highest priority)
|
||||
*/
|
||||
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { homedir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
// ── OAuth secrets that are optional but warn if missing ─────────────────────
|
||||
const OPTIONAL_OAUTH_SECRETS = [
|
||||
{ key: "ANTIGRAVITY_OAUTH_CLIENT_SECRET", label: "Antigravity OAuth" },
|
||||
@@ -31,23 +34,65 @@ const OPTIONAL_OAUTH_SECRETS = [
|
||||
];
|
||||
|
||||
// ── Resolve DATA_DIR (mirrors dataPaths.ts logic) ───────────────────────────
|
||||
function resolveDataDir(overridePath) {
|
||||
if (overridePath) return resolve(overridePath);
|
||||
function resolveDataDir(overridePath, env = process.env) {
|
||||
if (overridePath?.trim()) return resolve(overridePath);
|
||||
|
||||
const configured = process.env.DATA_DIR?.trim();
|
||||
const configured = env.DATA_DIR?.trim();
|
||||
if (configured) return resolve(configured);
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
const appData = env.APPDATA || join(homedir(), "AppData", "Roaming");
|
||||
return join(appData, "omniroute");
|
||||
}
|
||||
|
||||
const xdg = process.env.XDG_CONFIG_HOME?.trim();
|
||||
const xdg = env.XDG_CONFIG_HOME?.trim();
|
||||
if (xdg) return join(resolve(xdg), "omniroute");
|
||||
|
||||
return join(homedir(), ".omniroute");
|
||||
}
|
||||
|
||||
function getPreferredEnvFilePath(env = process.env) {
|
||||
const candidates = [];
|
||||
|
||||
if (env.DATA_DIR?.trim()) {
|
||||
candidates.push(join(resolve(env.DATA_DIR.trim()), ".env"));
|
||||
}
|
||||
|
||||
candidates.push(join(resolveDataDir(null, env), ".env"));
|
||||
candidates.push(join(process.cwd(), ".env"));
|
||||
|
||||
return candidates.find((filePath) => existsSync(filePath)) ?? null;
|
||||
}
|
||||
|
||||
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 (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(`Unable to inspect existing database at ${dbPath}: ${message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Parse a simple KEY=VALUE env file ───────────────────────────────────────
|
||||
function parseEnvFile(filePath) {
|
||||
if (!existsSync(filePath)) return {};
|
||||
@@ -85,18 +130,17 @@ function writeEnvFile(filePath, env) {
|
||||
export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
const log = quiet ? () => {} : (msg) => process.stderr.write(`[bootstrap] ${msg}\n`);
|
||||
|
||||
const dataDir = resolveDataDir(dataDirOverride);
|
||||
const preferredEnvPath = getPreferredEnvFilePath(process.env);
|
||||
const preferredEnv = preferredEnvPath ? parseEnvFile(preferredEnvPath) : {};
|
||||
const dataDir = resolveDataDir(dataDirOverride, { ...preferredEnv, ...process.env });
|
||||
const serverEnvPath = join(dataDir, "server.env");
|
||||
const dotEnvPath = join(process.cwd(), ".env");
|
||||
|
||||
// ── Layer 1: Load persisted server.env ────────────────────────────────────
|
||||
let persisted = parseEnvFile(serverEnvPath);
|
||||
|
||||
// ── Layer 2: Load .env from CWD (user overrides, higher priority) ─────────
|
||||
const dotEnv = parseEnvFile(dotEnvPath);
|
||||
|
||||
// ── Merge: persisted < .env < process.env ─────────────────────────────────
|
||||
const merged = { ...persisted, ...dotEnv, ...process.env };
|
||||
// ── Layer 2: Load the same preferred .env that the CLI wrapper uses ───────
|
||||
// This keeps run-next / run-standalone consistent with `bin/omniroute.mjs`.
|
||||
const merged = { ...persisted, ...preferredEnv, ...process.env };
|
||||
|
||||
// ── Auto-generate required secrets ────────────────────────────────────────
|
||||
let needsPersist = false;
|
||||
@@ -109,6 +153,14 @@ export function bootstrapEnv({ dataDirOverride, quiet = false } = {}) {
|
||||
}
|
||||
|
||||
if (!merged.STORAGE_ENCRYPTION_KEY?.trim()) {
|
||||
if (hasEncryptedCredentials(dataDir)) {
|
||||
throw new Error(
|
||||
`Refusing to auto-generate STORAGE_ENCRYPTION_KEY: encrypted credentials already exist in ${join(
|
||||
dataDir,
|
||||
"storage.sqlite"
|
||||
)}. Restore the key via ${preferredEnvPath ?? "an appropriate .env file"}, ${serverEnvPath}, or process.env.`
|
||||
);
|
||||
}
|
||||
persisted.STORAGE_ENCRYPTION_KEY = randomBytes(32).toString("hex");
|
||||
merged.STORAGE_ENCRYPTION_KEY = persisted.STORAGE_ENCRYPTION_KEY;
|
||||
needsPersist = true;
|
||||
|
||||
113
tests/unit/bootstrap-env.test.mjs
Normal file
113
tests/unit/bootstrap-env.test.mjs
Normal file
@@ -0,0 +1,113 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
import { bootstrapEnv } from "../../scripts/bootstrap-env.mjs";
|
||||
|
||||
function withTempEnv(fn) {
|
||||
const originalCwd = process.cwd();
|
||||
const originalEnv = { ...process.env };
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-bootstrap-test-"));
|
||||
const tempCwd = path.join(tempRoot, "cwd");
|
||||
const tempHome = path.join(tempRoot, "home");
|
||||
|
||||
fs.mkdirSync(tempCwd, { recursive: true });
|
||||
fs.mkdirSync(tempHome, { recursive: true });
|
||||
|
||||
delete process.env.DATA_DIR;
|
||||
delete process.env.XDG_CONFIG_HOME;
|
||||
delete process.env.APPDATA;
|
||||
delete process.env.JWT_SECRET;
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY_VERSION;
|
||||
delete process.env.API_KEY_SECRET;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
process.env.HOME = tempHome;
|
||||
process.chdir(tempCwd);
|
||||
|
||||
try {
|
||||
fn({ tempRoot, tempCwd, tempHome, dataDir: path.join(tempHome, ".omniroute") });
|
||||
} finally {
|
||||
process.chdir(originalCwd);
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) delete process.env[key];
|
||||
}
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
fs.rmSync(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("bootstrapEnv prefers ~/.omniroute/.env over server.env", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, ".env"),
|
||||
"STORAGE_ENCRYPTION_KEY=from-dot-env\nJWT_SECRET=jwt-from-dot-env\n",
|
||||
"utf8"
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dataDir, "server.env"),
|
||||
"STORAGE_ENCRYPTION_KEY=from-server-env\nJWT_SECRET=jwt-from-server-env\n",
|
||||
"utf8"
|
||||
);
|
||||
|
||||
const env = bootstrapEnv({ quiet: true });
|
||||
|
||||
assert.equal(env.STORAGE_ENCRYPTION_KEY, "from-dot-env");
|
||||
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv refuses to generate a new key over encrypted data", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
const db = new Database(path.join(dataDir, "storage.sqlite"));
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE provider_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
api_key TEXT,
|
||||
id_token TEXT
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO provider_connections (id, access_token) VALUES (?, ?)")
|
||||
.run("conn-1", "enc:v1:deadbeef:feedface:cafebabe");
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() => bootstrapEnv({ quiet: true }),
|
||||
/Refusing to auto-generate STORAGE_ENCRYPTION_KEY/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv fails closed when existing database cannot be inspected", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(path.join(dataDir, "storage.sqlite"), { recursive: true });
|
||||
|
||||
assert.throws(
|
||||
() => bootstrapEnv({ quiet: true }),
|
||||
/Unable to inspect existing database/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("bootstrapEnv ignores blank dataDirOverride values", () => {
|
||||
withTempEnv(({ dataDir }) => {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dataDir, ".env"), "JWT_SECRET=jwt-from-dot-env\n", "utf8");
|
||||
|
||||
const env = bootstrapEnv({ dataDirOverride: " ", quiet: true });
|
||||
|
||||
assert.equal(env.JWT_SECRET, "jwt-from-dot-env");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user