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
+1
View File
@@ -12,6 +12,7 @@ Source files:
- `routes/federation/reconciliation.ts` -- DM federated-id reconciliation + dead-incarnation artifact sweeps (worker-facing maintenance) - `routes/federation/reconciliation.ts` -- DM federated-id reconciliation + dead-incarnation artifact sweeps (worker-facing maintenance)
- `routes/federation/events/*.ts` -- Inbound relay event processors, grouped by domain: `dmMessages`, `membership`, `friends`, `calls`, `dmState` (presence/read-state/close/reopen/file-rejected), and `dispatch` (`processRelayEvents`, the fan-out entry point shared by the HTTP relay handler and the initial-sync worker) - `routes/federation/events/*.ts` -- Inbound relay event processors, grouped by domain: `dmMessages`, `membership`, `friends`, `calls`, `dmState` (presence/read-state/close/reopen/file-rejected), and `dispatch` (`processRelayEvents`, the fan-out entry point shared by the HTTP relay handler and the initial-sync worker)
- `routes/federation/handlers/*.ts` -- Fastify route registrars, grouped by endpoint concern: `peerHandshake` (initiate/accept/ensure/rotate/denied), `peerAdmin` (peer list/CRUD/reset/recheck/rotate), `approvals` (approval queue + peering subscriptions/notifications + approve/deny helpers), `relay` (identity delete, relay, epoch, sync), `lookup` (user lookups), `attach` (verify-attach-proof, `/api/users/@me/reattach`) - `routes/federation/handlers/*.ts` -- Fastify route registrars, grouped by endpoint concern: `peerHandshake` (initiate/accept/ensure/rotate/denied), `peerAdmin` (peer list/CRUD/reset/recheck/rotate), `approvals` (approval queue + peering subscriptions/notifications + approve/deny helpers), `relay` (identity delete, relay, epoch, sync), `lookup` (user lookups), `attach` (verify-attach-proof, `/api/users/@me/reattach`)
- `routes/federation/handlers/s2sAuth.ts` -- `authenticateS2SPeer(request, reply, opts?)`: the shared inbound S2S-HMAC auth preamble (parse headers → resolve active peer → optional per-peer rate limit **before** signature → verify HMAC signature → nonce replay). Adopted by the six endpoints whose preamble is byte-identical: `DELETE /identity`, `POST /relay`, `POST /sync` (`relay.ts`), `POST /users/lookup`, `POST /users/by-home-id` (`lookup.ts`), and `POST /verify-attach-proof` (`attach.ts`). Returns `{ ok: true, peer, nonce }` or, having already sent the rejection reply, `{ ok: false }` (caller must `return`). **Intentional non-adopters** (each keeps a load-bearing gate the helper would flatten, documented in its own docstring/comment): `POST /epoch` (revoked-only gate for peer recovery, 400 on missing headers, no nonce check), `POST /peer/rotate` (active-only, no nonce check), `POST /peer/denied` (`awaiting_approval` gate, synthetic no-grace secret verify).
- `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()` - `packages/server/src/utils/federationAuth.ts` -- HMAC signing, verification, header parsing, `getOurOrigin()`
- `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution - `packages/server/src/utils/federationOutbox.ts` -- Event queuing, coalescing, relay payload construction, mutation log, participant/target resolution
- `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill) - `packages/server/src/utils/federationLookup.ts` -- HMAC-signed remote-user lookups: `lookupRemoteUser` (by username) and `lookupRemoteUserByHomeId` (reverse lookup, used by stub backfill)
@@ -3,7 +3,6 @@ import { config } from '../../../config.js';
import { getDb, getRawDb, schema } from '../../../db/index.js'; import { getDb, getRawDb, schema } from '../../../db/index.js';
import { authenticate } from '../../../utils/auth.js'; import { authenticate } from '../../../utils/auth.js';
import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js'; import { fetchHomeProfileByHomeId, verifyAttachProofWithPeer } from '../../../utils/federationAttach.js';
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
import { sendSignedJson } from './signedResponse.js'; import { sendSignedJson } from './signedResponse.js';
import { sanitizeUser } from '../../../utils/sanitize.js'; import { sanitizeUser } from '../../../utils/sanitize.js';
import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js'; import { collectProfileBroadcastTargetIds } from '../../../utils/userDeletion.js';
@@ -15,7 +14,8 @@ import type { FastifyInstance, FastifyReply } from 'fastify';
import { buildDmChannelPayload } from '../dmChannels.js'; import { buildDmChannelPayload } from '../dmChannels.js';
import { extractDomain } from '../identity.js'; import { extractDomain } from '../identity.js';
import { downloadProfileAsset } from '../profile.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 { reconcileDmChannelFederatedId } from '../reconciliation.js';
import type { DmReconcileResult } from '../reconciliation.js'; import type { DmReconcileResult } from '../reconciliation.js';
@@ -37,39 +37,15 @@ export function registerAttachRoutes(app: FastifyInstance): void {
const db = getDb(); const db = getDb();
const rawDb = getRawDb(); const rawDb = getRawDb();
// 1. Verify HMAC headers (mirror by-home-id / users-lookup). // Shared inbound S2S-auth preamble: headers → active peer → rate limit →
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // signature → nonce replay. Shares the lookup rate-limit bucket (60/min) by
if (!fedHeaders) { // design (this is the same friend-request-originator flow as /users/lookup),
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); // running BEFORE signature with `Retry-After: 60`; no missing-nonce warning.
} const auth = authenticateS2SPeer(request, reply, {
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
const peer = db });
.select() if (!auth.ok) return;
.from(schema.federationPeers) const { peer } = auth;
.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 });
}
// 2. Sign every downstream response with the peer's shared secret so the // 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. // caller can trust the identity (or the fail-closed verdict) it carries.
@@ -1,8 +1,8 @@
import { getDb, schema } from '../../../db/index.js'; import { getDb, schema } from '../../../db/index.js';
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
import { and, eq, isNull, or } from 'drizzle-orm'; import { and, eq, isNull, or } from 'drizzle-orm';
import type { FastifyInstance } from 'fastify'; import type { FastifyInstance } from 'fastify';
import { isLookupRateLimited, isNonceDuplicate } from '../rateLimits.js'; import { isLookupRateLimited } from '../rateLimits.js';
import { authenticateS2SPeer } from './s2sAuth.js';
export function registerLookupRoutes(app: FastifyInstance): void { export function registerLookupRoutes(app: FastifyInstance): void {
// ─── POST /api/federation/users/lookup ───────────────────────────────────── // ─── POST /api/federation/users/lookup ─────────────────────────────────────
@@ -19,39 +19,14 @@ export function registerLookupRoutes(app: FastifyInstance): void {
async (request, reply) => { async (request, reply) => {
const db = getDb(); const db = getDb();
// 1. Verify HMAC (mirror relay endpoint) // Shared inbound S2S-auth preamble: headers → active peer → rate limit →
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // signature → nonce replay. The per-peer lookup rate limiter (60/min) runs
if (!fedHeaders) { // BEFORE signature verification and sends `Retry-After: 60`. This endpoint
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); // never logged on a missing nonce (logMissingNonce omitted).
} const auth = authenticateS2SPeer(request, reply, {
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
const peer = db });
.select() if (!auth.ok) return;
.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 });
}
// 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 });
}
// 2. Validate body // 2. Validate body
const rawUsername = (request.body as { username?: unknown } | null)?.username; const rawUsername = (request.body as { username?: unknown } | null)?.username;
@@ -109,39 +84,14 @@ export function registerLookupRoutes(app: FastifyInstance): void {
async (request, reply) => { async (request, reply) => {
const db = getDb(); const db = getDb();
// 1. Verify HMAC headers // Shared inbound S2S-auth preamble: headers → active peer → rate limit →
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // signature → nonce replay. Same shape as /users/lookup: per-peer lookup
if (!fedHeaders) { // rate limiter (60/min) BEFORE signature, `Retry-After: 60`, no
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); // missing-nonce warning.
} const auth = authenticateS2SPeer(request, reply, {
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
const peer = db });
.select() if (!auth.ok) return;
.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 });
}
// 2. Validate body // 2. Validate body
const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId; const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId;
@@ -625,6 +625,9 @@ export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
// ─── POST /api/federation/peer/rotate ─────────────────────────────────────── // ─── POST /api/federation/peer/rotate ───────────────────────────────────────
// Server-to-server: accept a secret rotation request from a peer instance. // Server-to-server: accept a secret rotation request from a peer instance.
// Authenticated via HMAC-SHA256 signature (current secret), NOT JWT. // Authenticated via HMAC-SHA256 signature (current secret), NOT JWT.
// NON-ADOPTER of authenticateS2SPeer (deliberate): active-only like the helper
// but runs NO nonce replay check (the rotation body is its own replay unit);
// sharing the helper would add a nonce gate this endpoint never had.
app.post<{ Body: { newSecret: string } }>( app.post<{ Body: { newSecret: string } }>(
'/api/federation/peer/rotate', '/api/federation/peer/rotate',
async (request, reply) => { async (request, reply) => {
@@ -684,6 +687,10 @@ export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
// Server-to-server: receive a denial notification from a remote instance. // Server-to-server: receive a denial notification from a remote instance.
// Authenticated via HMAC-SHA256 signature (the secret we sent in our original // Authenticated via HMAC-SHA256 signature (the secret we sent in our original
// peer/accept request, which the remote stored in their approval queue). // peer/accept request, which the remote stored in their approval queue).
// NON-ADOPTER of authenticateS2SPeer (deliberate): gates on 'awaiting_approval'
// (404 on no peer row, 409 on wrong status — not the helper's active-only 403),
// verifies against a SYNTHETIC no-grace secret object, and runs no nonce check.
// Entirely different control flow.
app.post<{ Body: { origin: string; reason: 'denied_by_admin' | 'expired'; message?: string } }>( app.post<{ Body: { origin: string; reason: 'denied_by_admin' | 'expired'; message?: string } }>(
'/api/federation/peer/denied', '/api/federation/peer/denied',
async (request, reply) => { async (request, reply) => {
@@ -15,7 +15,8 @@ import type { FastifyInstance } from 'fastify';
import { processRelayEvents } from '../events/dispatch.js'; import { processRelayEvents } from '../events/dispatch.js';
import { extractDomain } from '../identity.js'; import { extractDomain } from '../identity.js';
import { resolveLocalOrigin } from '../origin.js'; import { resolveLocalOrigin } from '../origin.js';
import { isNonceDuplicate, isRelayRateLimited } from '../rateLimits.js'; import { isRelayRateLimited } from '../rateLimits.js';
import { authenticateS2SPeer } from './s2sAuth.js';
export function registerRelayRoutes(app: FastifyInstance): void { export function registerRelayRoutes(app: FastifyInstance): void {
// ─── DELETE /api/federation/identity ────────────────────────────────────── // ─── DELETE /api/federation/identity ──────────────────────────────────────
@@ -26,37 +27,11 @@ export function registerRelayRoutes(app: FastifyInstance): void {
async (request, reply) => { async (request, reply) => {
const db = getDb(); const db = getDb();
// 1. Verify HMAC signature (same pattern as relay endpoint) // Shared inbound S2S-auth preamble: headers → active peer → signature →
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // nonce replay. No rate limiter; warns on a legacy peer's missing nonce.
if (!fedHeaders) { const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true });
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); if (!auth.ok) return;
} const { peer } = auth;
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 });
}
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 });
}
// 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)`);
}
// 2. Validate body // 2. Validate body
const { homeUserId, homeInstance, mode } = request.body; const { homeUserId, homeInstance, mode } = request.body;
@@ -77,7 +52,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
} }
// 4. Attribution guard: only the user's home instance can delete them // 4. Attribution guard: only the user's home instance can delete them
if (!user.homeInstance || extractDomain(user.homeInstance) !== extractDomain(fedHeaders.origin)) { if (!user.homeInstance || extractDomain(user.homeInstance) !== extractDomain(peer.origin)) {
return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 }); return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 });
} }
@@ -86,7 +61,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
// homeUserIds. Idempotent 200: from the caller's perspective this // homeUserIds. Idempotent 200: from the caller's perspective this
// identity does not exist here. // identity does not exist here.
if (user.federationHomeOrphaned === 1) { if (user.federationHomeOrphaned === 1) {
console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${fedHeaders.origin}`); console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${peer.origin}`);
return reply.code(200).send({ success: true }); return reply.code(200).send({ success: true });
} }
@@ -130,7 +105,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
// 11. Force-disconnect WS if somehow still connected (unlikely but safe) // 11. Force-disconnect WS if somehow still connected (unlikely but safe)
connectionManager.forceDisconnectUser(user.id); connectionManager.forceDisconnectUser(user.id);
console.log(`[federation] Identity deleted for user ${user.id} (${user.username}) via S2S from ${fedHeaders.origin}, mode=${mode}`); console.log(`[federation] Identity deleted for user ${user.id} (${user.username}) via S2S from ${peer.origin}, mode=${mode}`);
return reply.code(200).send({ success: true }); return reply.code(200).send({ success: true });
}, },
@@ -145,41 +120,25 @@ export function registerRelayRoutes(app: FastifyInstance): void {
async (request, reply) => { async (request, reply) => {
const db = getDb(); const db = getDb();
// 1. Verify HMAC signature // Shared inbound S2S-auth preamble: headers → active peer → rate limit →
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // signature → nonce replay. The per-peer relay rate limiter runs BEFORE
if (!fedHeaders) { // signature verification (avoid HMAC work on a flood); warns on a legacy
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); // peer's missing nonce.
} const auth = authenticateS2SPeer(request, reply, {
rateLimiter: { limited: isRelayRateLimited },
logMissingNonce: true,
});
if (!auth.ok) return;
const { peer } = auth;
const peer = db // 1b-epoch. Fast-path baseline population (design §3.2). The signature the
.select() // preamble verified proves the peer holds the current shared secret, so the
.from(schema.federationPeers) // epoch it carries in `sourceInstanceId` is authentic. Populate-if-null
.where(eq(schema.federationPeers.origin, fedHeaders.origin)) // ONLY: a valid relay can never carry an epoch differing from a non-null
.get(); // baseline (a different incarnation implies a different secret that fails
// HMAC), so we only ever fill a NULL — never overwrite. Independent of
if (!peer || peer.status !== 'active') { // per-event processing; does not affect relay accept/reject in any way. Old
return reply.code(403).send({ error: 'Unknown or inactive peer', statusCode: 403 }); // peers omit the field → skip (backward-compatible no-op).
}
// 1b. Per-peer rate limiting (before expensive HMAC verification)
if (isRelayRateLimited(peer.origin)) {
return reply.code(429).send({ error: 'Rate limit exceeded', statusCode: 429 });
}
// Serialize body back to JSON for HMAC verification (we control both sides)
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 });
}
// 1b-epoch. Fast-path baseline population (design §3.2). The signature just
// verified proves the peer holds the current shared secret, so the epoch it
// carries in `sourceInstanceId` is authentic. Populate-if-null ONLY: a valid
// relay can never carry an epoch differing from a non-null baseline (a
// different incarnation implies a different secret that fails HMAC), so we
// only ever fill a NULL — never overwrite. This is independent of per-event
// processing and does not affect relay accept/reject in any way. Old peers
// omit the field → skip (backward-compatible no-op).
const claimedEpoch = request.body.sourceInstanceId; const claimedEpoch = request.body.sourceInstanceId;
if (claimedEpoch && !peer.peerInstanceId) { if (claimedEpoch && !peer.peerInstanceId) {
db.update(schema.federationPeers) db.update(schema.federationPeers)
@@ -191,18 +150,6 @@ export function registerRelayRoutes(app: FastifyInstance): void {
.run(); .run();
} }
// 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 // 2. Validate request body shape
const body = request.body; const body = request.body;
if (!body || body.version !== 1 || !Array.isArray(body.events)) { if (!body || body.version !== 1 || !Array.isArray(body.events)) {
@@ -226,7 +173,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
.set({ .set({
lastSeenAt: Date.now(), lastSeenAt: Date.now(),
consecutiveFailures: 0, consecutiveFailures: 0,
...(fedHeaders.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}), ...(auth.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}),
}) })
.where(eq(schema.federationPeers.id, peer.id)) .where(eq(schema.federationPeers.id, peer.id))
.run(); .run();
@@ -257,6 +204,12 @@ export function registerRelayRoutes(app: FastifyInstance): void {
// writing it as the peer's baseline (design §3.2 / §9). The value itself // writing it as the peer's baseline (design §3.2 / §9). The value itself
// (instanceId) is already public via /instance/info; signing is for // (instanceId) is already public via /instance/info; signing is for
// baseline-integrity, not confidentiality. // baseline-integrity, not confidentiality.
//
// NON-ADOPTER of authenticateS2SPeer (deliberate): gates on status !== 'revoked'
// (ANY non-revoked peer must answer so a needs_attention/unreachable peer can
// drive RECOVERY via this signed round-trip), returns 400 (not 401) on missing
// headers, and runs NO nonce check. Folding it into the helper would flatten the
// recovery gate and the status code.
app.post( app.post(
'/api/federation/epoch', '/api/federation/epoch',
{ bodyLimit: 4 * 1024 }, { bodyLimit: 4 * 1024 },
@@ -301,40 +254,15 @@ export function registerRelayRoutes(app: FastifyInstance): void {
const db = getDb(); const db = getDb();
const rawDb = getRawDb(); const rawDb = getRawDb();
// 1. Verify HMAC signature // Shared inbound S2S-auth preamble: headers → active peer → signature
const fedHeaders = parseFederationHeaders(request.headers as Record<string, string | string[] | undefined>); // nonce replay. No rate limiter; warns (with the ` [sync]` tag) on a legacy
if (!fedHeaders) { // peer's missing nonce.
return reply.code(401).send({ error: 'Missing or malformed federation headers', statusCode: 401 }); const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true, logContext: 'sync' });
} if (!auth.ok) return;
const { peer } = auth;
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 });
}
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 });
}
// 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 // Ratchet: mark peer as nonce-supporting if this is the first nonce we've seen
if (fedHeaders.nonce && !peer.nonceSupported) { if (auth.nonce && !peer.nonceSupported) {
db.update(schema.federationPeers) db.update(schema.federationPeers)
.set({ nonceSupported: 1 }) .set({ nonceSupported: 1 })
.where(eq(schema.federationPeers.id, peer.id)) .where(eq(schema.federationPeers.id, peer.id))
@@ -0,0 +1,275 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { randomUUID } from 'node:crypto';
import * as schema from '../../../db/schema.js';
import { setWorkerId } from '../../../utils/snowflake.js';
import { signRequest } from '../../../utils/federationAuth.js';
import type { S2SAuthOptions } from './s2sAuth.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Module-level mutable state. Each beforeEach reassigns sqlite/testDb;
// the getDb getter in the mock closes over the current binding.
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
const PEER_ORIGIN = 'https://orbit.test';
const PEER_SECRET = 'a'.repeat(64);
vi.mock('../../../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
// Wrap verifyPeerSignature in a passthrough spy so ordering (rate-limit BEFORE
// signature) can be asserted by call count while real HMAC verification still runs.
const { verifySpy } = vi.hoisted(() => ({ verifySpy: vi.fn() }));
vi.mock('../../../utils/federationAuth.js', async (importActual) => {
const actual = await importActual<typeof import('../../../utils/federationAuth.js')>();
verifySpy.mockImplementation(actual.verifyPeerSignature);
return {
...actual,
verifyPeerSignature: (...args: Parameters<typeof actual.verifyPeerSignature>) => verifySpy(...args),
};
});
function applyMigrations(db: Database.Database): void {
const dir = path.resolve(__dirname, '../../../../drizzle');
for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) {
const sqlText = fs.readFileSync(path.join(dir, f), 'utf8');
for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
function seedPeer(status = 'active', nonceSupported = 0): void {
testDb.insert(schema.federationPeers).values({
id: 'peer-1',
origin: PEER_ORIGIN,
hmacSecret: PEER_SECRET,
status,
nonceSupported,
createdAt: Date.now(),
lastSeenAt: Date.now(),
consecutiveFailures: 0,
consecutiveAuthFailures: 0,
} as typeof schema.federationPeers.$inferInsert).run();
}
// Build a tiny app whose sole route drives authenticateS2SPeer and echoes the
// result. `authOpts` is injected verbatim so the rate-limiter (an injectable
// plain object) and log flags can be controlled per test.
async function buildApp(authOpts: S2SAuthOptions = {}): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { authenticateS2SPeer } = await import('./s2sAuth.js');
app.post('/probe', async (request, reply) => {
const result = authenticateS2SPeer(request, reply, authOpts);
if (!result.ok) return; // a reply was already sent
return reply.code(200).send({
ok: true,
peerOrigin: result.peer.origin,
nonce: result.nonce,
});
});
await app.ready();
return app;
}
/** Signed headers WITH a nonce (default valid path). */
function signedHeaders(body: string, nonce: string = randomUUID()): Record<string, string> {
const timestamp = Date.now();
const sig = signRequest(body, PEER_SECRET, timestamp, nonce);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Nonce': nonce,
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
/** Signed headers WITHOUT a nonce (legacy peer form: sign `${ts}.${body}`). */
function signedHeadersNoNonce(body: string): Record<string, string> {
const timestamp = Date.now();
const sig = signRequest(body, PEER_SECRET, timestamp, null);
return {
'X-Federation-Origin': PEER_ORIGIN,
'X-Federation-Timestamp': String(timestamp),
'X-Federation-Signature': `sha256=${sig}`,
'Content-Type': 'application/json',
};
}
async function probe(app: FastifyInstance, headers: Record<string, string>, body: object = {}) {
return app.inject({ method: 'POST', url: '/probe', headers, payload: JSON.stringify(body) });
}
describe('authenticateS2SPeer', () => {
let warnSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
verifySpy.mockClear();
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warnSpy.mockRestore();
sqlite.close();
});
it('missing/invalid federation headers → 401 (helper never emits /epoch\'s 400)', async () => {
seedPeer('active');
const app = await buildApp();
const res = await probe(app, { 'Content-Type': 'application/json' }, { hello: 'world' });
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toEqual({ error: 'Missing or malformed federation headers', statusCode: 401 });
// Guard against the non-adopter /epoch's 400: the shared helper is 401-only here.
expect(res.statusCode).not.toBe(400);
});
it('peer not found → 403 with exact body', async () => {
// No peer seeded.
const app = await buildApp();
const body = { hello: 'world' };
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toEqual({ error: 'Unknown or inactive peer', statusCode: 403 });
});
it('peer present but non-active status → 403 with exact body', async () => {
seedPeer('needs_attention');
const app = await buildApp();
const body = { hello: 'world' };
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toEqual({ error: 'Unknown or inactive peer', statusCode: 403 });
});
it('rate-limited WITH retryAfterSeconds → 429 + Retry-After header', async () => {
seedPeer('active');
const app = await buildApp({ rateLimiter: { limited: () => true, retryAfterSeconds: 60 } });
const body = { hello: 'world' };
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
expect(res.statusCode).toBe(429);
expect(res.headers['retry-after']).toBe('60');
expect(JSON.parse(res.body)).toEqual({ error: 'Rate limit exceeded', statusCode: 429 });
});
it('rate-limited WITHOUT retryAfterSeconds → 429, no Retry-After header', async () => {
seedPeer('active');
const app = await buildApp({ rateLimiter: { limited: () => true } });
const body = { hello: 'world' };
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
expect(res.statusCode).toBe(429);
expect(res.headers['retry-after']).toBeUndefined();
expect(JSON.parse(res.body)).toEqual({ error: 'Rate limit exceeded', statusCode: 429 });
});
it('rate-limit fires BEFORE signature verification (verifyPeerSignature not reached)', async () => {
seedPeer('active');
const app = await buildApp({ rateLimiter: { limited: () => true, retryAfterSeconds: 60 } });
// Deliberately BAD signature: if signature ran first we would see 401, not 429.
const headers = signedHeaders(JSON.stringify({ hello: 'world' }));
headers['X-Federation-Signature'] = 'sha256=' + 'f'.repeat(64);
const res = await probe(app, headers, { hello: 'world' });
expect(res.statusCode).toBe(429);
expect(verifySpy).not.toHaveBeenCalled();
});
it('bad signature → 401 with exact body', async () => {
seedPeer('active');
const app = await buildApp();
const headers = signedHeaders(JSON.stringify({ hello: 'world' }));
headers['X-Federation-Signature'] = 'sha256=' + 'f'.repeat(64);
const res = await probe(app, headers, { hello: 'world' });
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toEqual({ error: 'Invalid signature', statusCode: 401 });
});
it('duplicate nonce → 409 with exact body', async () => {
seedPeer('active');
const app = await buildApp();
const body = { hello: 'world' };
const nonce = 'dup-nonce-fixed-1';
// First request records the nonce and passes.
const first = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
expect(first.statusCode).toBe(200);
// Second request with the SAME nonce is a replay.
const second = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
expect(second.statusCode).toBe(409);
expect(JSON.parse(second.body)).toEqual({ error: 'Duplicate nonce — possible replay', statusCode: 409 });
});
it('nonce missing + peer SUPPORTS nonce → 401 with exact body', async () => {
seedPeer('active', 1); // nonceSupported = 1
const app = await buildApp();
const body = { hello: 'world' };
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
expect(res.statusCode).toBe(401);
expect(JSON.parse(res.body)).toEqual({ error: 'Nonce required — peer previously supported nonces', statusCode: 401 });
});
it('nonce missing + peer does NOT support nonce → passes; logMissingNonce=true warns', async () => {
seedPeer('active', 0);
const app = await buildApp({ logMissingNonce: true });
const body = { hello: 'world' };
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ ok: true, peerOrigin: PEER_ORIGIN, nonce: null });
expect(warnSpy).toHaveBeenCalledWith(
`[federation] Peer ${PEER_ORIGIN} does not support replay protection (no nonce)`,
);
});
it('nonce missing + peer does NOT support nonce → passes; logMissingNonce=false stays silent', async () => {
seedPeer('active', 0);
const app = await buildApp({ logMissingNonce: false });
const body = { hello: 'world' };
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
expect(res.statusCode).toBe(200);
expect(warnSpy).not.toHaveBeenCalled();
});
it('logContext appends the endpoint suffix to the missing-nonce warn (sync parity)', async () => {
seedPeer('active', 0);
const app = await buildApp({ logMissingNonce: true, logContext: 'sync' });
const body = { hello: 'world' };
const res = await probe(app, signedHeadersNoNonce(JSON.stringify(body)), body);
expect(res.statusCode).toBe(200);
expect(warnSpy).toHaveBeenCalledWith(
`[federation] Peer ${PEER_ORIGIN} does not support replay protection (no nonce) [sync]`,
);
});
it('success → { ok:true, peer, nonce } with the parsed nonce', async () => {
seedPeer('active');
const app = await buildApp();
const body = { hello: 'world' };
const nonce = 'success-nonce-1';
const res = await probe(app, signedHeaders(JSON.stringify(body), nonce), body);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual({ ok: true, peerOrigin: PEER_ORIGIN, nonce });
});
it('success with a rate-limiter that is under the cap → passes through', async () => {
seedPeer('active');
const app = await buildApp({ rateLimiter: { limited: () => false, retryAfterSeconds: 60 } });
const body = { hello: 'world' };
const res = await probe(app, signedHeaders(JSON.stringify(body)), body);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).ok).toBe(true);
});
});
@@ -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 };
}