Files
stack/apps/api/src/federation/federation.service.ts
T
Jason WoltjeandClaude Opus 4.5 70a6bc82e0 feat(#87): implement cross-instance identity linking for federation
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]>
2026-02-03 12:55:37 -06:00

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,
};
}
}