From cf284cab60ddde4df387f877399b4cec10999d49 Mon Sep 17 00:00:00 2001 From: imran Date: Wed, 22 Jul 2026 16:04:28 +0700 Subject: [PATCH] fix(db): encrypt provider connection secrets at rest (AES-256-GCM) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Provider access/refresh/id tokens and API keys were stored as plain JSON in the providerConnections.data SQLite column — readable by anyone with filesystem access to the data directory. Adds src/lib/db/helpers/secretCol.js (AES-256-GCM, machine-derived key by default, optional DB_ENCRYPTION_KEY env override for portability) and wires it into every read/write path touching that column: connectionsRepo.js, index.js (exportDb/importDb), migrate.js (legacy db.json import). Existing plaintext rows keep reading correctly via an "enc1:" prefix marker and are transparently re-encrypted on next write — no migration step needed. Co-Authored-By: Claude Sonnet 5 --- .env.example | 4 +++ src/lib/db/helpers/secretCol.js | 55 +++++++++++++++++++++++++++++ src/lib/db/index.js | 5 +-- src/lib/db/migrate.js | 3 +- src/lib/db/repos/connectionsRepo.js | 6 ++-- tests/unit/secretCol.test.js | 25 +++++++++++++ 6 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 src/lib/db/helpers/secretCol.js create mode 100644 tests/unit/secretCol.test.js diff --git a/.env.example b/.env.example index 6ed8f81e868..2ce3b24da71 100644 --- a/.env.example +++ b/.env.example @@ -17,6 +17,10 @@ ENABLE_REQUEST_LOGS=false OBSERVABILITY_ENABLED=true AUTH_COOKIE_SECURE=false REQUIRE_API_KEY=false +# Optional: overrides the machine-derived key used to encrypt provider connection +# secrets (access/refresh tokens, API keys) at rest in the local SQLite DB. +# Set this if you need the DB to remain readable after moving DATA_DIR to a new machine. +# DB_ENCRYPTION_KEY=change-me-to-a-long-random-secret # Cloud sync variables # Must point to this running instance so internal sync jobs can call /api/sync/cloud. diff --git a/src/lib/db/helpers/secretCol.js b/src/lib/db/helpers/secretCol.js new file mode 100644 index 00000000000..3df26738465 --- /dev/null +++ b/src/lib/db/helpers/secretCol.js @@ -0,0 +1,55 @@ +import crypto from "crypto"; + +const ENCRYPT_ALGO = "aes-256-gcm"; +const ENCRYPT_SALT = "9router-conn-secret"; +const ENC_PREFIX = "enc1:"; + +function deriveKey() { + if (process.env.DB_ENCRYPTION_KEY) { + return crypto.createHash("sha256").update(process.env.DB_ENCRYPTION_KEY).digest(); + } + try { + const { machineIdSync } = require("node-machine-id"); + const raw = machineIdSync(); + return crypto.createHash("sha256").update(raw + ENCRYPT_SALT).digest(); + } catch { + return crypto.createHash("sha256").update(ENCRYPT_SALT).digest(); + } +} + +function encrypt(plaintext) { + const key = deriveKey(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv(ENCRYPT_ALGO, key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${ENC_PREFIX}${iv.toString("hex")}:${tag.toString("hex")}:${encrypted.toString("hex")}`; +} + +function decrypt(stored) { + const [ivHex, tagHex, dataHex] = stored.slice(ENC_PREFIX.length).split(":"); + if (!ivHex || !tagHex || !dataHex) throw new Error("malformed secret ciphertext"); + const key = deriveKey(); + const decipher = crypto.createDecipheriv(ENCRYPT_ALGO, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(tagHex, "hex")); + return decipher.update(Buffer.from(dataHex, "hex")) + decipher.final("utf8"); +} + +// Encrypts a JSON-serializable value for storage in a `data` column. +export function encryptSecretJson(value) { + return encrypt(JSON.stringify(value ?? null)); +} + +// Decrypts a value stored by encryptSecretJson. Transparently reads legacy +// plaintext JSON (no "enc1:" prefix) written before this encryption was added. +export function decryptSecretJson(stored, fallback = null) { + if (stored == null) return fallback; + try { + if (typeof stored === "string" && stored.startsWith(ENC_PREFIX)) { + return JSON.parse(decrypt(stored)); + } + return JSON.parse(stored); + } catch { + return fallback; + } +} diff --git a/src/lib/db/index.js b/src/lib/db/index.js index e1c68472de4..f9a7b6ad7a1 100644 --- a/src/lib/db/index.js +++ b/src/lib/db/index.js @@ -1,6 +1,7 @@ // Public API barrel — all DB functions import { getAdapter } from "./driver.js"; import { stringifyJson, parseJson } from "./helpers/jsonCol.js"; +import { decryptSecretJson, encryptSecretJson } from "./helpers/secretCol.js"; // Settings export { @@ -74,7 +75,7 @@ export async function exportDb() { const out = { settings: await exportSettings(), - providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })), + providerConnections: db.all(`SELECT * FROM providerConnections`).map((r) => ({ ...decryptSecretJson(r.data, {}), id: r.id, provider: r.provider, authType: r.authType, name: r.name, email: r.email, priority: r.priority, isActive: r.isActive === 1, createdAt: r.createdAt, updatedAt: r.updatedAt })), providerNodes: db.all(`SELECT * FROM providerNodes`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, type: r.type, name: r.name, createdAt: r.createdAt, updatedAt: r.updatedAt })), proxyPools: db.all(`SELECT * FROM proxyPools`).map((r) => ({ ...parseJson(r.data, {}), id: r.id, isActive: r.isActive === 1, testStatus: r.testStatus, createdAt: r.createdAt, updatedAt: r.updatedAt })), apiKeys: db.all(`SELECT * FROM apiKeys`).map((r) => ({ id: r.id, key: r.key, name: r.name, machineId: r.machineId, isActive: r.isActive === 1, createdAt: r.createdAt })), @@ -118,7 +119,7 @@ export async function importDb(payload) { const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c; db.run( `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] + [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, encryptSecretJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] ); } for (const n of payload.providerNodes || []) { diff --git a/src/lib/db/migrate.js b/src/lib/db/migrate.js index 0cca4da01bc..acc8c7ccd9a 100644 --- a/src/lib/db/migrate.js +++ b/src/lib/db/migrate.js @@ -7,6 +7,7 @@ import { getMetaSync, setMetaSync } from "./helpers/metaStore.js"; import { makeBackupDir, backupFile, backupDbLite, pruneOldBackups } from "./backup.js"; import { getAppVersion } from "./version.js"; import { stringifyJson } from "./helpers/jsonCol.js"; +import { encryptSecretJson } from "./helpers/secretCol.js"; // Marker file: prevents re-importing legacy JSON when user wipes data.sqlite. const MIGRATED_MARKER = path.join(DB_DIR, ".migrated-from-json"); @@ -120,7 +121,7 @@ function importLegacyMain(adapter, data) { const { id, provider, authType, name, email, priority, isActive, createdAt, updatedAt, ...rest } = c; adapter.run( `INSERT OR REPLACE INTO providerConnections(id, provider, authType, name, email, priority, isActive, data, createdAt, updatedAt) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, stringifyJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] + [id, provider, authType || "oauth", name || null, email || null, priority || null, isActive === false ? 0 : 1, encryptSecretJson(rest), createdAt || new Date().toISOString(), updatedAt || new Date().toISOString()] ); }, (c) => ({ id: c.id ?? null, provider: c.provider ?? null, name: c.name ?? null })); diff --git a/src/lib/db/repos/connectionsRepo.js b/src/lib/db/repos/connectionsRepo.js index 4181843f8d0..d6d0bd7863d 100644 --- a/src/lib/db/repos/connectionsRepo.js +++ b/src/lib/db/repos/connectionsRepo.js @@ -1,6 +1,6 @@ import { v4 as uuidv4 } from "uuid"; import { getAdapter } from "../driver.js"; -import { parseJson, stringifyJson } from "../helpers/jsonCol.js"; +import { decryptSecretJson, encryptSecretJson } from "../helpers/secretCol.js"; const OPTIONAL_FIELDS = [ "displayName", "email", "globalPriority", "defaultModel", @@ -12,7 +12,7 @@ const OPTIONAL_FIELDS = [ function rowToConn(row) { if (!row) return null; - const extra = parseJson(row.data, {}); + const extra = decryptSecretJson(row.data, {}); return { ...extra, id: row.id, @@ -37,7 +37,7 @@ function connToRow(c) { email: email ?? null, priority: priority ?? null, isActive: isActive === false ? 0 : 1, - data: stringifyJson(rest), + data: encryptSecretJson(rest), createdAt, updatedAt, }; diff --git a/tests/unit/secretCol.test.js b/tests/unit/secretCol.test.js new file mode 100644 index 00000000000..ef728c0e604 --- /dev/null +++ b/tests/unit/secretCol.test.js @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { encryptSecretJson, decryptSecretJson } from "../../src/lib/db/helpers/secretCol.js"; + +describe("secretCol", () => { + it("round-trips a JSON value through encryption", () => { + const value = { apiKey: "sk-test-123", refreshToken: "rt-abc", nested: { n: 1 } }; + const stored = encryptSecretJson(value); + expect(typeof stored).toBe("string"); + expect(stored.startsWith("enc1:")).toBe(true); + expect(stored).not.toContain("sk-test-123"); + expect(decryptSecretJson(stored)).toEqual(value); + }); + + it("reads legacy plaintext JSON without the enc1: prefix", () => { + const value = { apiKey: "legacy-key" }; + const legacy = JSON.stringify(value); + expect(decryptSecretJson(legacy)).toEqual(value); + }); + + it("returns the fallback for null/invalid input", () => { + expect(decryptSecretJson(null, { a: 1 })).toEqual({ a: 1 }); + expect(decryptSecretJson("not json", { a: 1 })).toEqual({ a: 1 }); + expect(decryptSecretJson("enc1:garbage", { a: 1 })).toEqual({ a: 1 }); + }); +});