Files
stack/apps/api/src/federation/crypto.service.spec.ts
Jason Woltje 77d1d14e08 fix(#289): Prevent private key decryption error data leaks
Modified decrypt() error handling to only log error type without
stack traces, error details, or encrypted content. Added test to
verify sensitive data is not exposed in logs.

Security improvement: Prevents leakage of encrypted data or partial
decryption results through error logs.

Fixes #289

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-03 21:35:15 -06:00

189 lines
5.6 KiB
TypeScript

/**
* Crypto Service Tests
*/
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { ConfigService } from "@nestjs/config";
import { CryptoService } from "./crypto.service";
import { Logger } from "@nestjs/common";
describe("CryptoService", () => {
let service: CryptoService;
// Valid 32-byte hex key for testing
const testEncryptionKey = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
CryptoService,
{
provide: ConfigService,
useValue: {
get: (key: string) => {
if (key === "ENCRYPTION_KEY") return testEncryptionKey;
return undefined;
},
},
},
],
}).compile();
service = module.get<CryptoService>(CryptoService);
});
describe("initialization", () => {
it("should throw error if ENCRYPTION_KEY is missing", () => {
expect(() => {
new CryptoService({
get: () => undefined,
} as ConfigService);
}).toThrow("ENCRYPTION_KEY environment variable is required");
});
it("should throw error if ENCRYPTION_KEY is invalid length", () => {
expect(() => {
new CryptoService({
get: () => "invalid",
} as ConfigService);
}).toThrow("ENCRYPTION_KEY must be 64 hexadecimal characters");
});
it("should initialize successfully with valid key", () => {
expect(service).toBeDefined();
});
});
describe("encrypt", () => {
it("should encrypt plaintext data", () => {
// Arrange
const plaintext = "sensitive data";
// Act
const encrypted = service.encrypt(plaintext);
// Assert
expect(encrypted).toBeDefined();
expect(encrypted).not.toEqual(plaintext);
expect(encrypted.split(":")).toHaveLength(3); // iv:authTag:encrypted
});
it("should produce different ciphertext for same plaintext", () => {
// Arrange
const plaintext = "sensitive data";
// Act
const encrypted1 = service.encrypt(plaintext);
const encrypted2 = service.encrypt(plaintext);
// Assert
expect(encrypted1).not.toEqual(encrypted2); // Different IVs
});
it("should encrypt long data (RSA private key)", () => {
// Arrange
const longData =
"-----BEGIN PRIVATE KEY-----\n" + "a".repeat(1000) + "\n-----END PRIVATE KEY-----";
// Act
const encrypted = service.encrypt(longData);
// Assert
expect(encrypted).toBeDefined();
expect(encrypted.length).toBeGreaterThan(0);
});
});
describe("decrypt", () => {
it("should decrypt encrypted data", () => {
// Arrange
const plaintext = "sensitive data";
const encrypted = service.encrypt(plaintext);
// Act
const decrypted = service.decrypt(encrypted);
// Assert
expect(decrypted).toEqual(plaintext);
});
it("should decrypt long data", () => {
// Arrange
const longData =
"-----BEGIN PRIVATE KEY-----\n" + "a".repeat(1000) + "\n-----END PRIVATE KEY-----";
const encrypted = service.encrypt(longData);
// Act
const decrypted = service.decrypt(encrypted);
// Assert
expect(decrypted).toEqual(longData);
});
it("should throw error for invalid encrypted data format", () => {
// Arrange
const invalidData = "invalid:format";
// Act & Assert
expect(() => service.decrypt(invalidData)).toThrow("Failed to decrypt data");
});
it("should throw error for corrupted data", () => {
// Arrange
const plaintext = "sensitive data";
const encrypted = service.encrypt(plaintext);
const corrupted = encrypted.replace(/[0-9a-f]/, "x"); // Corrupt one character
// Act & Assert
expect(() => service.decrypt(corrupted)).toThrow("Failed to decrypt data");
});
it("should not log sensitive data in error messages", () => {
// Arrange
const loggerErrorSpy = vi.spyOn(Logger.prototype, "error");
const corruptedData = "corrupted:data:here";
// Act & Assert
expect(() => service.decrypt(corruptedData)).toThrow("Failed to decrypt data");
// Verify logger was called with safe message
expect(loggerErrorSpy).toHaveBeenCalled();
const logCall = loggerErrorSpy.mock.calls[0];
// First argument should contain error type but not sensitive data
expect(logCall[0]).toMatch(/Decryption failed:/);
// Should NOT log the actual error object with stack traces
expect(logCall.length).toBe(1); // Only one argument (the message)
// Verify the corrupted data is not in the log
const logMessage = logCall[0] as string;
expect(logMessage).not.toContain(corruptedData);
loggerErrorSpy.mockRestore();
});
});
describe("encrypt/decrypt round-trip", () => {
it("should maintain data integrity through encrypt-decrypt cycle", () => {
// Arrange
const testCases = [
"short",
"medium length string with special chars !@#$%",
"-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----",
JSON.stringify({ complex: "object", with: ["arrays", 123] }),
];
testCases.forEach((plaintext) => {
// Act
const encrypted = service.encrypt(plaintext);
const decrypted = service.decrypt(encrypted);
// Assert
expect(decrypted).toEqual(plaintext);
});
});
});
});