From 9b945ba5b7746f09a576d39b9d286d50f5b8b962 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:27:47 +0200 Subject: [PATCH] feat(federation): freeze login for reset-orphaned federated accounts --- .../server/src/routes/auth.epochGuard.test.ts | 105 ++++++++++++++++++ packages/server/src/routes/auth.ts | 11 ++ 2 files changed, 116 insertions(+) create mode 100644 packages/server/src/routes/auth.epochGuard.test.ts diff --git a/packages/server/src/routes/auth.epochGuard.test.ts b/packages/server/src/routes/auth.epochGuard.test.ts new file mode 100644 index 00000000..48424127 --- /dev/null +++ b/packages/server/src/routes/auth.epochGuard.test.ts @@ -0,0 +1,105 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import * as schema from '../db/schema.js'; +import { hashPassword } from '../utils/auth.js'; +import { setWorkerId } from '../utils/snowflake.js'; + +setWorkerId(2); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Module-level mutable state — mirrors auth.test.ts: the getDb mock closes over +// a getter so each beforeEach can swap in a fresh in-memory DB. +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; +let app: FastifyInstance; + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +function applyMigrations(db: Database.Database): void { + const migrationsDir = path.resolve(__dirname, '../../drizzle'); + const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort(); + for (const f of files) { + const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8'); + const statements = sqlText.split(/-->\s*statement-breakpoint/); + for (const stmt of statements) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +async function buildApp(): Promise { + const { authRoutes } = await import('./auth.js'); + const f = Fastify(); + await f.register(authRoutes); + return f; +} + +beforeEach(async () => { + sqlite = new Database(':memory:'); + sqlite.pragma('foreign_keys = ON'); + applyMigrations(sqlite); + testDb = drizzle(sqlite, { schema }); + app = await buildApp(); +}); + +describe('login: federation_home_orphaned freeze', () => { + it('rejects login for a frozen (orphaned) federated account even with the correct password', async () => { + // Seed a real federated account with a known password, then freeze it. + const passwordHash = await hashPassword('correct-horse'); + testDb.insert(schema.users).values({ + id: 'user-frozen-1', + username: 'carol@orbit.ddns.net', + passwordHash, + homeInstance: 'orbit.ddns.net', + homeUserId: 'old-home-id', + federationHomeOrphaned: 1, + avatarColor: '#fff', + createdAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'carol@orbit.ddns.net', password: 'correct-horse' }, + }); + + expect(res.statusCode).toBe(401); + expect(res.json().error).toBe('Invalid username or password'); + }); + + it('allows login for a non-frozen federated account with the correct password (freeze is targeted)', async () => { + // Control: same shape, but federationHomeOrphaned = 0 must authenticate, + // proving the freeze targets the flag rather than all federated accounts. + const passwordHash = await hashPassword('correct-horse'); + testDb.insert(schema.users).values({ + id: 'user-ok-1', + username: 'dave@orbit.ddns.net', + passwordHash, + homeInstance: 'orbit.ddns.net', + homeUserId: 'live-home-id', + federationHomeOrphaned: 0, + avatarColor: '#fff', + createdAt: Date.now(), + }).run(); + + const res = await app.inject({ + method: 'POST', + url: '/api/auth/login', + payload: { username: 'dave@orbit.ddns.net', password: 'correct-horse' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().token).toBeTruthy(); + }); +}); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index da3f6473..3d1666fb 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -379,6 +379,17 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(401).send({ error: 'This account has been deleted', statusCode: 401 }); } + // A federated account whose home instance was reset (a new incarnation stood + // up on the same domain) is FROZEN: its identity cannot be cryptographically + // proven continuous across the wipe (design §2 non-goal), so we must never let + // anyone — including a new same-name user on the reset home — authenticate into + // it. Freezing is reversible (admin Keep/Remove, or the real user re-registers + // into a fresh account). This is the enforcement half of the §6.3b quarantine; + // it blocks the local-password path AND, by returning first, the self-heal path. + if (user.federationHomeOrphaned === 1) { + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } + const validPassword = await verifyPassword(password, user.passwordHash); if (!validPassword) { // For federated users, try verifying against the home instance.