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:
@@ -0,0 +1,138 @@
|
||||
import { eq } from 'drizzle-orm';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { isNonceDuplicate } from '../rateLimits.js';
|
||||
|
||||
/** A `federation_peers` row, as returned by a `select().from(...).get()`. */
|
||||
type FederationPeerRow = typeof schema.federationPeers.$inferSelect;
|
||||
|
||||
/**
|
||||
* A rate limiter for the auth preamble. `limited(key)` returns true once the key
|
||||
* (always `peer.origin` here) is at capacity. When `retryAfterSeconds` is set, a
|
||||
* `Retry-After` header carrying that value is added to the 429 response.
|
||||
*/
|
||||
export interface S2SRateLimiter {
|
||||
limited: (key: string) => boolean;
|
||||
retryAfterSeconds?: number;
|
||||
}
|
||||
|
||||
export interface S2SAuthOptions {
|
||||
/** Run this limiter (keyed on `peer.origin`) BEFORE signature verification. */
|
||||
rateLimiter?: S2SRateLimiter;
|
||||
/**
|
||||
* When a request omits a nonce AND the peer has never advertised nonce
|
||||
* support, emit the legacy `console.warn`. Endpoints that historically logged
|
||||
* this pass `true`; those that stayed silent pass `false`/omit.
|
||||
*/
|
||||
logMissingNonce?: boolean;
|
||||
/**
|
||||
* Optional suffix for the missing-nonce warning, appended as ` [${logContext}]`.
|
||||
* Preserves the per-endpoint log tag (`/sync` logged a ` [sync]` suffix; the
|
||||
* `/identity` and `/relay` handlers logged no suffix).
|
||||
*/
|
||||
logContext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of {@link authenticateS2SPeer}. On `ok: false` a reply has ALREADY been
|
||||
* sent — the caller MUST `return` immediately without touching `reply` again.
|
||||
*/
|
||||
export type S2SAuthResult =
|
||||
| { ok: true; peer: FederationPeerRow; nonce: string | null }
|
||||
| { ok: false };
|
||||
|
||||
/**
|
||||
* Shared inbound S2S-auth preamble for HMAC-signed federation endpoints.
|
||||
*
|
||||
* Runs, IN THIS EXACT ORDER, the boilerplate that six endpoints share verbatim:
|
||||
* 1. Parse federation headers — missing/malformed → 401.
|
||||
* 2. Resolve the peer by origin; require `status === 'active'` → else 403.
|
||||
* 3. (optional) Rate-limit on `peer.origin` — 429 (+ `Retry-After` when
|
||||
* configured). Deliberately BEFORE signature verification so a flooded peer
|
||||
* never costs an HMAC computation.
|
||||
* 4. Verify the HMAC signature (honours rotation grace) → 401 on failure.
|
||||
* 5. Nonce replay protection: present + duplicate → 409; absent while the peer
|
||||
* advertises nonce support → 401; absent otherwise → pass (optionally warn).
|
||||
*
|
||||
* On success returns `{ ok: true, peer, nonce }`; the caller resumes with its
|
||||
* own body validation and side effects. On any rejection the reply is sent and
|
||||
* `{ ok: false }` is returned — the caller must `return` at once.
|
||||
*
|
||||
* ── INTENTIONAL NON-ADOPTERS (do NOT fold these into this helper) ─────────────
|
||||
* Three S2S endpoints deliberately keep bespoke auth because a load-bearing gate
|
||||
* differs; sharing this helper would silently flatten it:
|
||||
* • `POST /api/federation/epoch` — gates on `status !== 'revoked'` (ANY
|
||||
* non-revoked peer answers, so a needs_attention/unreachable peer can drive
|
||||
* RECOVERY via the signed epoch round-trip), returns **400** (not 401) on
|
||||
* missing headers, and runs **no** nonce check.
|
||||
* • `POST /api/federation/peer/rotate` — active-only but runs **no** nonce
|
||||
* check (a lone shape; the rotation body is the replay unit).
|
||||
* • `POST /api/federation/peer/denied` — gates on `awaiting_approval` (404 on
|
||||
* no peer row, 409 on wrong status) and verifies against a SYNTHETIC
|
||||
* no-grace secret object; entirely different control flow.
|
||||
* Also out of scope: `/peer/accept`, `/peer/initiate`, `/peer/ensure`
|
||||
* (first-contact / JWT, not S2S-HMAC).
|
||||
*/
|
||||
export function authenticateS2SPeer(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
opts: S2SAuthOptions = {},
|
||||
): S2SAuthResult {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Parse and require federation headers.
|
||||
const fedHeaders = parseFederationHeaders(
|
||||
request.headers as Record<string, string | string[] | undefined>,
|
||||
);
|
||||
if (!fedHeaders) {
|
||||
reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// 2. Resolve the peer by origin; require an active relationship.
|
||||
const peer = db
|
||||
.select()
|
||||
.from(schema.federationPeers)
|
||||
.where(eq(schema.federationPeers.origin, fedHeaders.origin))
|
||||
.get();
|
||||
|
||||
if (!peer || peer.status !== 'active') {
|
||||
reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// 3. Rate-limit BEFORE signature verification (avoid HMAC work on a flood).
|
||||
if (opts.rateLimiter && opts.rateLimiter.limited(peer.origin)) {
|
||||
reply.code(429);
|
||||
if (opts.rateLimiter.retryAfterSeconds !== undefined) {
|
||||
reply.header('Retry-After', String(opts.rateLimiter.retryAfterSeconds));
|
||||
}
|
||||
reply.send({ error: 'Rate limit exceeded', statusCode: 429 });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// 4. Verify the HMAC signature over the exact serialized body.
|
||||
const bodyString = JSON.stringify(request.body);
|
||||
if (!verifyPeerSignature(bodyString, fedHeaders.signature, fedHeaders.timestamp, fedHeaders.nonce, peer)) {
|
||||
reply.code(401).send({ error: 'Invalid signature', statusCode: 401 });
|
||||
return { ok: false };
|
||||
}
|
||||
|
||||
// 5. Nonce-based replay protection.
|
||||
if (fedHeaders.nonce) {
|
||||
if (isNonceDuplicate(peer.origin, fedHeaders.nonce)) {
|
||||
reply.code(409).send({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
|
||||
return { ok: false };
|
||||
}
|
||||
} else if (peer.nonceSupported) {
|
||||
// Peer previously proved nonce support but this request omits one — reject.
|
||||
reply.code(401).send({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
|
||||
return { ok: false };
|
||||
} else if (opts.logMissingNonce) {
|
||||
const suffix = opts.logContext ? ` [${opts.logContext}]` : '';
|
||||
console.warn(`[federation] Peer ${peer.origin} does not support replay protection (no nonce)${suffix}`);
|
||||
}
|
||||
|
||||
return { ok: true, peer, nonce: fedHeaders.nonce };
|
||||
}
|
||||
Reference in New Issue
Block a user