feat(federation): verify approval token before awaiting_approval → active

Receiver-side defense — closes the trust-bypass class the cheap fix
(4533e36) cannot cover. The /peer/accept handler's awaiting_approval
branch now requires an approvalToken matching the one stored on the
local peer row before promoting to active. Without a match:
- autoAccept=0 falls through to queueApprovalRequest (no bypass; new
  approval-request queued, existing awaiting_approval row untouched).
- autoAccept=1 falls back to permissive promotion (no regression vs
  prior behavior, since autoAccept=1 would accept any inbound regardless).

Successful match also deletes any stale approval-request row for the
origin to prevent debris accumulation from prior bypass attempts.

Refactor: queueing path extracted to a top-level queueApprovalRequest()
helper so both the no-existing-peer case and the awaiting_approval
mismatch fallback share one implementation. Helper generates a fresh
single-use token on every call.

Adds 'accept_awaiting_approval_fallback' variant to PeerActivationReason
to distinguish the autoAccept=1 fallback path from the verified path
in onPeerActivated audit logs.

Spec §3.5, §3.6.

Test counts: 289 → 301 (+12).
This commit is contained in:
Jannis Braun
2026-04-26 11:48:33 +02:00
parent 9e078c44ba
commit 058eb992e4
3 changed files with 493 additions and 68 deletions
@@ -0,0 +1,345 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
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 '../utils/snowflake.js';
setWorkerId(1);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
vi.mock('../utils/auth.js', () => ({
authenticate: async (req: { userId?: string }) => {
req.userId = 'admin-user';
},
requireAdmin: async () => {},
}));
vi.mock('../ws/handler.js', () => ({
connectionManager: {
sendToAdmins: vi.fn(),
getAllOnlineUserIds: () => [],
sendToUser: vi.fn(),
sendToDmMembers: vi.fn(),
},
}));
vi.mock('../utils/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(autoAccept: 0 | 1): void {
testDb.insert(schema.instanceSettings).values({
id: 1,
instanceName: 'Local Backspace',
autoAcceptPeering: autoAccept,
registrationOpen: 1,
updatedAt: Date.now(),
}).run();
}
async function buildApp(): Promise<FastifyInstance> {
const app = Fastify({ logger: false });
const { federationRoutes } = await import('./federation.js');
await app.register(federationRoutes);
await app.ready();
return app;
}
describe('POST /api/federation/peer/accept — approval token verification', () => {
let app: FastifyInstance;
beforeEach(async () => {
sqlite = new Database(':memory:');
testDb = drizzle(sqlite, { schema });
applyMigrations(sqlite);
});
async function setupAutoAccept(value: 0 | 1): Promise<void> {
seedInstanceSettings(value);
app = await buildApp();
}
it('queueing path stores token on peer_approval_requests and returns it in the 202 body (autoAccept=0, no existing peer)', async () => {
await setupAutoAccept(0);
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'remote-secret',
instanceName: 'Remote',
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.queued).toBe(true);
expect(typeof body.approvalToken).toBe('string');
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
const row = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(row?.approvalToken).toBe(body.approvalToken);
});
it('regenerates token on re-handshake (existing approval-request row gets new token)', async () => {
await setupAutoAccept(0);
const now = Date.now();
const oldToken = 'old-token-' + 'a'.repeat(53);
testDb.insert(schema.peerApprovalRequests).values({
id: 'approval-old',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'old-secret',
requestedAt: now - 1000,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: oldToken,
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
instanceName: 'Remote',
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.approvalToken).toMatch(/^[0-9a-f]{64}$/);
expect(body.approvalToken).not.toBe(oldToken);
const row = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(row?.approvalToken).toBe(body.approvalToken);
expect(row?.hmacSecret).toBe('fresh-secret');
});
it('promotes awaiting_approval → active when token matches (autoAccept=0)', async () => {
await setupAutoAccept(0);
const token = 'a'.repeat(64);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
instanceName: 'Remote',
approvalToken: token,
},
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.hmacSecret).toBe('fresh-secret');
expect(peer?.approvalToken).toBeNull();
});
it('on successful match, deletes any stale approval-request row for the same origin', async () => {
await setupAutoAccept(0);
const token = 'b'.repeat(64);
const now = Date.now();
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: now,
}).run();
// Stale approval-request from a prior bypass attempt (e.g., bug-prone code path).
testDb.insert(schema.peerApprovalRequests).values({
id: 'stale-request',
origin: 'https://remote.example',
instanceName: 'Remote',
hmacSecret: 'bypass-secret',
requestedAt: now,
expiresAt: now + 30 * 24 * 60 * 60 * 1000,
approvalToken: 'stale-token',
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
approvalToken: token,
},
});
expect(response.statusCode).toBe(200);
const stale = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(stale).toBeUndefined();
});
it('autoAccept=0 + missing token → does NOT promote, queues new approval-request', async () => {
await setupAutoAccept(0);
const token = 'c'.repeat(64);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: token,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'bypass-secret',
// no approvalToken
},
});
expect(response.statusCode).toBe(202);
const body = response.json() as { queued: boolean; approvalToken?: string };
expect(body.queued).toBe(true);
expect(typeof body.approvalToken).toBe('string');
// Existing awaiting_approval peer row UNCHANGED.
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
expect(peer?.hmacSecret).toBe('old-secret');
expect(peer?.approvalToken).toBe(token);
// New approval-request row exists with a fresh token (different from the existing peer's).
const req = testDb.select().from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, 'https://remote.example')).get();
expect(req?.approvalToken).toBe(body.approvalToken);
expect(req?.approvalToken).not.toBe(token);
});
it('autoAccept=0 + mismatched token → behaves the same as missing token (queues)', async () => {
await setupAutoAccept(0);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: 'd'.repeat(64),
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'bypass-secret',
approvalToken: 'e'.repeat(64),
},
});
expect(response.statusCode).toBe(202);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
});
it('autoAccept=0 + null stored token (legacy) + no inbound token → queues (does not promote)', async () => {
await setupAutoAccept(0);
testDb.insert(schema.federationPeers).values({
id: 'peer-legacy',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: null,
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
},
});
expect(response.statusCode).toBe(202);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('awaiting_approval');
});
it('autoAccept=1 + missing/mismatched token → fallback promotes (no security regression)', async () => {
await setupAutoAccept(1);
testDb.insert(schema.federationPeers).values({
id: 'peer-pending',
origin: 'https://remote.example',
hmacSecret: 'old-secret',
status: 'awaiting_approval',
approvalToken: 'f'.repeat(64),
createdAt: Date.now(),
}).run();
const response = await app.inject({
method: 'POST',
url: '/api/federation/peer/accept',
payload: {
sourceOrigin: 'https://remote.example',
hmacSecret: 'fresh-secret',
// no approvalToken
},
});
expect(response.statusCode).toBe(200);
const peer = testDb.select().from(schema.federationPeers)
.where(eq(schema.federationPeers.origin, 'https://remote.example')).get();
expect(peer?.status).toBe('active');
expect(peer?.hmacSecret).toBe('fresh-secret');
expect(peer?.approvalToken).toBeNull();
});
});
+147 -68
View File
@@ -1,4 +1,4 @@
import type { FastifyInstance } from 'fastify';
import type { FastifyInstance, FastifyReply } from 'fastify';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
@@ -246,6 +246,77 @@ setInterval(() => {
}
}, ACCEPT_RATE_WINDOW_MS).unref();
/**
* Queue an inbound peer/accept request for local-admin approval.
*
* Called from `/peer/accept` when:
* (a) `autoAcceptPeering=0` and no `pending`/`awaiting_approval` peer row
* exists for the source origin (first-contact request from remote), OR
* (b) the receiver is in `awaiting_approval` for this origin but the
* inbound `/peer/accept` cannot be cryptographically verified
* (token absent or mismatched) — see spec §3.5.
*
* Generates a fresh single-use approval token, upserts the
* `peer_approval_requests` row, notifies admins, and returns 202 with the
* token in the body. The initiator stores the token alongside its
* `awaiting_approval` row so a future `/peer/accept` from this side's
* `/approve` endpoint can verify mutual admin approval.
*/
function queueApprovalRequest(
db: ReturnType<typeof getDb>,
reply: FastifyReply,
sourceOrigin: string,
hmacSecret: string,
reqInstanceName: string | null,
): FastifyReply {
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const approvalToken = randomBytes(32).toString('hex');
const existingRequest = db
.select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.get();
if (existingRequest) {
db.update(schema.peerApprovalRequests)
.set({
instanceName: reqInstanceName,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken,
})
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
.run();
} else {
db.insert(schema.peerApprovalRequests)
.values({
id: generateSnowflake(),
origin: sourceOrigin,
instanceName: reqInstanceName,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
approvalToken,
})
.run();
}
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin: sourceOrigin,
instanceName: reqInstanceName ?? undefined,
});
return reply.code(202).send({
queued: true,
message: 'Request queued for admin approval',
approvalToken,
});
}
export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/initiate ────────────────────────────────────
// Admin-only: start a peering handshake with a remote instance.
@@ -432,7 +503,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
// ─── POST /api/federation/peer/accept ──────────────────────────────────────
// Server-to-server: accept a peering request from a remote instance.
// No JWT auth — this is first contact. Rate-limited by IP.
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string } }>(
app.post<{ Body: { sourceOrigin: string; challenge?: string; hmacSecret: string; instanceName?: string; approvalToken?: string } }>(
'/api/federation/peer/accept',
async (request, reply) => {
const clientIp = request.ip;
@@ -443,7 +514,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName } = request.body ?? {};
const { sourceOrigin: rawOrigin, hmacSecret, instanceName: reqInstanceName, approvalToken: inboundToken } = request.body ?? {};
if (!rawOrigin || typeof rawOrigin !== 'string') {
return reply.code(400).send({ error: 'sourceOrigin is required', statusCode: 400 });
@@ -514,50 +585,7 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
});
}
// Queue for admin approval — upsert into peer_approval_requests
const now = Date.now();
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
const existingRequest = db
.select({ id: schema.peerApprovalRequests.id })
.from(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.get();
if (existingRequest) {
db.update(schema.peerApprovalRequests)
.set({
instanceName: reqInstanceName ?? null,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
})
.where(eq(schema.peerApprovalRequests.id, existingRequest.id))
.run();
} else {
db.insert(schema.peerApprovalRequests)
.values({
id: generateSnowflake(),
origin: sourceOrigin,
instanceName: reqInstanceName ?? null,
hmacSecret,
requestedAt: now,
expiresAt: now + THIRTY_DAYS_MS,
})
.run();
}
// Notify admin users that a new approval request arrived
connectionManager.sendToAdmins({
type: 'federation_approval_request_received' as const,
origin: sourceOrigin,
instanceName: reqInstanceName ?? undefined,
});
return reply.code(202).send({
queued: true,
message: 'Request queued for admin approval',
});
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
}
@@ -617,31 +645,82 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
if (existing.status === 'awaiting_approval') {
// Remote admin approved — this is a fresh handshake from them.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Spec §3.5: token verification gates the awaiting_approval → active
// promotion. Without proof the inbound came from the remote's
// /approve endpoint, an adversarial timing-knowledge attack or a
// bug-prone background code path could falsely flip this row to
// active. The token is single-use entropy issued in the 202 we
// returned when the remote's outbound /peer/accept first hit our
// queue — only their /approve endpoint forwards it.
const tokenValid =
typeof existing.approvalToken === 'string' &&
existing.approvalToken.length > 0 &&
existing.approvalToken === inboundToken;
// Broadcast activation
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
if (tokenValid) {
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
// Clean up any stale approval-request row for this origin (e.g.,
// queued debris from a prior bypass attempt that did not promote).
db.delete(schema.peerApprovalRequests)
.where(eq(schema.peerApprovalRequests.origin, sourceOrigin))
.run();
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval) failed:', err)
);
// Token absent or mismatched. Cannot prove mutual approval.
if (autoAccept === 1) {
// We accept any inbound anyway — promoting here is no weaker than
// accepting a fresh handshake from a new peer. Clear the stored
// token (moot now) and proceed.
db.update(schema.federationPeers)
.set({
hmacSecret,
instanceName: reqInstanceName ?? null,
status: 'active',
lastSeenAt: Date.now(),
approvalToken: null,
})
.where(eq(schema.federationPeers.id, existing.id))
.run();
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
for (const uid of connectionManager.getAllOnlineUserIds()) {
connectionManager.sendToUser(uid, {
type: 'federation_peer_active' as const,
peerOrigin: sourceOrigin,
});
}
connectionManager.sendToAdmins({ type: 'federation_peers_changed' as const });
onPeerActivated(existing.id, 'accept_awaiting_approval_fallback').catch(err =>
console.error('[federation] onPeerActivated from /peer/accept (awaiting_approval fallback) failed:', err)
);
return reply.code(200).send({ accepted: true, instanceName: ourInstanceName });
}
// autoAccept=0 + unverifiable inbound → queue as new approval-request.
// Existing awaiting_approval row stays untouched; the new approval-
// request lets the local admin decide whether to honor this inbound.
return queueApprovalRequest(db, reply, sourceOrigin, hmacSecret, reqInstanceName ?? null);
}
// Pending — update with new secret and activate
db.update(schema.federationPeers)
@@ -9,6 +9,7 @@ export type PeerActivationReason =
| 'initiate_accepted'
| 'accept_rejected_override'
| 'accept_awaiting_approval'
| 'accept_awaiting_approval_fallback'
| 'accept_pending'
| 'accept_new'
| 'approval_handshake'