diff --git a/docs/systems/auth.md b/docs/systems/auth.md index fd456080..807e1784 100644 --- a/docs/systems/auth.md +++ b/docs/systems/auth.md @@ -264,9 +264,9 @@ Validates format (same rules as local registration: 3-32 chars, `/^[a-z0-9_]+$/` 2. Look up user by `username` (trimmed, lowercased) 3. Reject if not found (generic "Invalid username or password") 4. Reject if `isDeleted === 1` ("This account has been deleted") -5. **Reject if `federationHomeOrphaned === 1`** (generic "Invalid username or password") — *before* password verification. This freezes any federated account whose home instance was factory-reset (a new incarnation stood up on the same domain), set by the reset quarantine (§6.3 below / `federation.md` "Instance Epoch"). Because it returns first, it blocks both the local-password path AND the self-heal path — nobody, including a new same-name user on the reset home, can authenticate into the dead-incarnation account. Reversible (admin Keep/Remove, or the real user re-registers into a fresh account). -6. Verify password via bcrypt -7. **If password invalid AND user is federated:** attempt self-healing (see below) +5. Verify password via bcrypt. **`federationHomeOrphaned === 1` no longer short-circuits here.** A federated account whose home instance was reset (a new incarnation stood up on the same domain) is now treated as **detached** — a sovereign LOCAL account whose local password hash is the sole authority (detach design §4.1). Local-hash verification proceeds normally: the correct local password logs in. The flag's only login effect is to permanently disable the self-heal path (step 7). *(Historical note: this check was previously a pre-verification freeze that blocked the local-password path too; the detach re-interpretation removed it so the real owner keeps their account instead of being locked out.)* +6. *(merged into step 5)* +7. **If password invalid AND user is federated:** if `federationHomeOrphaned === 1` (detached), reject immediately with the generic "Invalid username or password" — **no outbound request to the home domain**: there is no trusted home to consult, and re-hashing on the new incarnation's say-so would hand the account to a stranger. Otherwise attempt self-healing (see below). 8. **If password invalid AND user is local:** reject 9. Sign JWT, return `{ token, user }`. **Note:** Login does NOT mutate `users.status`. A successful login does not by itself imply a live connection (the client may never establish a WebSocket due to network failure, mobile background, error path); writing `'online'` here would produce a permanently stuck-online row that no disconnect timer cleans up. The WebSocket auth path (`ws/handler.ts`) is the single source of truth for `status = 'online'`. See `docs/systems/activity-presence.md` "Boot Reset" for the mitigation that runs on server start. @@ -296,7 +296,7 @@ Before re-hashing, the self-heal confirms the home instance is the **same incarn - **Baseline on record AND `fetchPeerEpoch` returns `null`** — epoch cannot be determined (peer too old → 404, unreachable, bad/absent response signature, **or the reset peer's desynced secret rejecting our signed request**): **fail closed — refuse self-heal.** - **Baseline on record AND the epoch matches:** allow. -Trade-off: the separate authenticated call can fail independently of the login POST, so a transient home outage during a legitimate stale-hash login fails closed. This is security-over-availability on a rare, recoverable path (fallback: a normal password change once the home is reachable); trusting an unauthenticated body would re-open the hijack. A reset peer's `null` result also doubles as a reset signal — the guard is correct even before reset-detection has flagged the peer. The direct-login freeze (step 5 of the Login Flow, `federationHomeOrphaned = 1`) is the post-re-peer complement: once the admin re-peers and the baseline updates to the new epoch, the epoch guard alone would pass again, so the freeze is what keeps the dead-incarnation account locked. +Trade-off: the separate authenticated call can fail independently of the login POST, so a transient home outage during a legitimate stale-hash login fails closed. This is security-over-availability on a rare, recoverable path (fallback: a normal password change once the home is reachable); trusting an unauthenticated body would re-open the hijack. A reset peer's `null` result also doubles as a reset signal — the guard is correct even before reset-detection has flagged the peer. This epoch guard covers the **undetected-reset window** — non-detached federated accounts whose home was reset but not yet quarantined. Once the quarantine flags an account as **detached** (`federationHomeOrphaned = 1`), the self-heal path is disabled for it entirely (step 7 of the Login Flow): the detached account is no longer a remote identity that can be self-healed at all, so the epoch comparison never runs for it — the local hash is its only authority. Re-peering the new incarnation therefore cannot re-open self-heal into a detached account. --- diff --git a/packages/server/src/routes/auth.epochGuard.test.ts b/packages/server/src/routes/auth.epochGuard.test.ts index 5283ab83..97f19e15 100644 --- a/packages/server/src/routes/auth.epochGuard.test.ts +++ b/packages/server/src/routes/auth.epochGuard.test.ts @@ -54,13 +54,82 @@ beforeEach(async () => { 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'); +// --------------------------------------------------------------------------- +// Shared self-heal harness (module scope so both the epoch-guard suite AND the +// detached-account regression test reuse the exact same arrangement). +// --------------------------------------------------------------------------- + +// 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). `federationHomeOrphaned` is left at +// its default 0 — i.e. a NON-detached account for which self-heal stays enabled. +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; +} + +describe('detached account login (federation_home_orphaned)', () => { + // A detached account (its home instance was reset → a DIFFERENT incarnation now + // owns that domain) is a sovereign LOCAL account: the local password hash is the + // only authority. Local-hash login works normally; the hijackable self-heal path + // is PERMANENTLY disabled so the new incarnation can never re-hash a stranger's + // credentials into this established identity (detach design §4.1). + const seededUsername = 'carol@orbit.ddns.net'; + const seededUserId = 'user-detached-1'; + let savedFetch: typeof globalThis.fetch; + let fetchMock: ReturnType; + + beforeEach(() => { + savedFetch = globalThis.fetch; + fetchMock = vi.fn(); + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + }); + + afterEach(() => { + globalThis.fetch = savedFetch; + }); + + async function seedDetached(): Promise { + const passwordHash = await hashPassword('correct-pw'); testDb.insert(schema.users).values({ - id: 'user-frozen-1', - username: 'carol@orbit.ddns.net', + id: seededUserId, + username: seededUsername, passwordHash, homeInstance: 'orbit.ddns.net', homeUserId: 'old-home-id', @@ -68,20 +137,46 @@ describe('login: federation_home_orphaned freeze', () => { avatarColor: '#fff', createdAt: Date.now(), }).run(); + } + it('allows login with the correct LOCAL password for a detached account', async () => { + await seedDetached(); const res = await app.inject({ - method: 'POST', - url: '/api/auth/login', - payload: { username: 'carol@orbit.ddns.net', password: 'correct-horse' }, + method: 'POST', url: '/api/auth/login', + payload: { username: seededUsername, password: 'correct-pw' }, }); - - expect(res.statusCode).toBe(401); - expect(res.json().error).toBe('Invalid username or password'); + expect(res.statusCode).toBe(200); + expect(res.json().user.id).toBe(seededUserId); }); - 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. + it('rejects a wrong password for a detached account WITHOUT contacting the home domain', async () => { + await seedDetached(); + const res = await app.inject({ + method: 'POST', url: '/api/auth/login', + payload: { username: seededUsername, password: 'wrong-pw' }, + }); + expect(res.statusCode).toBe(401); + // The self-heal path must never fire for detached accounts: no fetch at all. + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('still allows self-heal for NON-detached federated accounts (regression)', async () => { + // federationHomeOrphaned=0 (seedStaleUserAndPeer default) must NOT take the + // new early-401 branch: stale local hash + home accepts + epoch matches the + // recorded baseline → self-heal → 200. Reuses the epoch-guard MATCH arrangement. + 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('allows local-password login for a NON-detached federated account (control)', async () => { + // federationHomeOrphaned=0 with a matching local hash authenticates directly, + // proving the detach handling targets the flag rather than all federated rows. const passwordHash = await hashPassword('correct-horse'); testDb.insert(schema.users).values({ id: 'user-ok-1', @@ -93,13 +188,10 @@ describe('login: federation_home_orphaned freeze', () => { avatarColor: '#fff', createdAt: Date.now(), }).run(); - const res = await app.inject({ - method: 'POST', - url: '/api/auth/login', + 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(); }); @@ -125,50 +217,6 @@ describe('login: self-heal epoch guard', () => { 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); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 426109e5..9f732a2e 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -380,22 +380,21 @@ 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. // If the password is valid there but stale here, self-heal the local hash. if (user.homeInstance) { + // Detached account (§6.3b detach): its home domain now belongs to a + // DIFFERENT incarnation — there is no trusted home to consult. The + // self-heal path is permanently disabled: re-hashing on the new + // incarnation's say-so would hand this established account to a + // stranger. Local-hash login above remains the only (and sufficient) + // way in — the hash was only ever written by the owner's registration + // or an epoch-gated self-heal against the OLD incarnation. + if (user.federationHomeOrphaned === 1) { + return reply.code(401).send({ error: 'Invalid username or password', statusCode: 401 }); + } try { const homeUsername = user.username.includes('@') ? user.username.split('@')[0]!