From 0c09a58d615206246ea8722aa6e96694a506adc0 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 31 Mar 2026 20:49:15 +0200 Subject: [PATCH] feat(federation): add grace period finalization and auto-rotation to health worker (FED-011) --- packages/server/src/utils/federationWorker.ts | 98 ++++++++++++++++++- 1 file changed, 96 insertions(+), 2 deletions(-) diff --git a/packages/server/src/utils/federationWorker.ts b/packages/server/src/utils/federationWorker.ts index 7c3f4679..1a89b3cc 100644 --- a/packages/server/src/utils/federationWorker.ts +++ b/packages/server/src/utils/federationWorker.ts @@ -1,10 +1,10 @@ import { getDb } from '../db/index.js'; import * as schema from '../db/schema.js'; -import { eq, and, lte, asc, inArray } from 'drizzle-orm'; +import { eq, and, lte, asc, inArray, sql } from 'drizzle-orm'; import { config } from '../config.js'; import { isFederationRelayEnabled, queueOutboxEvent } from './federationOutbox.js'; import { runFederationJanitor } from './storageJanitor.js'; -import { buildFederationHeaders, getOurOrigin } from './federationAuth.js'; +import { buildFederationHeaders, getOurOrigin, generateHmacSecret, ROTATION_GRACE_PERIOD_MS } from './federationAuth.js'; import { generateSnowflake } from './snowflake.js'; import { getDmMessageWithUser } from '../routes/dm.js'; import { connectionManager } from '../ws/handler.js'; @@ -680,6 +680,100 @@ function scheduleHealthCheckTick(): void { async function processHealthCheckTick(): Promise { const db = getDb(); + // ── Grace period finalization ────────────────────────────────────────────── + // Promote pending secrets that have completed their grace period. + const rotatingPeers = db + .select() + .from(schema.federationPeers) + .where( + and( + sql`${schema.federationPeers.pendingHmacSecret} IS NOT NULL`, + sql`${schema.federationPeers.secretRotationAt} IS NOT NULL`, + ), + ) + .all(); + + for (const peer of rotatingPeers) { + const elapsed = Date.now() - (peer.secretRotationAt ?? 0); + if (elapsed > ROTATION_GRACE_PERIOD_MS) { + db.update(schema.federationPeers) + .set({ + hmacSecret: peer.pendingHmacSecret!, + pendingHmacSecret: null, + secretRotationAt: null, + secretRotatedAt: Date.now(), + }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + console.log(`[federation-worker] Secret rotation finalized for peer ${peer.origin}`); + } + } + + // ── Auto-rotation ────────────────────────────────────────────────────────── + // Initiate rotation for active peers whose secret has aged past the threshold. + const autoRotateCandidates = db + .select() + .from(schema.federationPeers) + .where( + and( + eq(schema.federationPeers.status, 'active'), + sql`${schema.federationPeers.pendingHmacSecret} IS NULL`, + sql`${schema.federationPeers.autoRotateIntervalDays} > 0`, + ), + ) + .all(); + + const ourOrigin = getOurOrigin(); + + for (const peer of autoRotateCandidates) { + const lastRotation = peer.secretRotatedAt ?? peer.createdAt; + const intervalMs = peer.autoRotateIntervalDays * 86_400_000; + if (Date.now() - lastRotation < intervalMs) continue; + + // Time to rotate + const newSecret = generateHmacSecret(); + + // Store pending locally first + db.update(schema.federationPeers) + .set({ + pendingHmacSecret: newSecret, + secretRotationAt: Date.now(), + }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + + try { + const rotateBody = JSON.stringify({ newSecret }); + const headers = buildFederationHeaders(rotateBody, peer.hmacSecret, ourOrigin); + + const response = await fetch(`${peer.origin}/api/federation/peer/rotate`, { + method: 'POST', + headers, + body: rotateBody, + signal: AbortSignal.timeout(10_000), + }); + + if (response.ok) { + console.log(`[federation-worker] Auto-rotation initiated with peer ${peer.origin}`); + } else { + // Rollback + db.update(schema.federationPeers) + .set({ pendingHmacSecret: null, secretRotationAt: null }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + console.warn(`[federation-worker] Auto-rotation rejected by peer ${peer.origin} (HTTP ${response.status})`); + } + } catch (err) { + // Rollback + db.update(schema.federationPeers) + .set({ pendingHmacSecret: null, secretRotationAt: null }) + .where(eq(schema.federationPeers.id, peer.id)) + .run(); + const message = err instanceof Error ? err.message : 'Unknown error'; + console.warn(`[federation-worker] Auto-rotation failed for peer ${peer.origin}: ${message}`); + } + } + const unreachablePeers = db .select() .from(schema.federationPeers)