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 Fastify, { type FastifyInstance } from 'fastify';
|
||||||
import Database from 'better-sqlite3';
|
import Database from 'better-sqlite3';
|
||||||
import { drizzle } from 'drizzle-orm/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 * as schema from '../db/schema.js';
|
||||||
import { hashPassword } from '../utils/auth.js';
|
import { hashPassword } from '../utils/auth.js';
|
||||||
import { setWorkerId } from '../utils/snowflake.js';
|
import { setWorkerId } from '../utils/snowflake.js';
|
||||||
|
import { buildFederationHeaders } from '../utils/federationAuth.js';
|
||||||
|
|
||||||
setWorkerId(2);
|
setWorkerId(2);
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -103,3 +104,125 @@ describe('login: federation_home_orphaned freeze', () => {
|
|||||||
expect(res.json().token).toBeTruthy();
|
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 type { FastifyInstance } from 'fastify';
|
||||||
import { eq } from 'drizzle-orm';
|
import { eq, or } from 'drizzle-orm';
|
||||||
import { getDb, schema } from '../db/index.js';
|
import { getDb, schema } from '../db/index.js';
|
||||||
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
import { hashPassword, verifyPassword, signJwt } from '../utils/auth.js';
|
||||||
import { generateSnowflake } from '../utils/snowflake.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 type { RegisterRequest, LoginRequest, AuthResponse } from '@backspace/shared';
|
||||||
import { AVATAR_COLORS } from '@backspace/shared';
|
import { AVATAR_COLORS } from '@backspace/shared';
|
||||||
import { sanitizeUser } from '../utils/sanitize.js';
|
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';
|
import { getInviteByToken, inviteStatus, redeemInvite, InviteUnavailableError } from '../utils/inviteService.js';
|
||||||
|
|
||||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||||
@@ -413,6 +414,39 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
|||||||
clearTimeout(timeout);
|
clearTimeout(timeout);
|
||||||
|
|
||||||
if (homeResponse.ok) {
|
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.
|
// Home instance accepted the password — update our stale hash.
|
||||||
// Do NOT set passwordChangedAt: this is a state correction, not a
|
// Do NOT set passwordChangedAt: this is a state correction, not a
|
||||||
// password change. Setting it would invalidate existing valid JWTs.
|
// password change. Setting it would invalidate existing valid JWTs.
|
||||||
|
|||||||
Reference in New Issue
Block a user