From 5c5d41e46297e47dfae9dece8b33158b0283ce4e Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:12:16 +0200 Subject: [PATCH] feat(federation): wire nonce verification into relay and sync handlers (FED-008) Pass nonce through to verifySignature and enforce replay protection in both /api/federation/relay and /api/federation/sync: reject duplicate nonces (409), reject nonce-less requests from peers that previously sent nonces (401), warn for legacy peers, and auto-ratchet nonceSupported flag. --- packages/server/src/routes/federation.ts | 36 ++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index a0a1c549..b9b47ef8 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -461,10 +461,22 @@ export async function federationRoutes(app: FastifyInstance): Promise { // Serialize body back to JSON for HMAC verification (we control both sides) const bodyString = JSON.stringify(request.body); - if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) { + if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp, fedHeaders.nonce)) { return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); } + // 1c. Nonce-based replay protection + if (fedHeaders.nonce) { + if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) { + return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 }); + } + } else if (peer.nonceSupported) { + // Peer previously sent nonces but this request doesn't have one — reject + return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 }); + } else { + console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)`); + } + // 2. Validate request body shape const body = request.body; if (!body || body.version !== 1 || !Array.isArray(body.events)) { @@ -488,6 +500,7 @@ export async function federationRoutes(app: FastifyInstance): Promise { .set({ lastSeenAt: Date.now(), consecutiveFailures: 0, + ...(fedHeaders.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}), }) .where(eq(schema.federationPeers.id, peer.id)) .run(); @@ -537,10 +550,29 @@ export async function federationRoutes(app: FastifyInstance): Promise { } const bodyString = JSON.stringify(request.body); - if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp)) { + if (!verifySignature(bodyString, fedHeaders.signature, peer.hmacSecret, fedHeaders.timestamp, fedHeaders.nonce)) { return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 }); } + // 1b. Nonce-based replay protection + if (fedHeaders.nonce) { + if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) { + return reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 }); + } + } else if (peer.nonceSupported) { + return reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 }); + } else { + console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce) [sync]`); + } + + // Ratchet: mark peer as nonce-supporting if this is the first nonce we've seen + if (fedHeaders.nonce && !peer.nonceSupported) { + db.update(schema.federationPeers) + .set({ nonceSupported: 1 }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + } + // 2. Validate & normalize request body const body = request.body; if (!body || typeof body.sinceTimestamp !== 'number' || body.sinceTimestamp < 0) {