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
+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)