Security improvements: - Reduce timestamp tolerance from 5 minutes to 60 seconds - Add nonce-based replay attack prevention using Redis - Store signature nonce with 60s TTL matching tolerance window - Reject replayed messages with same signature Changes: - Update SignatureService.TIMESTAMP_TOLERANCE_MS to 60s - Add Redis client injection to SignatureService - Make verifyConnectionRequest async for nonce checking - Create RedisProvider for shared Redis client - Update ConnectionService to await signature verification - Add comprehensive test coverage for replay prevention Part of M7.1 Remediation Sprint P1 security fixes. Fixes #284 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
242 lines
7.0 KiB
TypeScript
242 lines
7.0 KiB
TypeScript
/**
|
|
* 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<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
|
|
*/
|
|
async verifyConnectionRequest(request: ConnectionRequest): Promise<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",
|
|
};
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|