diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 54591cea..176e4304 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2753,6 +2753,116 @@ export async function federationRoutes(app: FastifyInstance): Promise { }, ); + // ─── POST /api/federation/verify-attach-proof ─────────────────────────────── + // Server-to-server: verify a one-time attach-proof token minted by + // /api/auth/attach-proof (re-attach spec §3.1). The token is single-use (an + // atomic claim guarantees only one concurrent verification can win) and is + // bound to the CALLING peer's domain — the binding is checked against the + // authenticated peer row (extractDomain(peer.origin)), NEVER trusted from the + // request body. This is the anti-replay control: a compromised requester + // cannot redeem a token minted for a different peer. The response is HMAC- + // signed (epoch pattern) so the caller can trust the identity it carries; all + // failure modes fail closed to a signed { valid: false }. + app.post<{ Body: { token?: unknown } }>( + '/api/federation/verify-attach-proof', + { bodyLimit: 4 * 1024 }, + async (request, reply) => { + 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 }); + } + + // 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. + const sendSigned = (payload: { valid: false } | { valid: true; homeUserId: string; username: string }): FastifyReply => { + const responseBody = JSON.stringify(payload); + const sigHeaders = buildFederationHeaders(responseBody, peer.hmacSecret, getOurOrigin()); + reply.headers({ + 'X-Federation-Signature': sigHeaders['X-Federation-Signature'], + 'X-Federation-Timestamp': sigHeaders['X-Federation-Timestamp'], + 'X-Federation-Nonce': sigHeaders['X-Federation-Nonce'], + 'Content-Type': 'application/json', + }); + return reply.code(200).send(responseBody); + }; + + // 3. Validate the token shape (64 hex chars, as minted by attach-proof). + const rawToken = (request.body as { token?: unknown } | null)?.token; + if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) { + return sendSigned({ valid: false }); + } + + // 4. Atomic single-use claim. The domain binding is server-side: the + // token's target_domain must equal the AUTHENTICATED peer's domain, never + // a value from the request body. Concurrent verifications cannot both win + // because only the first UPDATE that flips used_at from NULL matches. + const peerDomain = extractDomain(peer.origin).toLowerCase(); + const now = Date.now(); + const claimed = rawDb.prepare(` + UPDATE federation_attach_proofs + SET used_at = ? + WHERE token = ? AND used_at IS NULL AND expires_at > ? AND lower(target_domain) = ? + RETURNING home_user_id + `).get(now, rawToken, now, peerDomain) as { home_user_id: string } | undefined; + + if (!claimed) { + return sendSigned({ valid: false }); + } + + // 5. Re-confirm the home user is still native (not tombstoned, not turned + // into a replicated stub) since the token was minted. + const homeUser = db + .select() + .from(schema.users) + .where( + and( + eq(schema.users.id, claimed.home_user_id), + eq(schema.users.isDeleted, 0), + isNull(schema.users.homeInstance), + ), + ) + .get(); + + if (!homeUser) { + return sendSigned({ valid: false }); + } + + return sendSigned({ valid: true, homeUserId: homeUser.id, username: homeUser.username }); + }, + ); + // ─── POST /api/federation/sync ────────────────────────────────────────────── // Server-to-server: checkpoint catch-up sync. A peer calls this after downtime // to retrieve missed DM mutations from the mutation log. diff --git a/packages/server/src/routes/federation.verifyAttachProof.test.ts b/packages/server/src/routes/federation.verifyAttachProof.test.ts new file mode 100644 index 00000000..8963ef69 --- /dev/null +++ b/packages/server/src/routes/federation.verifyAttachProof.test.ts @@ -0,0 +1,205 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signRequest, verifySignature } from '../utils/federationAuth.js'; +import { randomUUID } from 'node:crypto'; + +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, +})); + +vi.mock('../utils/federationAuth.js', async (importActual) => { + const actual = await importActual(); + return { ...actual, getOurOrigin: () => 'https://home.test' }; +}); + +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 seedActivePeer(): void { + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: PEER_ORIGIN, + hmacSecret: PEER_SECRET, + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + lastSeenAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); +} + +function seedNativeUser(): void { + testDb.insert(schema.users).values({ + id: 'native-1', + username: 'youruser', + displayName: null, + passwordHash: 'x', + status: 'offline', + isAdmin: 0, + isDeleted: 0, + discoverable: 1, + homeInstance: null, + homeUserId: null, + createdAt: 1, + } as typeof schema.users.$inferInsert).run(); +} + +// Tokens are minted by /api/auth/attach-proof as randomBytes(32).toString('hex') +// — always 64 lowercase hex chars — so the fixture token must be valid hex too. +function seedProof(overrides: Partial = {}): string { + const token = 'a1'.repeat(32); + testDb.insert(schema.federationAttachProofs).values({ + token, + homeUserId: 'native-1', + targetDomain: 'orbit.test', + createdAt: Date.now(), + expiresAt: Date.now() + 60_000, + usedAt: null, + ...overrides, + }).run(); + return overrides.token ?? token; +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { _resetLookupRateBuckets, federationRoutes } = await import('./federation.js'); + _resetLookupRateBuckets(); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +function signedHeaders(body: string): Record { + const timestamp = Date.now(); + const nonce = randomUUID(); + 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', + }; +} + +async function verify(app: FastifyInstance, body: object) { + const bodyStr = JSON.stringify(body); + return app.inject({ + method: 'POST', + url: '/api/federation/verify-attach-proof', + headers: signedHeaders(bodyStr), + payload: bodyStr, + }); +} + +describe('POST /api/federation/verify-attach-proof', () => { + let app: FastifyInstance; + + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer(); + seedNativeUser(); + app = await buildApp(); + }); + + it('valid token → identity returned, marked used', async () => { + const token = seedProof(); + const res = await verify(app, { token }); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body).toEqual({ valid: true, homeUserId: 'native-1', username: 'youruser' }); + const row = testDb.select().from(schema.federationAttachProofs).all()[0]!; + expect(row.usedAt).not.toBeNull(); + }); + + it('second verification of the same token → valid:false (single-use)', async () => { + const token = seedProof(); + await verify(app, { token }); + const res = await verify(app, { token }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('expired token → valid:false', async () => { + const token = seedProof({ expiresAt: Date.now() - 1 }); + const res = await verify(app, { token }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('token bound to a DIFFERENT target domain → valid:false (peer-domain binding)', async () => { + const token = seedProof({ targetDomain: 'someone-else.test' }); + const res = await verify(app, { token }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('unknown token → valid:false', async () => { + const res = await verify(app, { token: 'f'.repeat(64) }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('home user deleted after mint → valid:false', async () => { + const token = seedProof(); + testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, 'native-1')).run(); + const res = await verify(app, { token }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('home user no longer native (homeInstance set) after mint → valid:false', async () => { + const token = seedProof(); + testDb.update(schema.users).set({ homeInstance: 'orbit.test' }).where(eq(schema.users.id, 'native-1')).run(); + const res = await verify(app, { token }); + expect(JSON.parse(res.body)).toEqual({ valid: false }); + }); + + it('unsigned request → 401', async () => { + const res = await app.inject({ + method: 'POST', url: '/api/federation/verify-attach-proof', + headers: { 'Content-Type': 'application/json' }, payload: JSON.stringify({ token: 'x' }), + }); + expect(res.statusCode).toBe(401); + }); + + it('response is HMAC-signed (epoch pattern)', async () => { + const token = seedProof(); + const res = await verify(app, { token }); + const sig = res.headers['x-federation-signature'] as string; + expect(sig).toMatch(/^sha256=/); + const ts = res.headers['x-federation-timestamp'] as string; + expect(ts).toBeDefined(); + const nonce = res.headers['x-federation-nonce'] as string; + // Signature must verify against the response body with the shared secret. + expect(verifySignature(res.body, sig.slice('sha256='.length), PEER_SECRET, Number(ts), nonce)).toBe(true); + }); +});