fix: swap primary/legacy key derivation in encryption module

This commit is contained in:
oyi77
2026-05-04 19:19:25 +07:00
committed by diegosouzapw
parent 291ad52891
commit 773d71204f
3 changed files with 625 additions and 27 deletions

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

View File

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

View File

@@ -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",