diff --git a/docs/systems/federation.md b/docs/systems/federation.md index 6345eba4..3a5667a5 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -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) diff --git a/packages/server/src/routes/federation/handlers/attach.ts b/packages/server/src/routes/federation/handlers/attach.ts index 8eb557d7..c67f0b3d 100644 --- a/packages/server/src/routes/federation/handlers/attach.ts +++ b/packages/server/src/routes/federation/handlers/attach.ts @@ -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); - 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. diff --git a/packages/server/src/routes/federation/handlers/lookup.ts b/packages/server/src/routes/federation/handlers/lookup.ts index 10fc5127..6188d36c 100644 --- a/packages/server/src/routes/federation/handlers/lookup.ts +++ b/packages/server/src/routes/federation/handlers/lookup.ts @@ -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); - 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); - 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; diff --git a/packages/server/src/routes/federation/handlers/peerHandshake.ts b/packages/server/src/routes/federation/handlers/peerHandshake.ts index 56818e4e..8c287035 100644 --- a/packages/server/src/routes/federation/handlers/peerHandshake.ts +++ b/packages/server/src/routes/federation/handlers/peerHandshake.ts @@ -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) => { diff --git a/packages/server/src/routes/federation/handlers/relay.ts b/packages/server/src/routes/federation/handlers/relay.ts index d093a0b5..bf297d98 100644 --- a/packages/server/src/routes/federation/handlers/relay.ts +++ b/packages/server/src/routes/federation/handlers/relay.ts @@ -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); - 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); - 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); - 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)) diff --git a/packages/server/src/routes/federation/handlers/s2sAuth.test.ts b/packages/server/src/routes/federation/handlers/s2sAuth.test.ts new file mode 100644 index 00000000..6b6e7b41 --- /dev/null +++ b/packages/server/src/routes/federation/handlers/s2sAuth.test.ts @@ -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>; +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(); + verifySpy.mockImplementation(actual.verifyPeerSignature); + return { + ...actual, + verifyPeerSignature: (...args: Parameters) => 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 { + 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 { + 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 { + 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, body: object = {}) { + return app.inject({ method: 'POST', url: '/probe', headers, payload: JSON.stringify(body) }); +} + +describe('authenticateS2SPeer', () => { + let warnSpy: ReturnType; + + 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); + }); +}); diff --git a/packages/server/src/routes/federation/handlers/s2sAuth.ts b/packages/server/src/routes/federation/handlers/s2sAuth.ts new file mode 100644 index 00000000..deebda3d --- /dev/null +++ b/packages/server/src/routes/federation/handlers/s2sAuth.ts @@ -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, + ); + 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 }; +}