Implemented comprehensive security fixes for federation instance identity: CRITICAL SECURITY FIXES: 1. Private Key Encryption at Rest (AES-256-GCM) - Implemented CryptoService with AES-256-GCM encryption - Private keys encrypted before database storage - Decrypted only when needed in-memory - Master key stored in ENCRYPTION_KEY environment variable - Updated schema comment to reflect actual encryption method 2. Admin Authorization on Key Regeneration - Created AdminGuard for system-level admin operations - Requires workspace ownership for admin privileges - Key regeneration restricted to admin users only - Proper authorization checks before sensitive operations 3. Private Key Never Exposed in API Responses - Changed regenerateKeypair return type to PublicInstanceIdentity - Service method strips private key before returning - Added tests to verify private key exclusion - Controller returns only public identity ADDITIONAL SECURITY IMPROVEMENTS: 4. Audit Logging for Key Regeneration - Created FederationAuditService - Logs all keypair regeneration events - Includes userId, instanceId, and timestamp - Marked as security events for compliance 5. Input Validation for INSTANCE_URL - Validates URL format (must be HTTP/HTTPS) - Throws error on invalid URLs - Prevents malformed configuration 6. Added .env.example - Documents all required environment variables - Includes INSTANCE_NAME, INSTANCE_URL - Includes ENCRYPTION_KEY with generation instructions - Clear security warnings for production use TESTING: - Added 11 comprehensive crypto service tests - Updated 8 federation service tests for encryption - Updated 5 controller tests for security verification - Total: 24 tests passing (100% success rate) - Verified private key never exposed in responses - Verified encryption/decryption round-trip - Verified admin authorization requirements FILES CREATED: - apps/api/src/federation/crypto.service.ts (encryption) - apps/api/src/federation/crypto.service.spec.ts (tests) - apps/api/src/federation/audit.service.ts (audit logging) - apps/api/src/auth/guards/admin.guard.ts (authorization) - apps/api/.env.example (configuration template) FILES MODIFIED: - apps/api/prisma/schema.prisma (updated comment) - apps/api/src/federation/federation.service.ts (encryption integration) - apps/api/src/federation/federation.controller.ts (admin guard, audit) - apps/api/src/federation/federation.module.ts (new providers) - All test files updated for new security requirements CODE QUALITY: - All tests passing (24/24) - TypeScript compilation: PASS - ESLint: PASS - Test coverage maintained at 100% Fixes #84 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
163 lines
4.6 KiB
TypeScript
163 lines
4.6 KiB
TypeScript
/**
|
|
* Crypto Service Tests
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach } from "vitest";
|
|
import { Test, TestingModule } from "@nestjs/testing";
|
|
import { ConfigService } from "@nestjs/config";
|
|
import { CryptoService } from "./crypto.service";
|
|
|
|
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");
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
});
|
|
});
|