Changed modulusLength from 2048 to 4096 in generateKeypair() method following NIST recommendations for long-term security. Added test to verify generated keys meet the minimum size requirement. Security improvement: RSA-4096 provides better protection against future cryptographic attacks as computational power increases. Fixes #288 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
254 lines
6.9 KiB
TypeScript
254 lines
6.9 KiB
TypeScript
/**
|
|
* 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<InstanceIdentity> {
|
|
// 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<PublicInstanceIdentity> {
|
|
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<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: 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<string, unknown>;
|
|
}): Promise<PublicInstanceIdentity> {
|
|
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<Instance> {
|
|
const { publicKey, privateKey } = this.generateKeypair();
|
|
|
|
const instanceId = this.generateInstanceId();
|
|
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,
|
|
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<string, unknown>,
|
|
createdAt: instance.createdAt,
|
|
updatedAt: instance.updatedAt,
|
|
};
|
|
}
|
|
}
|