feat(federation): detached accounts keep local-password login; self-heal permanently disabled (detach spec §4.1)

This commit is contained in:
Jannis Braun
2026-07-02 18:28:55 +02:00
parent 1629f8fbe1
commit 79c1138813
3 changed files with 125 additions and 78 deletions
+4 -4
View File
@@ -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.
---
@@ -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<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;
}
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<typeof vi.fn>;
beforeEach(() => {
savedFetch = globalThis.fetch;
fetchMock = vi.fn();
globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = savedFetch;
});
async function seedDetached(): Promise<void> {
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<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);
+10 -11
View File
@@ -380,22 +380,21 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
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]!