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

@@ -10,6 +10,7 @@ import { Instance, Prisma } from "@prisma/client";
import { generateKeyPairSync } from "crypto";
import { randomUUID } from "crypto";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "./crypto.service";
import {
InstanceIdentity,
PublicInstanceIdentity,
@@ -23,7 +24,8 @@ export class FederationService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService
private readonly config: ConfigService,
private readonly crypto: CryptoService
) {}
/**
@@ -77,22 +79,29 @@ export class FederationService {
/**
* Regenerate the instance's keypair
* Returns public identity only (no private key exposure)
*/
async regenerateKeypair(): Promise<InstanceIdentity> {
async regenerateKeypair(): Promise<PublicInstanceIdentity> {
const instance = await this.getInstanceIdentity();
const { publicKey, privateKey } = this.generateKeypair();
// Encrypt private key before storing
const encryptedPrivateKey = this.crypto.encrypt(privateKey);
const updatedInstance = await this.prisma.instance.update({
where: { id: instance.id },
data: {
publicKey,
privateKey,
privateKey: encryptedPrivateKey,
},
});
this.logger.log("Instance keypair regenerated");
return this.mapToInstanceIdentity(updatedInstance);
// Return public identity only (security fix)
const identity = this.mapToInstanceIdentity(updatedInstance);
const { privateKey: _privateKey, ...publicIdentity } = identity;
return publicIdentity;
}
/**
@@ -105,6 +114,9 @@ export class FederationService {
const name = this.config.get<string>("INSTANCE_NAME") ?? "Mosaic Instance";
const url = this.config.get<string>("INSTANCE_URL") ?? "http://localhost:3000";
// Validate instance URL
this.validateInstanceUrl(url);
const capabilities: FederationCapabilities = {
supportsQuery: true,
supportsCommand: true,
@@ -113,13 +125,16 @@ export class FederationService {
protocolVersion: "1.0",
};
// Encrypt private key before storing (AES-256-GCM)
const encryptedPrivateKey = this.crypto.encrypt(privateKey);
const instance = await this.prisma.instance.create({
data: {
instanceId,
name,
url,
publicKey,
privateKey,
privateKey: encryptedPrivateKey,
capabilities: capabilities as Prisma.JsonObject,
metadata: {},
},
@@ -137,17 +152,35 @@ export class FederationService {
return `instance-${randomUUID()}`;
}
/**
* Validate instance URL format
*/
private validateInstanceUrl(url: string): void {
try {
const parsedUrl = new URL(url);
if (parsedUrl.protocol !== "http:" && parsedUrl.protocol !== "https:") {
throw new Error("URL must use HTTP or HTTPS protocol");
}
} catch {
throw new Error(`Invalid INSTANCE_URL: ${url}. Must be a valid HTTP/HTTPS URL.`);
}
}
/**
* Map Prisma Instance to InstanceIdentity type
* Decrypts private key from storage
*/
private mapToInstanceIdentity(instance: Instance): InstanceIdentity {
// Decrypt private key (stored as AES-256-GCM encrypted)
const decryptedPrivateKey = this.crypto.decrypt(instance.privateKey);
return {
id: instance.id,
instanceId: instance.instanceId,
name: instance.name,
url: instance.url,
publicKey: instance.publicKey,
privateKey: instance.privateKey,
privateKey: decryptedPrivateKey,
capabilities: instance.capabilities as FederationCapabilities,
metadata: instance.metadata as Record<string, unknown>,
createdAt: instance.createdAt,