From e0a0d92fe7decff283e70d82612f45a54cee7868 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:33:42 +0200 Subject: [PATCH] feat(federation): login self-heal epoch guard (fetchPeerEpoch, fail-closed) --- .../server/src/routes/auth.epochGuard.test.ts | 125 +++++++++++++++++- packages/server/src/routes/auth.ts | 38 +++++- 2 files changed, 160 insertions(+), 3 deletions(-) diff --git a/packages/server/src/routes/auth.epochGuard.test.ts b/packages/server/src/routes/auth.epochGuard.test.ts index 48424127..5283ab83 100644 --- a/packages/server/src/routes/auth.epochGuard.test.ts +++ b/packages/server/src/routes/auth.epochGuard.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import Database from 'better-sqlite3'; import { drizzle } from 'drizzle-orm/better-sqlite3'; @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import * as schema from '../db/schema.js'; import { hashPassword } from '../utils/auth.js'; import { setWorkerId } from '../utils/snowflake.js'; +import { buildFederationHeaders } from '../utils/federationAuth.js'; setWorkerId(2); const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -103,3 +104,125 @@ describe('login: federation_home_orphaned freeze', () => { expect(res.json().token).toBeTruthy(); }); }); + +describe('login: self-heal epoch guard', () => { + // The self-heal path re-hashes a federated user's stale local password when the + // home instance accepts it. The epoch guard (§6.3a) gates that re-hash on the + // home's CURRENT instance epoch matching the trusted baseline we recorded, so a + // factory-reset home (new incarnation, same domain) cannot silently hand an + // established account to a new same-name user via self-heal. + // + // globalThis.fetch is stubbed to route the two outbound POSTs the login handler + // makes: the home /api/auth/login probe (→ 200 {ok}) and the subsequent + // fetchPeerEpoch call to /api/federation/epoch (→ signed {instanceId} or 404). + let savedFetch: typeof globalThis.fetch; + + beforeEach(() => { + savedFetch = globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = savedFetch; + }); + + // Seed a federated user whose LOCAL hash is stale (does not match the test + // password) plus an active peer row carrying `baselineEpoch` as its recorded + // baseline (null → no baseline on record). + async function seedStaleUserAndPeer(baselineEpoch: string | null, hmacSecret: string): Promise { + const staleHash = await hashPassword('OLD-password-not-this'); + testDb.insert(schema.users).values({ + id: 'user-c', + username: 'carol@orbit.ddns.net', + passwordHash: staleHash, + homeInstance: 'orbit.ddns.net', + homeUserId: 'hid', + avatarColor: '#fff', + createdAt: Date.now(), + }).run(); + testDb.insert(schema.federationPeers).values({ + id: 'peer-k', + origin: 'https://orbit.ddns.net', + hmacSecret, + status: 'active', + peerInstanceId: baselineEpoch, + createdAt: Date.now(), + }).run(); + } + + // home-login POST → {ok:true}; /api/federation/epoch → signed {instanceId} (or + // 404 when `epochToEcho` is null, exercising the fail-closed "cannot determine" + // branch). The epoch response is HMAC-signed exactly as a real peer would sign + // it, so it round-trips through fetchPeerEpoch's real signature verification. + function makeFetchStub(hmacSecret: string, epochToEcho: string | null): typeof globalThis.fetch { + return (async (url: string | URL | Request): Promise => { + const u = String(url); + if (u.endsWith('/api/auth/login')) { + return new Response(JSON.stringify({ token: 't', user: {} }), { status: 200 }); + } + if (u.endsWith('/api/federation/epoch')) { + if (epochToEcho === null) return new Response('nope', { status: 404 }); + const body = JSON.stringify({ instanceId: epochToEcho }); + const headers = buildFederationHeaders(body, hmacSecret, 'https://our.origin'); + return new Response(body, { status: 200, headers }); + } + throw new Error(`unexpected fetch ${u}`); + }) as typeof globalThis.fetch; + } + + it('MATCH → self-heal allowed (login 200)', async () => { + const secret = 'shared-secret-abc'; + await seedStaleUserAndPeer('EPOCH-A', secret); + globalThis.fetch = makeFetchStub(secret, 'EPOCH-A'); // home echoes the SAME epoch + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'carol@orbit.ddns.net', password: 'the-real-current-password' }, + }); + + expect(res.statusCode).toBe(200); + }); + + it('DIFFERS → self-heal refused (login 401)', async () => { + const secret = 'shared-secret-abc'; + await seedStaleUserAndPeer('EPOCH-A', secret); + globalThis.fetch = makeFetchStub(secret, 'EPOCH-B'); // reset home echoes a NEW epoch + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'carol@orbit.ddns.net', password: 'attacker-password' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('CANNOT DETERMINE (404 / desynced secret) → fail closed (login 401)', async () => { + const secret = 'shared-secret-abc'; + await seedStaleUserAndPeer('EPOCH-A', secret); + globalThis.fetch = makeFetchStub(secret, null); // epoch endpoint 404 → null + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'carol@orbit.ddns.net', password: 'whatever' }, + }); + + expect(res.statusCode).toBe(401); + }); + + it('NO BASELINE (peerInstanceId null) → legacy allow (login 200)', async () => { + const secret = 'shared-secret-abc'; + await seedStaleUserAndPeer(null, secret); // no baseline on record + + globalThis.fetch = makeFetchStub(secret, 'EPOCH-A'); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'carol@orbit.ddns.net', password: 'the-real-current-password' }, + }); + + expect(res.statusCode).toBe(200); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 3d1666fb..426109e5 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq } from 'drizzle-orm'; +import { eq, or } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; @@ -7,7 +7,8 @@ import { config } from '../config.js'; import type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared'; import { AVATAR_COLORS } from '@backspace/shared'; import { sanitizeUser } from '../utils/sanitize.js'; -import { findFederatedUser } from './federation.js'; +import { findFederatedUser, extractDomain } from './federation.js'; +import { fetchPeerEpoch } from '../utils/federationEpoch.js'; import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js'; export async function authRoutes(app: FastifyInstance): Promise { @@ -413,6 +414,39 @@ export async function authRoutes(app: FastifyInstance): Promise { clearTimeout(timeout); if (homeResponse.ok) { + // §6.3a epoch guard: self-heal (re-hashing the local password because + // the home accepted it) must fire ONLY when the home instance is the + // SAME incarnation we established trust with. A reset home (new + // incarnation on the same domain) accepting a NEW same-name user's + // credentials would otherwise silently hand that stranger this + // established account. We gate on the trusted baseline epoch. + // + // Reuse the authenticated fetchPeerEpoch (HMAC-signed both ways) rather + // than an unauthenticated login-response body — the latter is + // TLS-MITM-bypassable and would re-open the exact hijack this closes. + const homeDomain = extractDomain(user.homeInstance); + const peer = db.select().from(schema.federationPeers) + .where(or( + eq(schema.federationPeers.origin, homeDomain), + eq(schema.federationPeers.origin, `https://${homeDomain}`), + eq(schema.federationPeers.origin, `http://${homeDomain}`), + )) + .get(); + + if (peer && peer.peerInstanceId) { + // Baseline on record → enforce. currentEpoch === null means "cannot + // determine" (peer too old → 404, unreachable, bad sig, or the reset + // peer's desynced secret rejects our signed request) → fail closed. + const currentEpoch = await fetchPeerEpoch({ origin: peer.origin, hmacSecret: peer.hmacSecret }); + if (currentEpoch !== peer.peerInstanceId) { + app.log.warn( + `Refused self-heal for ${user.username}: home epoch ${currentEpoch ?? 'unknown'} != baseline ${peer.peerInstanceId}`, + ); + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + } + // No peer row / null baseline → legacy allow (fall through to self-heal). + // Home instance accepted the password — update our stale hash. // Do NOT set passwordChangedAt: this is a state correction, not a // password change. Setting it would invalidate existing valid JWTs.