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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user