From 9e078c44ba007a12e6c95ed59fd503dd78f62c22 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Sun, 26 Apr 2026 11:45:16 +0200 Subject: [PATCH] feat(federation): performHandshake captures + clears approval token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 202 response: parse approvalToken from body and store on the local federation_peers row alongside status='awaiting_approval'. Legacy receivers that omit the field result in null stored token, handled gracefully by the verification logic landing in subsequent commits. - 200 response: clear approvalToken in the same UPDATE that sets status='active' (single-use consumption per spec §3.2). Test coverage: 4 tests in federationPeering.approvalToken.test.ts covering JSON token, missing token (legacy), non-JSON body, and 200 clear after prior token storage. --- .../federationPeering.approvalToken.test.ts | 172 ++++++++++++++++++ .../server/src/utils/federationPeering.ts | 19 +- 2 files changed, 188 insertions(+), 3 deletions(-) create mode 100644 packages/server/src/utils/federationPeering.approvalToken.test.ts diff --git a/packages/server/src/utils/federationPeering.approvalToken.test.ts b/packages/server/src/utils/federationPeering.approvalToken.test.ts new file mode 100644 index 00000000..a337738f --- /dev/null +++ b/packages/server/src/utils/federationPeering.approvalToken.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +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 './snowflake.js'; + +setWorkerId(1); + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + schema, +})); + +vi.mock('./federationAuth.js', async () => { + const actual = await vi.importActual('./federationAuth.js'); + return { + ...actual, + getOurOrigin: () => 'https://local.example', + generateHmacSecret: () => 'mock-hmac-secret', + }; +}); + +vi.mock('../routes/federation.js', () => ({ + validateOrigin: (raw: string) => { + try { + const url = new URL(raw); + return url.origin; + } catch { + return null; + } + }, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToAdmins: vi.fn(), + sendToUser: vi.fn(), + getAllOnlineUserIds: () => [], + }, +})); + +vi.mock('./federationPeerActivation.js', () => ({ + onPeerActivated: vi.fn(async () => undefined), + onPeerDeactivated: vi.fn(async () => undefined), +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedInstanceSettings(): void { + testDb.insert(schema.instanceSettings).values({ + id: 1, + instanceName: 'Local Backspace', + autoAcceptPeering: 1, + registrationOpen: 1, + updatedAt: Date.now(), + }).run(); +} + +describe('performHandshake — approval token capture & clear', () => { + beforeEach(async () => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedInstanceSettings(); + const { _clearInFlightPeering } = await import('./federationPeering.js'); + _clearInFlightPeering(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + sqlite.close(); + }); + + it('stores approvalToken on the peer row when remote returns 202 with token', async () => { + const fakeToken = 'a'.repeat(64); + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued', approvalToken: fakeToken }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://remote.example'); + + expect(result.status).toBe('pending'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBe(fakeToken); + }); + + it('stores null approvalToken when 202 omits the field (legacy receiver)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ queued: true, message: 'queued' }), + { status: 202, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://legacy.example'); + + expect(result.status).toBe('pending'); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://legacy.example')).get(); + expect(peer?.status).toBe('awaiting_approval'); + expect(peer?.approvalToken).toBeNull(); + }); + + it('handles non-JSON 202 body gracefully (token stays null)', async () => { + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('', { status: 202 }), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://empty.example'); + + expect(result.status).toBe('pending'); + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://empty.example')).get(); + expect(peer?.approvalToken).toBeNull(); + }); + + it('clears approvalToken on 200 activation even if previously stored', async () => { + testDb.insert(schema.federationPeers).values({ + id: 'peer-existing', + origin: 'https://remote.example', + hmacSecret: 'old-secret', + status: 'pending', + approvalToken: 'old-token', + createdAt: Date.now(), + }).run(); + + vi.spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ accepted: true, instanceName: 'Remote' }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ), + ); + + const { ensurePeered } = await import('./federationPeering.js'); + const result = await ensurePeered('https://remote.example'); + expect(result.status).toBe('active'); + + const peer = testDb.select().from(schema.federationPeers) + .where(eq(schema.federationPeers.origin, 'https://remote.example')).get(); + expect(peer?.status).toBe('active'); + expect(peer?.approvalToken).toBeNull(); + }); +}); diff --git a/packages/server/src/utils/federationPeering.ts b/packages/server/src/utils/federationPeering.ts index 2fbda0a5..48ce5769 100644 --- a/packages/server/src/utils/federationPeering.ts +++ b/packages/server/src/utils/federationPeering.ts @@ -166,9 +166,22 @@ async function performHandshake( }); if (response.status === 202) { - // Request queued for admin approval on the remote side + // Capture the approval token from the 202 body if present. Stored on + // our federation_peers row so a subsequent inbound /peer/accept (from + // the remote's /approve flow) can be cryptographically verified before + // we promote awaiting_approval → active. Spec §3.7. + let approvalToken: string | null = null; + try { + const body = (await response.json()) as { approvalToken?: string }; + if (typeof body?.approvalToken === 'string' && body.approvalToken.length > 0) { + approvalToken = body.approvalToken; + } + } catch { + // Non-JSON or empty body — legacy receiver, leave null. + } + db.update(schema.federationPeers) - .set({ status: 'awaiting_approval' }) + .set({ status: 'awaiting_approval', approvalToken }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js'); @@ -191,7 +204,7 @@ async function performHandshake( } db.update(schema.federationPeers) - .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName }) + .set({ status: 'active', lastSeenAt: Date.now(), instanceName: remoteInstanceName, approvalToken: null }) .where(eq(schema.federationPeers.id, peerId)) .run(); const { connectionManager } = await import('../ws/handler.js');