Files
stack/apps/api/src/federation/crypto.service.spec.ts
Jason Woltje e3dd490d4d fix(#84): address critical security issues in federation identity
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>
2026-02-03 11:13:12 -06:00

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