/** * Signature Service * * Handles message signing and verification for federation protocol. */ import { Injectable, Logger, Inject } from "@nestjs/common"; import { createSign, createVerify } from "crypto"; import { FederationService } from "./federation.service"; import type { SignableMessage, SignatureValidationResult, ConnectionRequest, } from "./types/connection.types"; import type Redis from "ioredis"; @Injectable() export class SignatureService { private readonly logger = new Logger(SignatureService.name); private readonly TIMESTAMP_TOLERANCE_MS = 60 * 1000; // 60 seconds private readonly CLOCK_SKEW_TOLERANCE_MS = 60 * 1000; // 1 minute for future timestamps private readonly NONCE_TTL_SECONDS = 60; // Nonce TTL matches tolerance window constructor( private readonly federationService: FederationService, @Inject("REDIS_CLIENT") private readonly redis: Redis ) {} /** * 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 { 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 { 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 */ async verifyConnectionRequest(request: ConnectionRequest): Promise { // 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", }; } // Check for replay attack (nonce already used) const nonceKey = `nonce:${signature}`; const nonceExists = await this.redis.get(nonceKey); if (nonceExists) { this.logger.warn("Replay attack detected: signature already used"); return { valid: false, error: "Request rejected: potential replay attack detected", }; } // 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; } // Store nonce to prevent replay attacks await this.redis.setex(nonceKey, this.NONCE_TTL_SECONDS, "1"); 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; } }