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>
This commit is contained in:
Jason Woltje
2026-02-03 11:13:12 -06:00
parent 7989c089ef
commit e3dd490d4d
12 changed files with 516 additions and 38 deletions

View File

@@ -0,0 +1,97 @@
/**
* Crypto Service
*
* Handles encryption/decryption for sensitive data.
*/
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createCipheriv, createDecipheriv, randomBytes } from "crypto";
@Injectable()
export class CryptoService {
private readonly logger = new Logger(CryptoService.name);
private readonly algorithm = "aes-256-gcm";
private readonly encryptionKey: Buffer;
constructor(private readonly config: ConfigService) {
const keyHex = this.config.get<string>("ENCRYPTION_KEY");
if (!keyHex) {
throw new Error("ENCRYPTION_KEY environment variable is required for private key encryption");
}
// Validate key is 64 hex characters (32 bytes for AES-256)
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
throw new Error("ENCRYPTION_KEY must be 64 hexadecimal characters (32 bytes)");
}
this.encryptionKey = Buffer.from(keyHex, "hex");
this.logger.log("Crypto service initialized with AES-256-GCM encryption");
}
/**
* Encrypt sensitive data (e.g., private keys)
* Returns base64-encoded string with format: iv:authTag:encrypted
*/
encrypt(plaintext: string): string {
try {
// Generate random IV (12 bytes for GCM)
const iv = randomBytes(12);
// Create cipher
const cipher = createCipheriv(this.algorithm, this.encryptionKey, iv);
// Encrypt data
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
// Get auth tag
const authTag = cipher.getAuthTag();
// Return as iv:authTag:encrypted (all hex-encoded)
return `${iv.toString("hex")}:${authTag.toString("hex")}:${encrypted}`;
} catch (error) {
this.logger.error("Encryption failed", error);
throw new Error("Failed to encrypt data");
}
}
/**
* Decrypt sensitive data
* Expects format: iv:authTag:encrypted (all hex-encoded)
*/
decrypt(encrypted: string): string {
try {
// Parse encrypted data
const parts = encrypted.split(":");
if (parts.length !== 3) {
throw new Error("Invalid encrypted data format");
}
const ivHex = parts[0];
const authTagHex = parts[1];
const encryptedData = parts[2];
if (!ivHex || !authTagHex || !encryptedData) {
throw new Error("Invalid encrypted data format");
}
const iv = Buffer.from(ivHex, "hex");
const authTag = Buffer.from(authTagHex, "hex");
// Create decipher
const decipher = createDecipheriv(this.algorithm, this.encryptionKey, iv);
decipher.setAuthTag(authTag);
// Decrypt data
const decryptedBuffer = decipher.update(encryptedData, "hex");
const finalBuffer = decipher.final();
const decrypted = Buffer.concat([decryptedBuffer, finalBuffer]).toString("utf8");
return decrypted;
} catch (error) {
this.logger.error("Decryption failed", error);
throw new Error("Failed to decrypt data");
}
}
}