From 566d79347f06a2743e40adb12faaf293ba8c9f2f Mon Sep 17 00:00:00 2001 From: cyph3rasi Date: Wed, 15 Jul 2026 07:59:46 -0700 Subject: [PATCH] Fix federated E2EE delivery when remote node metadata exceeds key lookup limit Hop-State: A_06FPCJKZ17TQRWHPEDC2R30 Hop-Proposal: R_06FPCJKCFSDGTQJV38RW9AG Hop-Task: T_06FPCHWGKW3T4JFQZMN4MT8 Hop-Attempt: AT_06FPCHWGKYKYAXA04H4WRY0 --- src/app/api/node/key/route.test.ts | 31 +++++++++++++++ src/app/api/node/key/route.ts | 22 +++++++++++ src/lib/swarm/signature.test.ts | 61 ++++++++++++++++++++++++++++++ src/lib/swarm/signature.ts | 19 ++++++++-- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/app/api/node/key/route.test.ts create mode 100644 src/app/api/node/key/route.ts create mode 100644 src/lib/swarm/signature.test.ts diff --git a/src/app/api/node/key/route.test.ts b/src/app/api/node/key/route.test.ts new file mode 100644 index 0000000..66c12f7 --- /dev/null +++ b/src/app/api/node/key/route.test.ts @@ -0,0 +1,31 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + getNodePublicKey: vi.fn(), +})); + +vi.mock('@/lib/swarm/node-keys', () => ({ + getNodePublicKey: mocks.getNodePublicKey, +})); + +import { GET } from './route'; + +describe('GET /api/node/key', () => { + beforeEach(() => { + mocks.getNodePublicKey.mockReset(); + process.env.NEXT_PUBLIC_NODE_DOMAIN = 'node.example'; + }); + + it('returns only the node identity needed for signature verification', async () => { + mocks.getNodePublicKey.mockResolvedValue('public-key'); + + const result = await GET(); + + expect(result.status).toBe(200); + expect(result.headers.get('cache-control')).toBe('no-store'); + await expect(result.json()).resolves.toEqual({ + domain: 'node.example', + publicKey: 'public-key', + }); + }); +}); diff --git a/src/app/api/node/key/route.ts b/src/app/api/node/key/route.ts new file mode 100644 index 0000000..d3513a1 --- /dev/null +++ b/src/app/api/node/key/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from 'next/server'; + +import { getNodePublicKey } from '@/lib/swarm/node-keys'; + +export async function GET() { + try { + const publicKey = await getNodePublicKey(); + if (!publicKey) { + return NextResponse.json({ error: 'Node public key is unavailable' }, { status: 503 }); + } + + return NextResponse.json({ + domain: process.env.NEXT_PUBLIC_NODE_DOMAIN || 'localhost:43821', + publicKey, + }, { + headers: { 'Cache-Control': 'no-store' }, + }); + } catch (error) { + console.error('Node public key error:', error); + return NextResponse.json({ error: 'Node public key is unavailable' }, { status: 503 }); + } +} diff --git a/src/lib/swarm/signature.test.ts b/src/lib/swarm/signature.test.ts new file mode 100644 index 0000000..ec44a80 --- /dev/null +++ b/src/lib/swarm/signature.test.ts @@ -0,0 +1,61 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + safeFederationRequest: vi.fn(), +})); + +vi.mock('@/db', () => ({ + db: { + query: { users: { findFirst: vi.fn() } }, + }, + users: {}, +})); + +vi.mock('./node-blocklist', () => ({ + isNodeBlocked: vi.fn().mockResolvedValue(false), + normalizeNodeDomain: (domain: string) => domain.toLowerCase(), +})); + +vi.mock('./node-domain', () => ({ + getPublicSwarmDomain: (domain: string) => domain, +})); + +vi.mock('./safe-federation-http', () => ({ + safeFederationRequest: mocks.safeFederationRequest, +})); + +import { getNodePublicKey } from './signature'; + +function response(status: number, body: unknown) { + return { status, json: () => body }; +} + +describe('node public key discovery', () => { + beforeEach(() => { + mocks.safeFederationRequest.mockReset(); + }); + + it('uses the bounded key-only endpoint', async () => { + mocks.safeFederationRequest.mockResolvedValue(response(200, { publicKey: 'node-key' })); + + await expect(getNodePublicKey('small.example')).resolves.toBe('node-key'); + expect(mocks.safeFederationRequest).toHaveBeenCalledOnce(); + expect(mocks.safeFederationRequest).toHaveBeenCalledWith( + 'https://small.example/api/node/key', + expect.objectContaining({ maxResponseBytes: 16 * 1024 }), + ); + }); + + it('supports legacy nodes whose node document contains embedded branding', async () => { + mocks.safeFederationRequest + .mockResolvedValueOnce(response(404, {})) + .mockResolvedValueOnce(response(200, { publicKey: 'legacy-node-key' })); + + await expect(getNodePublicKey('legacy.example')).resolves.toBe('legacy-node-key'); + expect(mocks.safeFederationRequest).toHaveBeenNthCalledWith( + 2, + 'https://legacy.example/api/node', + expect.objectContaining({ maxResponseBytes: 256 * 1024 }), + ); + }); +}); diff --git a/src/lib/swarm/signature.ts b/src/lib/swarm/signature.ts index fbfacff..1db5513 100644 --- a/src/lib/swarm/signature.ts +++ b/src/lib/swarm/signature.ts @@ -16,6 +16,11 @@ import { safeFederationRequest } from './safe-federation-http'; const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000; const MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES = 1_000; +const NODE_PUBLIC_KEY_MAX_RESPONSE_BYTES = 16 * 1024; +// Older nodes expose their key only through /api/node. That document may +// include embedded branding assets, so keep a bounded compatibility fallback +// while new nodes use the deliberately small key-only endpoint. +const LEGACY_NODE_INFO_MAX_RESPONSE_BYTES = 256 * 1024; const nodePublicKeyCache = new Map(); const pendingNodePublicKeyRequests = new Map>(); @@ -109,14 +114,22 @@ export async function getNodePublicKey(domain: string): Promise { if (pending) return await pending; const keyRequest = (async (): Promise => { - const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, { + let response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node/key`, { headers: { 'Accept': 'application/json' }, timeoutMs: 5_000, - maxResponseBytes: 64 * 1024, + maxResponseBytes: NODE_PUBLIC_KEY_MAX_RESPONSE_BYTES, }); + if (response.status === 404 || response.status === 405) { + response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, { + headers: { 'Accept': 'application/json' }, + timeoutMs: 5_000, + maxResponseBytes: LEGACY_NODE_INFO_MAX_RESPONSE_BYTES, + }); + } + if (response.status < 200 || response.status >= 300) { - console.error(`[Signature] Failed to fetch node info from ${normalizedDomain}: ${response.status}`); + console.error(`[Signature] Failed to fetch node public key from ${normalizedDomain}: ${response.status}`); return null; }