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/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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user