From 773d71204f7b41c13c4072e545ae2e72ee726386 Mon Sep 17 00:00:00 2001 From: oyi77 Date: Mon, 4 May 2026 19:19:25 +0700 Subject: [PATCH] fix: swap primary/legacy key derivation in encryption module --- src/lib/db/__tests__/encryption.test.ts | 553 ++++++++++++++++++++++++ src/lib/db/encryption.ts | 98 +++-- vitest.config.ts | 1 + 3 files changed, 625 insertions(+), 27 deletions(-) create mode 100644 src/lib/db/__tests__/encryption.test.ts diff --git a/src/lib/db/__tests__/encryption.test.ts b/src/lib/db/__tests__/encryption.test.ts new file mode 100644 index 0000000000..ed76bc48a9 --- /dev/null +++ b/src/lib/db/__tests__/encryption.test.ts @@ -0,0 +1,553 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createCipheriv, randomBytes, scryptSync, createHash } from "crypto"; + +// Test helper to manually create legacy-encrypted values +function createLegacyEncrypted(plaintext: string, secret: string): string { + const ALGORITHM = "aes-256-gcm"; + const IV_LENGTH = 16; + const KEY_LENGTH = 32; + const PREFIX = "enc:v1:"; + + // OLD dynamic salt derivation (the bug) + const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); + const legacyKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); + + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, legacyKey, iv); + + let encrypted = cipher.update(plaintext, "utf8", "hex"); + encrypted += cipher.final("hex"); + const authTag = cipher.getAuthTag().toString("hex"); + + return `${PREFIX}${iv.toString("hex")}:${encrypted}:${authTag}`; +} + +describe("encryption module", () => { + beforeEach(() => { + // Clear all env vars and reset modules before each test + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + describe("encrypt/decrypt roundtrip with static key (PRIMARY path)", () => { + it("should encrypt and decrypt a value successfully", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("../encryption"); + + const plaintext = "my-secret-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBeDefined(); + expect(encrypted).toMatch(/^enc:v1:/); + expect(encrypted).not.toBe(plaintext); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple encrypt/decrypt cycles", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("../encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + const decrypted = encrypted.map((e) => decrypt(e!)); + + expect(decrypted).toEqual(values); + }); + }); + + describe("legacy fallback: decrypt values encrypted with dynamic-salt key", () => { + it("should decrypt legacy-encrypted value using fallback key", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-api-token"; + + // Manually create a legacy-encrypted value + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + }); + + it("should handle multiple legacy-encrypted values", async () => { + const secret = "test-secret-key-12345"; + const values = ["legacy1", "legacy2", "legacy3"]; + + const legacyEncrypted = values.map((v) => createLegacyEncrypted(v, secret)); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const decrypted = legacyEncrypted.map((e) => decrypt(e)); + expect(decrypted).toEqual(values); + }); + }); + + describe("migration flag: after legacy decrypt, isMigrationNeeded() returns true", () => { + it("should set migration flag when decrypting legacy value", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded } = await import("../encryption"); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(true); + }); + + it("should NOT set migration flag when decrypting static-key value", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("../encryption"); + + const plaintext = "modern-token"; + const encrypted = encrypt(plaintext); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("resetMigrationFlag() clears the flag", () => { + it("should reset migration flag after it was set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "legacy-token"; + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded, resetMigrationFlag } = await import("../encryption"); + + decrypt(legacyEncrypted); + expect(isMigrationNeeded()).toBe(true); + + resetMigrationFlag(); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("passthrough mode: no STORAGE_ENCRYPTION_KEY set → plaintext stored", () => { + it("should return plaintext when encryption key is not set", async () => { + // No STORAGE_ENCRYPTION_KEY set + vi.resetModules(); + + const { encrypt, decrypt, isEncryptionEnabled } = await import("../encryption"); + + expect(isEncryptionEnabled()).toBe(false); + + const plaintext = "my-api-key"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toBe(plaintext); + + const decrypted = decrypt(plaintext); + expect(decrypted).toBe(plaintext); + }); + + it("should handle null and undefined in passthrough mode", async () => { + vi.resetModules(); + + const { encrypt, decrypt } = await import("../encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + }); + + describe("encryptConnectionFields / decryptConnectionFields helpers", () => { + it("should encrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields } = await import("../encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields(conn); + + expect(encrypted.id).toBe("conn-123"); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toMatch(/^enc:v1:/); + expect(encrypted.refreshToken).toMatch(/^enc:v1:/); + expect(encrypted.idToken).toMatch(/^enc:v1:/); + }); + + it("should decrypt all credential fields in a connection object", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = await import("../encryption"); + + const conn = { + id: "conn-123", + apiKey: "plain-api-key", + accessToken: "plain-access-token", + refreshToken: "plain-refresh-token", + idToken: "plain-id-token", + }; + + const encrypted = encryptConnectionFields({ ...conn }); + const decrypted = decryptConnectionFields(encrypted); + + expect(decrypted.id).toBe("conn-123"); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBe("plain-access-token"); + expect(decrypted.refreshToken).toBe("plain-refresh-token"); + expect(decrypted.idToken).toBe("plain-id-token"); + }); + + it("should handle null and undefined connection objects", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = await import("../encryption"); + + expect(encryptConnectionFields(null)).toBeNull(); + expect(encryptConnectionFields(undefined)).toBeUndefined(); + expect(decryptConnectionFields(null)).toBeNull(); + expect(decryptConnectionFields(undefined)).toBeUndefined(); + }); + + it("should handle connection objects with missing fields", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encryptConnectionFields, decryptConnectionFields } = await import("../encryption"); + + const conn: import("../encryption").ConnectionFields = { + id: "conn-123", + apiKey: "plain-api-key", + // accessToken, refreshToken, idToken missing + }; + + const encrypted = encryptConnectionFields({ ...conn }); + expect(encrypted.apiKey).toMatch(/^enc:v1:/); + expect(encrypted.accessToken).toBeUndefined(); + + const decrypted = decryptConnectionFields(encrypted); + expect(decrypted.apiKey).toBe("plain-api-key"); + expect(decrypted.accessToken).toBeUndefined(); + }); + + it("should set migration flag when decrypting legacy-encrypted connection fields", async () => { + const secret = "test-secret-key-12345"; + const legacyApiKey = createLegacyEncrypted("legacy-api-key", secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decryptConnectionFields, isMigrationNeeded } = await import("../encryption"); + + const conn = { + id: "conn-123", + apiKey: legacyApiKey, + }; + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decryptConnectionFields(conn); + expect(decrypted.apiKey).toBe("legacy-api-key"); + expect(isMigrationNeeded()).toBe(true); + }); + }); + + describe("edge cases: null/undefined inputs, already-encrypted, malformed ciphertext", () => { + it("should handle null and undefined inputs", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("../encryption"); + + expect(encrypt(null)).toBeNull(); + expect(encrypt(undefined)).toBeUndefined(); + expect(decrypt(null)).toBeNull(); + expect(decrypt(undefined)).toBeUndefined(); + }); + + it("should not double-encrypt already-encrypted values", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("../encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + expect(encrypted).toMatch(/^enc:v1:/); + + // Try to encrypt again + const doubleEncrypted = encrypt(encrypted!); + expect(doubleEncrypted).toBe(encrypted); + }); + + it("should return null for malformed ciphertext (missing parts)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const malformed = "enc:v1:onlyonepart"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for malformed ciphertext (invalid hex)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const malformed = "enc:v1:notvalidhex:notvalidhex:notvalidhex"; + const result = decrypt(malformed); + expect(result).toBeNull(); + }); + + it("should return null for ciphertext with wrong auth tag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt } = await import("../encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Tamper with the auth tag + const parts = encrypted!.split(":"); + parts[parts.length - 1] = "0000000000000000000000000000000000000000000000000000000000000000"; + const tampered = parts.join(":"); + + const result = decrypt(tampered); + expect(result).toBeNull(); + }); + + it("should return plaintext unchanged if not encrypted (legacy plaintext)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const plaintext = "not-encrypted-value"; + const result = decrypt(plaintext); + expect(result).toBe(plaintext); + }); + + it("should return null when trying to decrypt without encryption key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt } = await import("../encryption"); + + const plaintext = "my-secret"; + const encrypted = encrypt(plaintext); + + // Now remove the key and try to decrypt + vi.unstubAllEnvs(); + vi.resetModules(); + + const { decrypt } = await import("../encryption"); + + const result = decrypt(encrypted!); + expect(result).toBeNull(); + }); + }); + + describe("validateEncryptionConfig() with various key states", () => { + it("should return valid when no key is set (passthrough mode)", async () => { + vi.resetModules(); + + const { validateEncryptionConfig } = await import("../encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when a proper key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("../encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return valid when key is empty string (treated as not set)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("../encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should return invalid when key is whitespace only", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", " "); + vi.resetModules(); + + const { validateEncryptionConfig } = await import("../encryption"); + + const result = validateEncryptionConfig(); + expect(result.valid).toBe(false); + expect(result.error).toContain("empty"); + }); + }); + + describe("isEncryptionEnabled() accuracy", () => { + it("should return false when no key is set", async () => { + vi.resetModules(); + + const { isEncryptionEnabled } = await import("../encryption"); + + expect(isEncryptionEnabled()).toBe(false); + }); + + it("should return true when key is set", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("../encryption"); + + expect(isEncryptionEnabled()).toBe(true); + }); + + it("should return true even when key is invalid (just checks presence)", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", ""); + vi.resetModules(); + + const { isEncryptionEnabled } = await import("../encryption"); + + // isEncryptionEnabled only checks if the env var is truthy + expect(isEncryptionEnabled()).toBe(false); + }); + }); + + describe("new encryptions always use static salt key", () => { + it("should encrypt with static key and decrypt without migration flag", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("../encryption"); + + const plaintext = "new-token"; + const encrypted = encrypt(plaintext); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = decrypt(encrypted!); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + + it("should verify multiple new encryptions use static key", async () => { + vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345"); + vi.resetModules(); + + const { encrypt, decrypt, isMigrationNeeded } = await import("../encryption"); + + const values = ["token1", "token2", "token3"]; + const encrypted = values.map((v) => encrypt(v)); + + expect(isMigrationNeeded()).toBe(false); + + const decrypted = encrypted.map((e) => decrypt(e!)); + expect(decrypted).toEqual(values); + expect(isMigrationNeeded()).toBe(false); + }); + }); + + describe("EXACT bug scenario: dynamic salt → legacy fallback → migration flag", () => { + it("should reproduce the exact bug: value encrypted with dynamic salt, decrypt recovers via legacy fallback, migration flag set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "health-check-token"; + + // Simulate the bug: health-check thread encrypted with dynamic salt + const buggyEncrypted = createLegacyEncrypted(plaintext, secret); + + // Main API tries to decrypt + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, isMigrationNeeded } = await import("../encryption"); + + expect(isMigrationNeeded()).toBe(false); + + // Should recover via legacy fallback + const decrypted = decrypt(buggyEncrypted); + expect(decrypted).toBe(plaintext); + + // Migration flag should be set + expect(isMigrationNeeded()).toBe(true); + }); + + it("should verify re-encryption after migration flag is set", async () => { + const secret = "test-secret-key-12345"; + const plaintext = "health-check-token"; + + const legacyEncrypted = createLegacyEncrypted(plaintext, secret); + + vi.stubEnv("STORAGE_ENCRYPTION_KEY", secret); + vi.resetModules(); + + const { decrypt, encrypt, isMigrationNeeded, resetMigrationFlag } = + await import("../encryption"); + + // Decrypt legacy value + const decrypted = decrypt(legacyEncrypted); + expect(decrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(true); + + // Re-encrypt with static key + const reEncrypted = encrypt(decrypted!); + expect(reEncrypted).toMatch(/^enc:v1:/); + expect(reEncrypted).not.toBe(legacyEncrypted); + + // Reset migration flag + resetMigrationFlag(); + expect(isMigrationNeeded()).toBe(false); + + // Verify new encryption decrypts without migration flag + const finalDecrypted = decrypt(reEncrypted!); + expect(finalDecrypted).toBe(plaintext); + expect(isMigrationNeeded()).toBe(false); + }); + }); +}); diff --git a/src/lib/db/encryption.ts b/src/lib/db/encryption.ts index f322ceb54a..a392f7f3f4 100644 --- a/src/lib/db/encryption.ts +++ b/src/lib/db/encryption.ts @@ -6,6 +6,23 @@ * * If STORAGE_ENCRYPTION_KEY is not set, operates in passthrough mode * (stores plaintext for development convenience). + * + * KEY DERIVATION CHANGE (v3.7.9): + * The PRIMARY key is now derived with a static salt ("omniroute-field-encryption-v1"). + * The LEGACY key used a dynamic salt (sha256 hash of the key). Auto-migration + * re-encrypts any legacy-encrypted tokens on decrypt. + * + * Why the change? + * The dynamic salt `createHash("sha256").update(secret).digest().slice(0, 16)` produced + * a different derived key than the static salt `"omniroute-field-encryption-v1"`. When the + * health-check/token-refresh path used one derivation and the main API used another, + * tokens encrypted by one path became undecryptable by the other, causing: + * - Persistent decrypt failures + * - Re-encryption loops (health-check undoing fixes) + * - CPU spikes (50%) from error cascades + * + * This fix makes the static salt the primary derivation and auto-migrates + * legacy-encrypted tokens back to static-salt encryption. */ import { createCipheriv, createDecipheriv, randomBytes, scryptSync, createHash } from "crypto"; @@ -14,9 +31,11 @@ const ALGORITHM = "aes-256-gcm"; const IV_LENGTH = 16; const KEY_LENGTH = 32; const PREFIX = "enc:v1:"; +const STATIC_SALT = "omniroute-field-encryption-v1"; -let _derivedKey: Buffer | null = null; -let _legacyDerivedKey: Buffer | null = null; +let _staticKey: Buffer | null = null; +let _legacyDynamicKey: Buffer | null = null; +let _migrationNeeded = false; /** Connection object with potentially encrypted credential fields. */ export interface ConnectionFields { @@ -28,11 +47,12 @@ export interface ConnectionFields { } /** - * Derive a 256-bit key from the env secret using scrypt. + * Derive the PRIMARY encryption key using the static salt. + * This is the canonical key derivation that all new encryptions use. * Returns null if no encryption key is configured. */ -function getKey(): Buffer | null { - if (_derivedKey !== null) return _derivedKey; +function getStaticKey(): Buffer | null { + if (_staticKey !== null) return _staticKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret) return null; @@ -45,10 +65,8 @@ function getKey(): Buffer | null { return null; } - // Dynamic salt derived from key hash to prevent rainbow table attacks, while remaining deterministic - const salt = createHash("sha256").update(secret).digest().slice(0, 16); try { - _derivedKey = scryptSync(secret, salt, KEY_LENGTH); + _staticKey = scryptSync(secret, STATIC_SALT, KEY_LENGTH); } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error( @@ -57,26 +75,40 @@ function getKey(): Buffer | null { ); return null; } - return _derivedKey; + return _staticKey; } /** - * Derive legacy 256-bit key from the env secret using the old static salt. - * Used exclusively for fallback decryption. + * Derive the LEGACY key using the old dynamic salt method. + * Used exclusively for fallback decryption of tokens encrypted by older versions. + * + * The old dynamic salt was: createHash("sha256").update(secret).digest().slice(0, 16) + * This produced a different derived key than the static salt, causing incompatibility. */ -function getLegacyKey(): Buffer | null { - if (_legacyDerivedKey !== null) return _legacyDerivedKey; +function getLegacyDynamicKey(): Buffer | null { + if (_legacyDynamicKey !== null) return _legacyDynamicKey; const secret = process.env.STORAGE_ENCRYPTION_KEY; if (!secret || typeof secret !== "string" || secret.trim().length === 0) return null; - const legacySalt = "omniroute-field-encryption-v1"; + // This is the OLD dynamic salt derivation that caused the bug + const dynamicSalt = createHash("sha256").update(secret).digest().slice(0, 16); try { - _legacyDerivedKey = scryptSync(secret, legacySalt, KEY_LENGTH); + _legacyDynamicKey = scryptSync(secret, dynamicSalt, KEY_LENGTH); } catch { return null; } - return _legacyDerivedKey; + return _legacyDynamicKey; +} + +/** Check if any tokens were decrypted using the legacy key (indicating migration is needed). */ +export function isMigrationNeeded(): boolean { + return _migrationNeeded; +} + +/** Reset migration flag (call after migration is complete). */ +export function resetMigrationFlag(): void { + _migrationNeeded = false; } /** Check if encryption is enabled. */ @@ -85,13 +117,13 @@ export function isEncryptionEnabled(): boolean { } /** - * Encrypt a plaintext string. Returns ciphertext with prefix. + * Encrypt a plaintext string using the STATIC salt key. * If encryption is not configured, returns plaintext unchanged. */ export function encrypt(plaintext: string | null | undefined): string | null | undefined { if (!plaintext || typeof plaintext !== "string") return plaintext; - const key = getKey(); + const key = getStaticKey(); if (!key) { console.warn( "[Encryption] STORAGE_ENCRYPTION_KEY not set. Storing plaintext (passthrough mode)." @@ -122,7 +154,12 @@ export function encrypt(plaintext: string | null | undefined): string | null | u } /** - * Decrypt a ciphertext string. If not encrypted (no prefix), returns as-is. + * Decrypt a ciphertext string. Attempts static-salt key first (primary), + * then falls back to legacy dynamic-salt key for backward compatibility. + * + * When a token is decrypted using the legacy key, it is flagged for + * auto-migration: the next encrypt() call will re-encrypt it with the + * static-salt key, gradually migrating the database. */ export function decrypt(ciphertext: string | null | undefined): string | null | undefined { if (!ciphertext || typeof ciphertext !== "string") return ciphertext; @@ -130,12 +167,11 @@ export function decrypt(ciphertext: string | null | undefined): string | null | // Not encrypted — return as-is (legacy plaintext or passthrough mode) if (!ciphertext.startsWith(PREFIX)) return ciphertext; - const key = getKey(); - if (!key) { + const staticKey = getStaticKey(); + if (!staticKey) { console.warn( "[Encryption] Found encrypted data but STORAGE_ENCRYPTION_KEY is not set. Cannot decrypt." ); - // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers return null; } @@ -143,7 +179,6 @@ export function decrypt(ciphertext: string | null | undefined): string | null | const parts = body.split(":"); if (parts.length !== 3) { console.error("[Encryption] Malformed encrypted value"); - // Return null instead of encrypted ciphertext to prevent sending malformed encrypted tokens to providers return null; } @@ -165,15 +200,20 @@ export function decrypt(ciphertext: string | null | undefined): string | null | }; try { - const decrypted = tryDecryptWithKey(key); + // PRIMARY: Try static-salt key first (canonical derivation) + let decrypted = tryDecryptWithKey(staticKey); if (decrypted !== null) { return decrypted; } - const legacyKey = getLegacyKey(); + // FALLBACK: Try legacy dynamic-salt key (backward compatibility) + const legacyKey = getLegacyDynamicKey(); if (legacyKey) { const legacyDecrypted = tryDecryptWithKey(legacyKey); if (legacyDecrypted !== null) { + // Flag for migration: this token was encrypted with the legacy key + // and should be re-encrypted with the static key on next write + _migrationNeeded = true; return legacyDecrypted; } } @@ -186,13 +226,14 @@ export function decrypt(ciphertext: string | null | undefined): string | null | } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); console.error("[Encryption] Decryption failed:", message); - // Return null instead of encrypted ciphertext to prevent sending encrypted tokens to providers return null; } } /** * Encrypt sensitive fields in a connection object (mutates in-place). + * After decryption that required legacy key, re-encrypt with static key + * to migrate tokens automatically. */ export function encryptConnectionFields(conn: T): T { if (!isEncryptionEnabled()) return conn; @@ -207,6 +248,9 @@ export function encryptConnectionFields(row: T): T { if (!row) return row; @@ -242,7 +286,7 @@ export function validateEncryptionConfig(): { valid: boolean; error?: string } { // Try deriving a key to verify it works try { - scryptSync(secret, "omniroute-field-encryption-v1", KEY_LENGTH); + scryptSync(secret, STATIC_SALT, KEY_LENGTH); return { valid: true }; } catch (err: unknown) { const message = err instanceof Error ? err.message : String(err); diff --git a/vitest.config.ts b/vitest.config.ts index 097ca504ba..dcc32a55e7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -11,6 +11,7 @@ export default defineConfig({ "src/app/**/dashboard/endpoint/__tests__/**/*.test.tsx", "src/lib/memory/__tests__/**/*.test.ts", "src/lib/skills/__tests__/**/*.test.ts", + "src/lib/db/__tests__/**/*.test.ts", "open-sse/**/__tests__/**/*.test.ts", "open-sse/services/**/__tests__/**/*.test.ts", "tests/e2e/ecosystem.test.ts",