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.
This commit is contained in:
Jannis Braun
2026-03-31 18:12:16 +02:00
parent 2f9cce9d38
commit 5c5d41e462
+34 -2
View File
@@ -461,10 +461,22 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// 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<void> {
.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<void> {
}
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) {