diff --git a/packages/server/src/utils/federationAttach.test.ts b/packages/server/src/utils/federationAttach.test.ts new file mode 100644 index 00000000..a76a45d0 --- /dev/null +++ b/packages/server/src/utils/federationAttach.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { signRequest } from './federationAuth.js'; + +const PEER = { origin: 'https://orbit.test', hmacSecret: 'b'.repeat(64) }; + +function signedResponse(bodyObj: object): Response { + const body = JSON.stringify(bodyObj); + const ts = Date.now(); + const nonce = 'resp-nonce'; + const sig = signRequest(body, PEER.hmacSecret, ts, nonce); + return new Response(body, { + status: 200, + headers: { + 'x-federation-signature': `sha256=${sig}`, + 'x-federation-timestamp': String(ts), + 'x-federation-nonce': nonce, + }, + }); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('verifyAttachProofWithPeer', () => { + it('returns the verified identity for a signed valid:true response', async () => { + vi.stubGlobal('fetch', vi.fn(async () => signedResponse({ valid: true, homeUserId: 'h1', username: 'youruser' }))); + const { verifyAttachProofWithPeer } = await import('./federationAttach.js'); + const result = await verifyAttachProofWithPeer(PEER, 'a'.repeat(64)); + expect(result).toEqual({ valid: true, homeUserId: 'h1', username: 'youruser' }); + const call = (fetch as ReturnType).mock.calls[0]!; + expect(call[0]).toBe('https://orbit.test/api/federation/verify-attach-proof'); + expect((call[1] as RequestInit).headers).toHaveProperty('X-Federation-Signature'); + }); + + it('treats an UNSIGNED response as valid:false (never trust unauthenticated bodies)', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ valid: true, homeUserId: 'h1', username: 'youruser' }), { status: 200 }))); + const { verifyAttachProofWithPeer } = await import('./federationAttach.js'); + expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false }); + }); + + it('network error → valid:false', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); })); + const { verifyAttachProofWithPeer } = await import('./federationAttach.js'); + expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false }); + }); +}); + +describe('fetchHomeProfileByHomeId', () => { + it('returns the profile for found:true', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ + found: true, + user: { homeUserId: 'h1', username: 'youruser', profile: { displayName: 'J', avatar: 'a.webp', avatarColor: 'coral', banner: null, bio: null } }, + }), { status: 200 }))); + const { fetchHomeProfileByHomeId } = await import('./federationAttach.js'); + const result = await fetchHomeProfileByHomeId(PEER, 'h1'); + expect(result?.username).toBe('youruser'); + expect(result?.profile.avatar).toBe('a.webp'); + }); + + it('found:false or network error → null', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ found: false }), { status: 200 }))); + const { fetchHomeProfileByHomeId } = await import('./federationAttach.js'); + expect(await fetchHomeProfileByHomeId(PEER, 'h1')).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationAttach.ts b/packages/server/src/utils/federationAttach.ts new file mode 100644 index 00000000..f0aff08b --- /dev/null +++ b/packages/server/src/utils/federationAttach.ts @@ -0,0 +1,109 @@ +import { buildFederationHeaders, verifySignature, getOurOrigin } from './federationAuth.js'; + +export interface PeerForAttach { + origin: string; + hmacSecret: string; +} + +/** + * Verify a one-time attach-proof token with the detached account's home + * instance (re-attach spec §3.1). The response body is only trusted when its + * HMAC signature verifies against the shared peer secret — mirrors + * fetchPeerEpoch. Any failure (network, bad status, bad signature, malformed + * body) is treated as { valid: false }: re-attach fails closed. + */ +export async function verifyAttachProofWithPeer( + peer: PeerForAttach, + token: string, +): Promise<{ valid: true; homeUserId: string; username: string } | { valid: false }> { + const body = JSON.stringify({ token }); + const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin()); + + let res: Response; + try { + res = await fetch(`${peer.origin}/api/federation/verify-attach-proof`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(10_000), + }); + } catch { + return { valid: false }; + } + if (!res.ok) return { valid: false }; + + let text: string; + try { + text = await res.text(); + } catch { + return { valid: false }; + } + + // Verify the response signature with the SAME secret and arg order the peer's + // handler signed it with (buildFederationHeaders). A mismatch means we must + // not trust the body — never trust an unauthenticated body (spec §2). + const sig = (res.headers.get('x-federation-signature') ?? '').replace(/^sha256=/, ''); + const ts = Number(res.headers.get('x-federation-timestamp')); + const nonce = res.headers.get('x-federation-nonce'); + if (!sig || !Number.isFinite(ts) || !verifySignature(text, sig, peer.hmacSecret, ts, nonce)) { + return { valid: false }; + } + + try { + const parsed = JSON.parse(text) as { valid?: boolean; homeUserId?: string; username?: string }; + if (parsed.valid === true && typeof parsed.homeUserId === 'string' && typeof parsed.username === 'string') { + return { valid: true, homeUserId: parsed.homeUserId, username: parsed.username }; + } + } catch { + // fall through + } + return { valid: false }; +} + +/** + * Fetch the home instance's current profile for a native user via the + * existing POST /api/federation/users/by-home-id endpoint. Best-effort: + * null on any failure — re-attach proceeds without an initial profile + * (the next profile_update relay fills it). + */ +export async function fetchHomeProfileByHomeId( + peer: PeerForAttach, + homeUserId: string, +): Promise<{ username: string; profile: { displayName: string | null; avatar: string | null; avatarColor: string | null; banner: string | null; bio: string | null } } | null> { + const body = JSON.stringify({ homeUserId }); + const headers = buildFederationHeaders(body, peer.hmacSecret, getOurOrigin()); + + let res: Response; + try { + res = await fetch(`${peer.origin}/api/federation/users/by-home-id`, { + method: 'POST', + headers, + body, + signal: AbortSignal.timeout(10_000), + }); + } catch { + return null; + } + if (!res.ok) return null; + + try { + const parsed = await res.json() as { + found?: boolean; + user?: { username?: string; profile?: { displayName?: string | null; avatar?: string | null; avatarColor?: string | null; banner?: string | null; bio?: string | null } }; + }; + if (!parsed.found || !parsed.user || typeof parsed.user.username !== 'string' || !parsed.user.profile) return null; + const p = parsed.user.profile; + return { + username: parsed.user.username, + profile: { + displayName: p.displayName ?? null, + avatar: p.avatar ?? null, + avatarColor: p.avatarColor ?? null, + banner: p.banner ?? null, + bio: p.bio ?? null, + }, + }; + } catch { + return null; + } +}