feat(federation): login self-heal epoch guard (fetchPeerEpoch, fail-closed)

This commit is contained in:
Jannis Braun
2026-07-02 01:33:42 +02:00
parent 9b945ba5b7
commit e0a0d92fe7
2 changed files with 160 additions and 3 deletions
@@ -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<void> {
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<Response> => {
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);
});
});
+36 -2
View File
@@ -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<void> {
@@ -413,6 +414,39 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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.