feat(federation): login self-heal epoch guard (fetchPeerEpoch, fail-closed)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user