feat(federation): outbound attach-proof verification + home-profile fetch helpers (re-attach spec §3.1)
This commit is contained in:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user