361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
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("@/lib/db/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("@/lib/db/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("migrateLegacyEncryptedString() behavior", () => {
|
|
it("should upgrade legacy encrypted tokens to static key tokens", 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 { migrateLegacyEncryptedString, decrypt } = await import("@/lib/db/encryption");
|
|
|
|
const result = migrateLegacyEncryptedString(legacyEncrypted);
|
|
expect(result.updated).toBe(true);
|
|
expect(result.value).not.toBe(legacyEncrypted);
|
|
expect(result.value).toMatch(/^enc:v1:/);
|
|
|
|
const decrypted = decrypt(result.value);
|
|
expect(decrypted).toBe(plaintext);
|
|
});
|
|
|
|
it("should NOT upgrade static-key encrypted tokens", async () => {
|
|
vi.stubEnv("STORAGE_ENCRYPTION_KEY", "test-secret-key-12345");
|
|
vi.resetModules();
|
|
|
|
const { encrypt, migrateLegacyEncryptedString } = await import("@/lib/db/encryption");
|
|
|
|
const plaintext = "modern-token";
|
|
const encrypted = encrypt(plaintext);
|
|
|
|
const result = migrateLegacyEncryptedString(encrypted!);
|
|
expect(result.updated).toBe(false);
|
|
expect(result.value).toBe(encrypted);
|
|
});
|
|
});
|
|
|
|
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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/encryption");
|
|
|
|
const conn: import("@/lib/db/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();
|
|
});
|
|
});
|
|
|
|
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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/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("@/lib/db/encryption");
|
|
|
|
const result = decrypt(encrypted!);
|
|
expect(result).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("validateEncryptionConfig() removed as dead code (#5364)", () => {
|
|
// The unused `validateEncryptionConfig` export was removed in #5364 (no
|
|
// production caller). Guard that it stays gone, mirroring the sibling
|
|
// node:test assertion in db-encryption.test.ts.
|
|
it("no longer exports validateEncryptionConfig", async () => {
|
|
vi.resetModules();
|
|
|
|
const mod = await import("@/lib/db/encryption");
|
|
|
|
expect("validateEncryptionConfig" in mod).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe("isEncryptionEnabled() accuracy", () => {
|
|
it("should return false when no key is set", async () => {
|
|
vi.resetModules();
|
|
|
|
const { isEncryptionEnabled } = await import("@/lib/db/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("@/lib/db/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("@/lib/db/encryption");
|
|
|
|
// isEncryptionEnabled only checks if the env var is truthy
|
|
expect(isEncryptionEnabled()).toBe(false);
|
|
});
|
|
});
|
|
});
|