diff --git a/packages/server/src/utils/federationAuth.ts b/packages/server/src/utils/federationAuth.ts new file mode 100644 index 00000000..71cd3fe5 --- /dev/null +++ b/packages/server/src/utils/federationAuth.ts @@ -0,0 +1,124 @@ +import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; + +const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes + +/** + * Generate a 256-bit random hex secret for HMAC signing. + */ +export function generateHmacSecret(): string { + return randomBytes(32).toString('hex'); +} + +/** + * Compute HMAC-SHA256 of `${timestamp}.${body}` using the given secret. + * Returns a hex-encoded signature string. + */ +export function signRequest(body: string, secret: string, timestamp: number): string { + return createHmac('sha256', secret) + .update(`${timestamp}.${body}`) + .digest('hex'); +} + +/** + * Verify a federation request signature. + * + * Returns true only if: + * - The timestamp is within maxAgeMs of Date.now() + * - The HMAC-SHA256 of `${timestamp}.${body}` matches the provided signature + * + * Uses constant-time comparison to prevent timing attacks. + */ +export function verifySignature( + body: string, + signature: string, + secret: string, + timestamp: number, + maxAgeMs: number = DEFAULT_MAX_AGE_MS, +): boolean { + // Guard against empty or obviously invalid inputs + if (!body && body !== '') return false; + if (!signature || !secret) return false; + + // Validate timestamp is within the acceptable window + const age = Math.abs(Date.now() - timestamp); + if (age > maxAgeMs) return false; + + const expected = signRequest(body, secret, timestamp); + + // Convert both to Buffers for constant-time comparison + const expectedBuf = Buffer.from(expected, 'hex'); + const actualBuf = Buffer.from(signature, 'hex'); + + // timingSafeEqual requires identical lengths; mismatched lengths mean invalid + if (expectedBuf.length !== actualBuf.length) return false; + + return timingSafeEqual(expectedBuf, actualBuf); +} + +/** + * Build the HTTP headers required for an outbound federation request. + * + * Returned headers: + * X-Federation-Signature: sha256= + * X-Federation-Origin: + * X-Federation-Timestamp: + * Content-Type: application/json + */ +export function buildFederationHeaders( + body: string, + secret: string, + origin: string, +): Record { + const timestamp = Date.now(); + const sig = signRequest(body, secret, timestamp); + + return { + 'X-Federation-Signature': `sha256=${sig}`, + 'X-Federation-Origin': origin, + 'X-Federation-Timestamp': String(timestamp), + 'Content-Type': 'application/json', + }; +} + +/** + * Parse and validate the federation headers from an inbound request. + * + * Returns the extracted { origin, timestamp, signature } on success, + * or null if any required header is missing or malformed. + * + * Signature header format: `sha256=` + */ +export function parseFederationHeaders( + headers: Record, +): { origin: string; timestamp: number; signature: string } | null { + // Helper to extract a single string value from a potentially multi-value header + const getHeader = (name: string): string | null => { + const value = headers[name]; + if (value === undefined || value === null) return null; + if (Array.isArray(value)) { + return value.length > 0 ? value[0] : null; + } + return value; + }; + + const rawSignature = getHeader('x-federation-signature') ?? getHeader('X-Federation-Signature'); + const rawOrigin = getHeader('x-federation-origin') ?? getHeader('X-Federation-Origin'); + const rawTimestamp = getHeader('x-federation-timestamp') ?? getHeader('X-Federation-Timestamp'); + + if (!rawSignature || !rawOrigin || !rawTimestamp) return null; + + // Signature must be in the format "sha256=" + const sigPrefix = 'sha256='; + if (!rawSignature.startsWith(sigPrefix)) return null; + + const signature = rawSignature.slice(sigPrefix.length); + if (!signature || !/^[0-9a-f]+$/i.test(signature)) return null; + + const timestampMs = Number(rawTimestamp); + if (!Number.isFinite(timestampMs) || timestampMs <= 0) return null; + + const origin = rawOrigin.trim(); + if (!origin) return null; + + return { origin, timestamp: timestampMs, signature }; +}