/** * Federation Service * * Manages instance identity and federation connections. */ import { Injectable, Logger } from "@nestjs/common"; import { ConfigService } from "@nestjs/config"; 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, KeyPair, FederationCapabilities, } from "./types/instance.types"; @Injectable() export class FederationService { private readonly logger = new Logger(FederationService.name); constructor( private readonly prisma: PrismaService, private readonly config: ConfigService, private readonly crypto: CryptoService ) {} /** * Get the instance identity, creating it if it doesn't exist */ async getInstanceIdentity(): Promise { // Try to find existing instance let instance = await this.prisma.instance.findFirst(); if (!instance) { this.logger.log("No instance identity found, creating new one"); instance = await this.createInstanceIdentity(); } return this.mapToInstanceIdentity(instance); } /** * Get public instance identity (without private key) */ async getPublicIdentity(): Promise { const instance = await this.getInstanceIdentity(); // Exclude private key from public identity const { privateKey: _privateKey, ...publicIdentity } = instance; return publicIdentity; } /** * Generate a new RSA key pair for instance signing * Uses RSA-4096 for future-proof security (NIST recommendation) */ generateKeypair(): KeyPair { const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 4096, publicKeyEncoding: { type: "spki", format: "pem", }, privateKeyEncoding: { type: "pkcs8", format: "pem", }, }); return { publicKey, privateKey, }; } /** * Regenerate the instance's keypair * Returns public identity only (no private key exposure) */ async regenerateKeypair(): Promise { 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: encryptedPrivateKey, }, }); this.logger.log("Instance keypair regenerated"); // Return public identity only (security fix) const identity = this.mapToInstanceIdentity(updatedInstance); const { privateKey: _privateKey, ...publicIdentity } = identity; return publicIdentity; } /** * Update instance configuration * Allows updating name, capabilities, and metadata * Returns public identity only (no private key exposure) */ async updateInstanceConfiguration(updates: { name?: string; capabilities?: FederationCapabilities; metadata?: Record; }): Promise { const instance = await this.getInstanceIdentity(); // Build update data object const data: Prisma.InstanceUpdateInput = {}; if (updates.name !== undefined) { data.name = updates.name; } if (updates.capabilities !== undefined) { data.capabilities = updates.capabilities as Prisma.JsonObject; } if (updates.metadata !== undefined) { data.metadata = updates.metadata as Prisma.JsonObject; } const updatedInstance = await this.prisma.instance.update({ where: { id: instance.id }, data, }); this.logger.log(`Instance configuration updated: ${JSON.stringify(updates)}`); // Return public identity only (security fix) const identity = this.mapToInstanceIdentity(updatedInstance); const { privateKey: _privateKey, ...publicIdentity } = identity; return publicIdentity; } /** * Create a new instance identity */ private async createInstanceIdentity(): Promise { const { publicKey, privateKey } = this.generateKeypair(); const instanceId = this.generateInstanceId(); const name = this.config.get("INSTANCE_NAME") ?? "Mosaic Instance"; const url = this.config.get("INSTANCE_URL") ?? "http://localhost:3000"; // Validate instance URL this.validateInstanceUrl(url); const capabilities: FederationCapabilities = { supportsQuery: true, supportsCommand: true, supportsEvent: true, supportsAgentSpawn: true, 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: encryptedPrivateKey, capabilities: capabilities as Prisma.JsonObject, metadata: {}, }, }); this.logger.log(`Created instance identity: ${instanceId}`); return instance; } /** * Get a federation connection by remote instance ID * Returns the first active or pending connection */ async getConnectionByRemoteInstanceId( remoteInstanceId: string ): Promise<{ remotePublicKey: string } | null> { const connection = await this.prisma.federationConnection.findFirst({ where: { remoteInstanceId, status: { in: ["ACTIVE", "PENDING"], }, }, select: { remotePublicKey: true, }, }); return connection; } /** * Generate a unique instance ID */ private generateInstanceId(): string { 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: decryptedPrivateKey, capabilities: instance.capabilities as FederationCapabilities, metadata: instance.metadata as Record, createdAt: instance.createdAt, updatedAt: instance.updatedAt, }; } }