refactor(federation): consolidate inbound S2S-auth preamble into one helper (#11)

Six S2S-HMAC endpoints repeated the same inbound-auth preamble verbatim
(parse federation headers -> resolve active peer -> optional per-peer rate
limit -> verify HMAC signature -> nonce replay protection). Extract it into
authenticateS2SPeer() so the trust boundary has a single, tested definition.

Adopters (preamble only; every post-auth side effect, body validation, and
response is unchanged):
- DELETE /api/federation/identity      (no rate limiter; warns on missing nonce)
- POST   /api/federation/relay         (relay limiter; warns; keeps in-handler
                                        epoch-baseline populate + nonce ratchet)
- POST   /api/federation/sync          (no limiter; warns with the [sync] tag;
                                        keeps in-handler nonce ratchet)
- POST   /api/federation/users/lookup       (lookup limiter, Retry-After 60)
- POST   /api/federation/users/by-home-id   (same)
- POST   /api/federation/verify-attach-proof(shares lookup bucket, Retry-After 60)

Deliberate non-adopters, each keeping a load-bearing gate the helper would
flatten (documented at each site + in the helper docstring):
- POST /api/federation/epoch        gates status != 'revoked' (peer recovery),
                                    400 on missing headers, no nonce check
- POST /api/federation/peer/rotate  active-only but no nonce check
- POST /api/federation/peer/denied  awaiting_approval gate (404/409), synthetic
                                    no-grace secret verify

Behavior-preserving. The rate limiter is injected (plain { limited, retryAfter }),
so the limit still fires BEFORE signature verification. The only ordering change:
/relay's opportunistic epoch-baseline populate now runs just after the shared
preamble (i.e. after the nonce check) instead of between signature and nonce.
This is provably equivalent for every reachable honest-peer state (a duplicate
nonce means the baseline is already non-null; a valid-signature-but-no-nonce
request from a nonce-supporting peer is unreachable in transit and carries no
security/correctness consequence) and the populate is documented as not
affecting relay accept/reject.

Adds a dedicated unit test covering the full decision table (headers, peer
status, rate-limit + Retry-After, rate-limit-before-signature ordering,
signature, nonce duplicate/missing, log flag + context suffix, success). Full
server suite green (804 tests).
This commit is contained in:
TheZwiss
2026-07-10 03:08:09 +02:00
committed by GitHub
parent c79bf91398
commit d76e06a023
7 changed files with 492 additions and 217 deletions
@@ -3,7 +3,6 @@ import { config } from '../../../config.js';
import { getDb, getRawDb, schema } from '../../../db/index.js';
import { authenticate } from '../../../utils/auth.js';
import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js';
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
import { sendSignedJson } from './signedResponse.js';
import { sanitizeUser } from '../../../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js';
@@ -15,7 +14,8 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { buildDmChannelPayload } from '../dmChannels.js';
import { extractDomain } from '../identity.js';
import { downloadProfileAsset } from '../profile.js';
import { isLookupRateLimited, isNonceDuplicate } from '../rateLimits.js';
import { isLookupRateLimited } from '../rateLimits.js';
import { authenticateS2SPeer } from './s2sAuth.js';
import { reconcileDmChannelFederatedId } from '../reconciliation.js';
import type { DmReconcileResult } from '../reconciliation.js';
@@ -37,39 +37,15 @@ export function registerAttachRoutes(app: FastifyInstance): void {
const db = getDb();
const rawDb = getRawDb();
// 1. Verify HMAC headers (mirror by-home-id / users-lookup).
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>);
if (!fedHeaders) {
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
}
const peer = db
.select()
.from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
.get();
if (!peer || peer.status !== 'active') {
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
}
if (isLookupRateLimited(peer.origin)) {
return reply.code(429).header('Retry-After', '60').send({ error: 'Rate limit exceeded', statusCode: 429 });
}
const bodyString = JSON.stringify(request.body);
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
return reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
}
// 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 });
}
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
// signature → nonce replay. Shares the lookup rate-limit bucket (60/min) by
// design (this is the same friend-request-originator flow as /users/lookup),
// running BEFORE signature with `Retry-After: 60`; no missing-nonce warning.
const auth = authenticateS2SPeer(request, reply, {
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
});
if (!auth.ok) return;
const { peer } = auth;
// 2. Sign every downstream response with the peer's shared secret so the
// caller can trust the identity (or the fail-closed verdict) it carries.