Files
stack/apps/api/src/federation/signature.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

221 lines
6.3 KiB
TypeScript

/**
* Signature Service
*
* Handles message signing and verification for federation protocol.
*/
import { Injectable, Logger } from "@nestjs/common";
import { createSign, createVerify } from "crypto";
import { FederationService } from "./federation.service";
import type {
SignableMessage,
SignatureValidationResult,
ConnectionRequest,
} from "./types/connection.types";
@Injectable()
export class SignatureService {
private readonly logger = new Logger(SignatureService.name);
private readonly TIMESTAMP_TOLERANCE_MS = 5 * 60 * 1000; // 5 minutes
private readonly CLOCK_SKEW_TOLERANCE_MS = 60 * 1000; // 1 minute for future timestamps
constructor(private readonly federationService: FederationService) {}
/**
* Sign a message with a private key
* Returns base64-encoded RSA-SHA256 signature
*/
sign(message: SignableMessage, privateKey: string): string {
try {
// Create canonical JSON representation (sorted keys)
const canonical = this.canonicalizeMessage(message);
// Create signature
const sign = createSign("RSA-SHA256");
sign.update(canonical);
sign.end();
const signature = sign.sign(privateKey, "base64");
return signature;
} catch (error) {
this.logger.error("Failed to sign message", error);
throw new Error("Failed to sign message");
}
}
/**
* Verify a message signature with a public key
*/
verify(
message: SignableMessage,
signature: string,
publicKey: string
): SignatureValidationResult {
try {
// Create canonical JSON representation (sorted keys)
const canonical = this.canonicalizeMessage(message);
// Verify signature
const verify = createVerify("RSA-SHA256");
verify.update(canonical);
verify.end();
const valid = verify.verify(publicKey, signature, "base64");
if (!valid) {
return {
valid: false,
error: "Invalid signature",
};
}
return { valid: true };
} catch (error) {
this.logger.error("Signature verification failed", error);
return {
valid: false,
error: error instanceof Error ? error.message : "Verification failed",
};
}
}
/**
* Validate timestamp is within acceptable range
* Rejects timestamps older than 5 minutes or more than 1 minute in the future
*/
validateTimestamp(timestamp: number): boolean {
const now = Date.now();
const age = now - timestamp;
// Reject if too old
if (age > this.TIMESTAMP_TOLERANCE_MS) {
this.logger.warn(`Timestamp too old: ${age.toString()}ms`);
return false;
}
// Reject if too far in the future (allow some clock skew)
if (age < -this.CLOCK_SKEW_TOLERANCE_MS) {
this.logger.warn(`Timestamp too far in future: ${(-age).toString()}ms`);
return false;
}
return true;
}
/**
* Sign a message using this instance's private key
*/
async signMessage(message: SignableMessage): Promise<string> {
const identity = await this.federationService.getInstanceIdentity();
if (!identity.privateKey) {
throw new Error("Instance private key not available");
}
return this.sign(message, identity.privateKey);
}
/**
* Verify a message signature using a remote instance's public key
* Fetches the public key from the connection record
*/
async verifyMessage(
message: SignableMessage,
signature: string,
remoteInstanceId: string
): Promise<SignatureValidationResult> {
try {
// Fetch remote instance public key from connection record
// For now, we'll fetch from any connection with this instance
// In production, this should be cached or fetched from instance identity endpoint
const connection =
await this.federationService.getConnectionByRemoteInstanceId(remoteInstanceId);
if (!connection) {
return {
valid: false,
error: "Remote instance not connected",
};
}
// Verify signature using remote public key
return this.verify(message, signature, connection.remotePublicKey);
} catch (error) {
this.logger.error("Failed to verify message", error);
return {
valid: false,
error: error instanceof Error ? error.message : "Verification failed",
};
}
}
/**
* Verify a connection request signature
*/
verifyConnectionRequest(request: ConnectionRequest): SignatureValidationResult {
// Extract signature and create message for verification
const { signature, ...message } = request;
// Validate timestamp
if (!this.validateTimestamp(request.timestamp)) {
return {
valid: false,
error: "Request timestamp is outside acceptable range",
};
}
// Verify signature using the public key from the request
const result = this.verify(message, signature, request.publicKey);
if (!result.valid) {
const errorMsg = result.error ?? "Unknown error";
this.logger.warn(`Connection request signature verification failed: ${errorMsg}`);
}
return result;
}
/**
* Create canonical JSON representation of a message for signing
* Sorts keys recursively to ensure consistent signatures
*/
private canonicalizeMessage(message: SignableMessage): string {
return JSON.stringify(this.sortObjectKeys(message));
}
/**
* Recursively sort object keys for canonical representation
* @param obj - The object to sort
* @returns A new object with sorted keys
*/
private sortObjectKeys(obj: SignableMessage): SignableMessage {
// Handle arrays - recursively sort elements
if (Array.isArray(obj)) {
const sortedArray = obj.map((item: unknown): unknown => {
if (typeof item === "object" && item !== null) {
return this.sortObjectKeys(item as SignableMessage);
}
return item;
});
// Arrays are valid SignableMessage values when nested in objects
return sortedArray as unknown as SignableMessage;
}
// Handle objects - sort keys alphabetically
const sorted: SignableMessage = {};
const keys = Object.keys(obj).sort();
for (const key of keys) {
const value = obj[key];
if (typeof value === "object" && value !== null) {
sorted[key] = this.sortObjectKeys(value as SignableMessage);
} else {
sorted[key] = value;
}
}
return sorted;
}
}