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
This commit is contained in:
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -16,6 +16,11 @@ import { safeFederationRequest } from './safe-federation-http';
|
|||||||
|
|
||||||
const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000;
|
const NODE_PUBLIC_KEY_CACHE_TTL_MS = 60_000;
|
||||||
const MAX_NODE_PUBLIC_KEY_CACHE_ENTRIES = 1_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<string, { publicKey: string; expiresAt: number }>();
|
const nodePublicKeyCache = new Map<string, { publicKey: string; expiresAt: number }>();
|
||||||
const pendingNodePublicKeyRequests = new Map<string, Promise<string | null>>();
|
const pendingNodePublicKeyRequests = new Map<string, Promise<string | null>>();
|
||||||
|
|
||||||
@@ -109,14 +114,22 @@ export async function getNodePublicKey(domain: string): Promise<string | null> {
|
|||||||
if (pending) return await pending;
|
if (pending) return await pending;
|
||||||
|
|
||||||
const keyRequest = (async (): Promise<string | null> => {
|
const keyRequest = (async (): Promise<string | null> => {
|
||||||
const response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node`, {
|
let response = await safeFederationRequest(`${target.protocol}://${normalizedDomain}/api/node/key`, {
|
||||||
headers: { 'Accept': 'application/json' },
|
headers: { 'Accept': 'application/json' },
|
||||||
timeoutMs: 5_000,
|
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) {
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user