feat(federation): detached accounts keep local-password login; self-heal permanently disabled (detach spec §4.1)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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]!
|
||||
|
||||
Reference in New Issue
Block a user