From fbe2fb370bc9717b08329d38bdfe9bf50797af6d Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sat, 25 Apr 2026 21:37:41 +0200 Subject: [PATCH] feat(federation): lookupRemoteUser outbound HMAC helper --- .../server/src/utils/federationLookup.test.ts | 218 ++++++++++++++++++ packages/server/src/utils/federationLookup.ts | 77 +++++++ 2 files changed, 295 insertions(+) create mode 100644 packages/server/src/utils/federationLookup.test.ts create mode 100644 packages/server/src/utils/federationLookup.ts diff --git a/packages/server/src/utils/federationLookup.test.ts b/packages/server/src/utils/federationLookup.test.ts new file mode 100644 index 00000000..ddf2b712 --- /dev/null +++ b/packages/server/src/utils/federationLookup.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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 * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(1); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +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('./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(); +} + +const VALID_RESPONSE_BODY = { + found: true, + user: { + homeUserId: 'remote-uid-1', + username: 'bob', + profile: { + displayName: 'Bob', + avatar: null, + avatarColor: null, + banner: null, + bio: 'hello from orbit', + }, + }, +}; + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe('lookupRemoteUser', () => { + it('1. returns ok:true with homeUserId/username/profile on HTTP 200 + valid body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ + ok: true, + homeUserId: 'remote-uid-1', + username: 'bob', + profile: { + displayName: 'Bob', + avatar: null, + avatarColor: null, + banner: null, + bio: 'hello from orbit', + }, + }); + }); + + it('2. returns ok:false reason:not_found on HTTP 404', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 404, + headers: { get: () => null }, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'nobody'); + + expect(result).toEqual({ ok: false, reason: 'not_found' }); + }); + + it('3. returns ok:false reason:rate_limited with retryAfter on HTTP 429', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: false, + status: 429, + headers: { get: (name: string) => (name === 'Retry-After' ? '60' : null) }, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'rate_limited', retryAfter: 60 }); + }); + + it('4. returns ok:false reason:unreachable on network error (TypeError)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed'))); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + }); + + it('5. returns ok:false reason:unreachable on AbortError (timeout)', async () => { + const abortError = new DOMException('The operation was aborted.', 'AbortError'); + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(abortError)); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + const result = await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(result).toEqual({ ok: false, reason: 'unreachable' }); + }); + + it('6. throws when no peer record exists in federation_peers', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await expect(lookupRemoteUser('https://unknown.test', 'bob')).rejects.toThrow( + 'lookupRemoteUser: no peer record for https://unknown.test', + ); + }); + + it('7. signs the request with correct HMAC headers', async () => { + let capturedInit: RequestInit | undefined; + vi.stubGlobal( + 'fetch', + vi.fn().mockImplementation((_url: string, init: RequestInit) => { + capturedInit = init; + return Promise.resolve({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => VALID_RESPONSE_BODY, + }); + }), + ); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await lookupRemoteUser(PEER_ORIGIN, 'bob'); + + expect(capturedInit).toBeDefined(); + const headers = capturedInit!.headers as Record; + + // URL + const fetchMock = (globalThis.fetch as ReturnType); + expect(fetchMock).toHaveBeenCalledWith( + `${PEER_ORIGIN}/api/federation/users/lookup`, + expect.anything(), + ); + + // Required HMAC headers + expect(headers['X-Federation-Origin']).toBe('https://home.test'); + expect(headers['X-Federation-Signature']).toMatch(/^sha256=[0-9a-f]{64}$/); + expect(headers['X-Federation-Nonce']).toBeTruthy(); + expect(headers['X-Federation-Timestamp']).toMatch(/^\d+$/); + }); + + it('8. throws on malformed 200 body', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + status: 200, + headers: { get: () => null }, + json: async () => ({ found: false, code: 'user_not_found' }), + })); + + const { lookupRemoteUser } = await import('./federationLookup.js'); + await expect(lookupRemoteUser(PEER_ORIGIN, 'bob')).rejects.toThrow( + `lookupRemoteUser: peer ${PEER_ORIGIN} returned malformed body`, + ); + }); +}); diff --git a/packages/server/src/utils/federationLookup.ts b/packages/server/src/utils/federationLookup.ts new file mode 100644 index 00000000..d8b81476 --- /dev/null +++ b/packages/server/src/utils/federationLookup.ts @@ -0,0 +1,77 @@ +import { eq } from 'drizzle-orm'; +import { getDb } from '../db/index.js'; +import * as schema from '../db/schema.js'; +import { buildFederationHeaders, getOurOrigin } from './federationAuth.js'; +import type { FederationUserLookupProfile, FederationUserLookupResponse } from '@backspace/shared'; + +const LOOKUP_TIMEOUT_MS = 10_000; + +export type LookupResult = + | { ok: true; homeUserId: string; username: string; profile: FederationUserLookupProfile } + | { ok: false; reason: 'not_found' } + | { ok: false; reason: 'unreachable' } + | { ok: false; reason: 'rate_limited'; retryAfter?: number }; + +/** + * Look up a username on a remote peer instance. + * + * - Looks up the peer's HMAC secret from the local federation_peers table. + * - Throws if the peer record is missing — caller must ensurePeered first. + * - Wraps fetch in a 10s timeout; network/timeout/AbortError → unreachable. + * - HTTP 404 → not_found; 429 → rate_limited (with retryAfter); 200 + valid body → ok; + * anything else (5xx, unexpected 4xx, malformed body) → throws (logged at call site). + */ +export async function lookupRemoteUser(peerOrigin: string, username: string): Promise { + const db = getDb(); + const peer = db + .select() + .from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, peerOrigin)) + .get(); + + if (!peer) { + throw new Error(`lookupRemoteUser: no peer record for ${peerOrigin}`); + } + + const body = JSON.stringify({ username }); + const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin()); + + let response: Response; + try { + response = await fetch(`${peerOrigin}/api/federation/users/lookup`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(LOOKUP_TIMEOUT_MS), + }); + } catch { + // Network error, timeout, AbortError — all unreachable. + return { ok: false, reason: 'unreachable' }; + } + + if (response.status === 404) { + return { ok: false, reason: 'not_found' }; + } + + if (response.status === 429) { + const raw = Number(response.headers.get('Retry-After') ?? '60'); + const retryAfter = Number.isFinite(raw) ? raw : 60; + return { ok: false, reason: 'rate_limited', retryAfter }; + } + + if (!response.ok) { + throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned HTTP ${response.status}`); + } + + const json = (await response.json()) as FederationUserLookupResponse; + if (!json || json.found !== true || !json.user || typeof json.user.homeUserId !== 'string') { + throw new Error(`lookupRemoteUser: peer ${peerOrigin} returned malformed body`); + } + + return { + ok: true, + homeUserId: json.user.homeUserId, + username: json.user.username, + profile: json.user.profile, + }; +}