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:
@@ -12,6 +12,7 @@ Source files:
|
||||
- `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/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/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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { getDb, schema } from '../../../db/index.js';
|
||||
import { parseFederationHeaders, verifyPeerSignature } from '../../../utils/federationAuth.js';
|
||||
import { and, eq, isNull, or } from 'drizzle-orm';
|
||||
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 {
|
||||
// ─── POST /api/federation/users/lookup ─────────────────────────────────────
|
||||
@@ -19,39 +19,14 @@ export function registerLookupRoutes(app: FastifyInstance): void {
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC (mirror relay endpoint)
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||
// signature → nonce replay. The per-peer lookup rate limiter (60/min) runs
|
||||
// BEFORE signature verification and sends `Retry-After: 60`. This endpoint
|
||||
// never logged on a missing nonce (logMissingNonce omitted).
|
||||
const auth = authenticateS2SPeer(request, reply, {
|
||||
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
||||
});
|
||||
if (!auth.ok) return;
|
||||
|
||||
// 2. Validate body
|
||||
const rawUsername = (request.body as { username?: unknown } | null)?.username;
|
||||
@@ -109,39 +84,14 @@ export function registerLookupRoutes(app: FastifyInstance): void {
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC headers
|
||||
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. Same shape as /users/lookup: per-peer lookup
|
||||
// rate limiter (60/min) BEFORE signature, `Retry-After: 60`, no
|
||||
// missing-nonce warning.
|
||||
const auth = authenticateS2SPeer(request, reply, {
|
||||
rateLimiter: { limited: isLookupRateLimited, retryAfterSeconds: 60 },
|
||||
});
|
||||
if (!auth.ok) return;
|
||||
|
||||
// 2. Validate body
|
||||
const rawId = (request.body as { homeUserId?: unknown } | null)?.homeUserId;
|
||||
|
||||
@@ -625,6 +625,9 @@ export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
|
||||
// ─── POST /api/federation/peer/rotate ───────────────────────────────────────
|
||||
// Server-to-server: accept a secret rotation request from a peer instance.
|
||||
// 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 } }>(
|
||||
'/api/federation/peer/rotate',
|
||||
async (request, reply) => {
|
||||
@@ -684,6 +687,10 @@ export function registerPeerHandshakeRoutes(app: FastifyInstance): void {
|
||||
// Server-to-server: receive a denial notification from a remote instance.
|
||||
// Authenticated via HMAC-SHA256 signature (the secret we sent in our original
|
||||
// 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 } }>(
|
||||
'/api/federation/peer/denied',
|
||||
async (request, reply) => {
|
||||
|
||||
@@ -15,7 +15,8 @@ import type { FastifyInstance } from 'fastify';
|
||||
import { processRelayEvents } from '../events/dispatch.js';
|
||||
import { extractDomain } from '../identity.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 {
|
||||
// ─── DELETE /api/federation/identity ──────────────────────────────────────
|
||||
@@ -26,37 +27,11 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC signature (same pattern as relay endpoint)
|
||||
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 });
|
||||
}
|
||||
|
||||
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)`);
|
||||
}
|
||||
// Shared inbound S2S-auth preamble: headers → active peer → signature →
|
||||
// nonce replay. No rate limiter; warns on a legacy peer's missing nonce.
|
||||
const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true });
|
||||
if (!auth.ok) return;
|
||||
const { peer } = auth;
|
||||
|
||||
// 2. Validate 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
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -86,7 +61,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
// homeUserIds. Idempotent 200: from the caller's perspective this
|
||||
// identity does not exist here.
|
||||
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 });
|
||||
}
|
||||
|
||||
@@ -130,7 +105,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
// 11. Force-disconnect WS if somehow still connected (unlikely but safe)
|
||||
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 });
|
||||
},
|
||||
@@ -145,41 +120,25 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
async (request, reply) => {
|
||||
const db = getDb();
|
||||
|
||||
// 1. Verify HMAC signature
|
||||
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 });
|
||||
}
|
||||
// Shared inbound S2S-auth preamble: headers → active peer → rate limit →
|
||||
// signature → nonce replay. The per-peer relay rate limiter runs BEFORE
|
||||
// signature verification (avoid HMAC work on a flood); warns on a legacy
|
||||
// peer's missing nonce.
|
||||
const auth = authenticateS2SPeer(request, reply, {
|
||||
rateLimiter: { limited: isRelayRateLimited },
|
||||
logMissingNonce: true,
|
||||
});
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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).
|
||||
// 1b-epoch. Fast-path baseline population (design §3.2). The signature the
|
||||
// preamble 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. Independent of
|
||||
// per-event processing; does not affect relay accept/reject in any way. Old
|
||||
// peers omit the field → skip (backward-compatible no-op).
|
||||
const claimedEpoch = request.body.sourceInstanceId;
|
||||
if (claimedEpoch && !peer.peerInstanceId) {
|
||||
db.update(schema.federationPeers)
|
||||
@@ -191,18 +150,6 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
.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
|
||||
const body = request.body;
|
||||
if (!body || body.version !== 1 || !Array.isArray(body.events)) {
|
||||
@@ -226,7 +173,7 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
.set({
|
||||
lastSeenAt: Date.now(),
|
||||
consecutiveFailures: 0,
|
||||
...(fedHeaders.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}),
|
||||
...(auth.nonce && !peer.nonceSupported ? { nonceSupported: 1 } : {}),
|
||||
})
|
||||
.where(eq(schema.federationPeers.id, peer.id))
|
||||
.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
|
||||
// (instanceId) is already public via /instance/info; signing is for
|
||||
// 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(
|
||||
'/api/federation/epoch',
|
||||
{ bodyLimit: 4 * 1024 },
|
||||
@@ -301,40 +254,15 @@ export function registerRelayRoutes(app: FastifyInstance): void {
|
||||
const db = getDb();
|
||||
const rawDb = getRawDb();
|
||||
|
||||
// 1. Verify HMAC signature
|
||||
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 });
|
||||
}
|
||||
|
||||
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]`);
|
||||
}
|
||||
// Shared inbound S2S-auth preamble: headers → active peer → signature →
|
||||
// nonce replay. No rate limiter; warns (with the ` [sync]` tag) on a legacy
|
||||
// peer's missing nonce.
|
||||
const auth = authenticateS2SPeer(request, reply, { logMissingNonce: true, logContext: 'sync' });
|
||||
if (!auth.ok) return;
|
||||
const { peer } = auth;
|
||||
|
||||
// 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)
|
||||
.set({ nonceSupported: 1 })
|
||||
.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 };
|
||||
}
|
||||
Reference in New Issue
Block a user