feat(#85): implement CONNECT/DISCONNECT protocol
Implemented connection handshake protocol for federation building on the Instance Identity Model from issue #84. **Services:** - SignatureService: Message signing/verification with RSA-SHA256 - ConnectionService: Federation connection management **API Endpoints:** - POST /api/v1/federation/connections/initiate - POST /api/v1/federation/connections/:id/accept - POST /api/v1/federation/connections/:id/reject - POST /api/v1/federation/connections/:id/disconnect - GET /api/v1/federation/connections - GET /api/v1/federation/connections/:id - POST /api/v1/federation/incoming/connect **Tests:** 70 tests pass (18 Signature + 20 Connection + 13 Controller + 19 existing) **Coverage:** 100% on new code **TDD Approach:** Tests written before implementation Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
192
apps/api/src/federation/signature.service.ts
Normal file
192
apps/api/src/federation/signature.service.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* 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 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 null
|
||||
if (obj === null) {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle arrays - map recursively
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (Array.isArray(obj)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument
|
||||
return obj.map((item: any) =>
|
||||
typeof item === "object" && item !== null ? this.sortObjectKeys(item) : item
|
||||
) as SignableMessage;
|
||||
}
|
||||
|
||||
// Handle non-objects (primitives)
|
||||
if (typeof obj !== "object") {
|
||||
return obj;
|
||||
}
|
||||
|
||||
// Handle objects - sort keys alphabetically
|
||||
const sorted: SignableMessage = {};
|
||||
const keys = Object.keys(obj).sort();
|
||||
|
||||
for (const key of keys) {
|
||||
const value = obj[key];
|
||||
sorted[key] =
|
||||
typeof value === "object" && value !== null
|
||||
? this.sortObjectKeys(value as SignableMessage)
|
||||
: value;
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user