mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
fix: swap primary/legacy key derivation in encryption module
This commit is contained in:
553
src/lib/db/__tests__/encryption.test.ts
Normal file
553
src/lib/db/__tests__/encryption.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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<T extends ConnectionFields | null | undefined>(conn: T): T {
|
||||
if (!isEncryptionEnabled()) return conn;
|
||||
@@ -207,6 +248,9 @@ export function encryptConnectionFields<T extends ConnectionFields | null | unde
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields in a connection row (returns new object).
|
||||
* Note: If any field was decrypted using the legacy key, the migration
|
||||
* flag is set. The calling code should check isMigrationNeeded() and
|
||||
* trigger a re-encrypt (write-back) to migrate those tokens to the static key.
|
||||
*/
|
||||
export function decryptConnectionFields<T extends ConnectionFields | null | undefined>(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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user