From d7c30f2c50cbbad3b0aa218a0076997da0fd0b91 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:07:19 +0200 Subject: [PATCH] feat(federation): add nonce to HMAC sign/verify and header build/parse (FED-008) - signRequest now accepts optional nonce; payload becomes `${timestamp}.${nonce}.${body}` when present, falling back to `${timestamp}.${body}` for legacy peers - verifySignature accepts matching nonce parameter and passes it through - buildFederationHeaders generates a UUID nonce per request and includes X-Federation-Nonce header - parseFederationHeaders extracts X-Federation-Nonce into nonce field (null when absent) --- packages/server/src/utils/federationAuth.ts | 31 ++++++++++++++------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/packages/server/src/utils/federationAuth.ts b/packages/server/src/utils/federationAuth.ts index ee97908d..1bb6e337 100644 --- a/packages/server/src/utils/federationAuth.ts +++ b/packages/server/src/utils/federationAuth.ts @@ -1,4 +1,4 @@ -import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; +import { createHmac, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'; import { config } from '../config.js'; const DEFAULT_MAX_AGE_MS = 15 * 60 * 1000; // 15 minutes @@ -11,12 +11,14 @@ export function generateHmacSecret(): string { } /** - * Compute HMAC-SHA256 of `${timestamp}.${body}` using the given secret. + * Compute HMAC-SHA256 of `${timestamp}.${nonce}.${body}` (or `${timestamp}.${body}` when nonce + * is absent, for backward compat with legacy peers) using the given secret. * Returns a hex-encoded signature string. */ -export function signRequest(body: string, secret: string, timestamp: number): string { +export function signRequest(body: string, secret: string, timestamp: number, nonce: string | null = null): string { + const payload = nonce ? `${timestamp}.${nonce}.${body}` : `${timestamp}.${body}`; return createHmac('sha256', secret) - .update(`${timestamp}.${body}`) + .update(payload) .digest('hex'); } @@ -25,7 +27,8 @@ export function signRequest(body: string, secret: string, timestamp: number): st * * Returns true only if: * - The timestamp is within maxAgeMs of Date.now() - * - The HMAC-SHA256 of `${timestamp}.${body}` matches the provided signature + * - The HMAC-SHA256 of `${timestamp}.${nonce}.${body}` (or `${timestamp}.${body}` when nonce + * is absent) matches the provided signature * * Uses constant-time comparison to prevent timing attacks. */ @@ -34,6 +37,7 @@ export function verifySignature( signature: string, secret: string, timestamp: number, + nonce: string | null = null, maxAgeMs: number = DEFAULT_MAX_AGE_MS, ): boolean { // Guard against empty or obviously invalid inputs @@ -44,7 +48,7 @@ export function verifySignature( const age = Math.abs(Date.now() - timestamp); if (age > maxAgeMs) return false; - const expected = signRequest(body, secret, timestamp); + const expected = signRequest(body, secret, timestamp, nonce); // Convert both to Buffers for constant-time comparison const expectedBuf = Buffer.from(expected, 'hex'); @@ -63,6 +67,7 @@ export function verifySignature( * X-Federation-Signature: sha256= * X-Federation-Origin: * X-Federation-Timestamp: + * X-Federation-Nonce: * Content-Type: application/json */ export function buildFederationHeaders( @@ -71,12 +76,14 @@ export function buildFederationHeaders( origin: string, ): Record { const timestamp = Date.now(); - const sig = signRequest(body, secret, timestamp); + const nonce = randomUUID(); + const sig = signRequest(body, secret, timestamp, nonce); return { 'X-Federation-Signature': `sha256=${sig}`, 'X-Federation-Origin': origin, 'X-Federation-Timestamp': String(timestamp), + 'X-Federation-Nonce': nonce, 'Content-Type': 'application/json', }; } @@ -84,14 +91,15 @@ export function buildFederationHeaders( /** * Parse and validate the federation headers from an inbound request. * - * Returns the extracted { origin, timestamp, signature } on success, + * Returns the extracted { origin, timestamp, signature, nonce } on success, * or null if any required header is missing or malformed. + * `nonce` is null when the peer did not send X-Federation-Nonce (legacy peers). * * Signature header format: `sha256=` */ export function parseFederationHeaders( headers: Record, -): { origin: string; timestamp: number; signature: string } | null { +): { origin: string; timestamp: number; signature: string; nonce: string | null } | null { // Helper to extract a single string value from a potentially multi-value header const getHeader = (name: string): string | null => { const value = headers[name]; @@ -121,7 +129,10 @@ export function parseFederationHeaders( const origin = rawOrigin.trim(); if (!origin) return null; - return { origin, timestamp: timestampMs, signature }; + const rawNonce = getHeader('x-federation-nonce') ?? getHeader('X-Federation-Nonce'); + const nonce = rawNonce && typeof rawNonce === 'string' && rawNonce.trim() ? rawNonce.trim() : null; + + return { origin, timestamp: timestampMs, signature, nonce }; } /**