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