Implements FED-004: Cross-Instance Identity Linking, building on the foundation from FED-001, FED-002, and FED-003. New Services: - IdentityLinkingService: Handles identity verification and mapping with signature validation and OIDC token verification - IdentityResolutionService: Resolves identities between local and remote instances with support for bulk operations New API Endpoints (IdentityLinkingController): - POST /api/v1/federation/identity/verify - Verify remote identity - POST /api/v1/federation/identity/resolve - Resolve remote to local user - POST /api/v1/federation/identity/bulk-resolve - Bulk resolution - GET /api/v1/federation/identity/me - Get current user's identities - POST /api/v1/federation/identity/link - Create identity mapping - PATCH /api/v1/federation/identity/:id - Update mapping - DELETE /api/v1/federation/identity/:id - Revoke mapping - GET /api/v1/federation/identity/:id/validate - Validate mapping Security Features: - Signature verification using remote instance public keys - OIDC token validation before creating mappings - Timestamp validation to prevent replay attacks - Workspace isolation via authentication guards - Comprehensive audit logging for all identity operations Enhancements: - Added SignatureService.verifyMessage() for remote signature verification - Added FederationService.getConnectionByRemoteInstanceId() - Extended FederationAuditService with identity logging methods - Created comprehensive DTOs with class-validator decorators Testing: - 38 new tests (19 service + 7 resolution + 12 controller) - All 132 federation tests passing - TypeScript compilation passing with no errors - High test coverage achieved (>85% requirement exceeded) Technical Details: - Leverages existing FederatedIdentity model from FED-003 - Uses RSA SHA-256 signatures for cryptographic verification - Supports one identity mapping per remote instance per user - Resolution service optimized for read-heavy operations - Built following TDD principles (Red-Green-Refactor) Closes #87 Co-Authored-By: Claude Opus 4.5 <[email protected]>
213 lines
5.7 KiB
TypeScript
213 lines
5.7 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
|
|
*/
|
|
generateKeypair(): KeyPair {
|
|
const { publicKey, privateKey } = generateKeyPairSync("rsa", {
|
|
modulusLength: 2048,
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
}
|