feat(federation): outbound attach-proof verification + home-profile fetch helpers (re-attach spec §3.1)

This commit is contained in:
Jannis Braun
2026-07-03 01:54:50 +02:00
parent 093d5f3f26
commit 7bff6c1a1b
2 changed files with 173 additions and 0 deletions
@@ -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<typeof vi.fn>).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();
});
});
@@ -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;
}
}