From 732d146396ae0bc243ef7968e470745f402fcf91 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Thu, 2 Jul 2026 01:39:26 +0200 Subject: [PATCH] feat(federation): quarantine real accounts on reset heal (freeze + free-handle) --- .../server/src/utils/federationReset.test.ts | 101 +++++++++++++++++- packages/server/src/utils/federationReset.ts | 70 +++++++++++- 2 files changed, 164 insertions(+), 7 deletions(-) diff --git a/packages/server/src/utils/federationReset.test.ts b/packages/server/src/utils/federationReset.test.ts index 6d7a149a..a8139c52 100644 --- a/packages/server/src/utils/federationReset.test.ts +++ b/packages/server/src/utils/federationReset.test.ts @@ -187,7 +187,7 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { .where(eq(schema.users.id, id)).run(); } - it('genuine reset: soft-tombstones flagged stubs only, leaves real accounts flagged + intact, resolves journal', async () => { + it('genuine reset: soft-tombstones flagged stubs, quarantines (freeze+rename) real accounts, resolves journal', async () => { seedPeer(); seedJournal('E0'); // A local native user to be the friendship counterpart. @@ -201,7 +201,8 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { testDb.insert(schema.friends).values({ userId: 'stub-1', friendId: 'local-1', createdAt: Date.now(), }).run(); - // Flagged REAL federated account (real bcrypt) — must survive untouched + still flagged. + // Flagged REAL federated account (real bcrypt), no owned space — must survive + // (never deleted) but be quarantined: frozen + renamed to free the handle. seedUser('real-1', { passwordHash: '$2b$10$realbcrypthash' }); flag('real-1'); @@ -218,11 +219,13 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { // Heal flag cleared on the healed stub. expect(stub.federationHealPending).toBe(0); - // Real account UNTOUCHED and STILL flagged (left for Phase 2 quarantine). + // Real account NEVER deleted (content preserved) but quarantined: frozen, + // handle freed via rename, heal flag cleared (Phase 2 §6.3b). const real = testDb.select().from(schema.users).where(eq(schema.users.id, 'real-1')).get()!; expect(real.isDeleted).toBe(0); - expect(real.username).toBe('real-1@peer.example'); - expect(real.federationHealPending).toBe(1); + expect(real.username).toBe('!orphaned:real-1@peer.example'); + expect(real.federationHomeOrphaned).toBe(1); + expect(real.federationHealPending).toBe(0); // Journal resolved with the freshly-handshaked epoch. const journal = testDb.select().from(schema.federationResetEvents) @@ -277,3 +280,91 @@ describe('healResetIncarnation — heal after authenticated re-peer', () => { } }); }); + +describe('healResetIncarnation — real-account quarantine (Phase 2)', () => { + const QORIGIN = 'orbit.ddns.net'; + let uidCounter = 0; + + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + vi.clearAllMocks(); + uidCounter = 0; + }); + + afterEach(() => { + sqlite.close(); + }); + + function seedJournal(opts: { origin: string; deadEpoch: string }): void { + testDb.insert(schema.federationResetEvents).values({ + origin: opts.origin, deadEpoch: opts.deadEpoch, newEpoch: null, + detectedAt: Date.now(), resolvedAt: null, + stubCount: 0, orphanedAccountCount: 0, + }).run(); + } + + function seedRealAccount(opts: { homeInstance: string; username: string; healPending: number }): string { + const id = `real-${++uidCounter}`; + testDb.insert(schema.users).values({ + id, username: opts.username, passwordHash: '$2b$10$realbcrypthash', + homeInstance: opts.homeInstance, homeUserId: id, + isDeleted: 0, federationHealPending: opts.healPending, createdAt: Date.now(), + }).run(); + return id; + } + + function seedSpace(opts: { ownerId: string; name: string }): void { + testDb.insert(schema.spaces).values({ + id: `space-${opts.ownerId}`, name: opts.name, + ownerId: opts.ownerId, createdAt: Date.now(), + }).run(); + } + + it('renames + freezes a flagged real account with NO owned spaces', async () => { + seedJournal({ origin: QORIGIN, deadEpoch: 'E0' }); + const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 }); + + const { healResetIncarnation } = await import('./federationReset.js'); + healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted'); + + const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!; + expect(row.username).toBe(`!orphaned:${uid}@orbit.ddns.net`); // handle freed + expect(row.federationHomeOrphaned).toBe(1); // frozen + expect(row.federationHealPending).toBe(0); // processed + expect(row.isDeleted).toBe(0); // NOT deleted (content preserved) + }); + + it('freezes but does NOT rename a flagged real account that OWNS a space; surfaces it', async () => { + seedJournal({ origin: QORIGIN, deadEpoch: 'E0' }); + const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'dave@orbit.ddns.net', healPending: 1 }); + seedSpace({ ownerId: uid, name: 'Dave HQ' }); // owns a space + + const { healResetIncarnation } = await import('./federationReset.js'); + healResetIncarnation(QORIGIN, 'E1', 'initiate_accepted'); + + const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!; + expect(row.username).toBe('dave@orbit.ddns.net'); // NOT renamed (owner) + expect(row.federationHomeOrphaned).toBe(1); // frozen + expect(row.federationHealPending).toBe(0); // processed + expect(row.isDeleted).toBe(0); + // journal orphaned_account_count reflects the frozen set (1) + const j = testDb.select().from(schema.federationResetEvents) + .where(eq(schema.federationResetEvents.origin, QORIGIN)).get()!; + expect(j.orphanedAccountCount).toBe(1); + }); + + it('false-positive branch (same incarnation) does NOT quarantine real accounts', async () => { + seedJournal({ origin: QORIGIN, deadEpoch: 'E0' }); + const uid = seedRealAccount({ homeInstance: QORIGIN, username: 'carol@orbit.ddns.net', healPending: 1 }); + + const { healResetIncarnation } = await import('./federationReset.js'); + healResetIncarnation(QORIGIN, 'E0', 'accept_new'); // newEpoch == deadEpoch → false alarm + + const row = testDb.select().from(schema.users).where(eq(schema.users.id, uid)).get()!; + expect(row.username).toBe('carol@orbit.ddns.net'); // untouched + expect(row.federationHomeOrphaned ?? 0).toBe(0); // NOT frozen + expect(row.federationHealPending).toBe(0); // flags cleared (false-alarm path) + }); +}); diff --git a/packages/server/src/utils/federationReset.ts b/packages/server/src/utils/federationReset.ts index 92eea755..899c144d 100644 --- a/packages/server/src/utils/federationReset.ts +++ b/packages/server/src/utils/federationReset.ts @@ -270,7 +270,6 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P // Clear the heal flag on exactly the stubs we healed, keyed by id. // `tombstoneUser` has already randomized their `password_hash`, so re-querying // by the stub sentinel would miss them — the id list is the reliable key. - // Real accounts keep `federation_heal_pending = 1` for Phase 2. if (stubs.length > 0) { db.update(schema.users) .set({ federationHealPending: 0 }) @@ -278,8 +277,75 @@ export function healResetIncarnation(origin: string, newEpoch: string, reason: P .run(); } + // Quarantine the flagged REAL accounts (freeze + free-handle / surface). §6.3b. + const orphanedCount = quarantineOrphanedAccounts(origin); + + // Resolve the journal and refresh the orphaned-account count to the frozen set. db.update(schema.federationResetEvents) - .set({ newEpoch, resolvedAt: Date.now() }) + .set({ newEpoch, resolvedAt: Date.now(), orphanedAccountCount: orphanedCount }) .where(eq(schema.federationResetEvents.origin, origin)) .run(); } + +/** + * Post-heal quarantine of the dead incarnation's REAL federated accounts (design + * §6.3b). Called from `healResetIncarnation`'s genuine-reset branch AFTER the stub + * soft-tombstone loop. Real accounts carry non-re-syncable local content and are + * NEVER auto-deleted — they are FROZEN and surfaced to the admin. + * + * For every flagged real account (`federation_heal_pending = 1`, + * `passwordHash != REPLICATED_STUB_SENTINEL`, `isDeleted = 0`) for this origin: + * - Set `federation_home_orphaned = 1` (FREEZE). This is universal — it is what + * closes the post-re-peer hijack (the Task-2 epoch guard passes once the + * baseline is updated to the new epoch, so the freeze is the only remaining + * barrier). The direct-login freeze (auth.ts) enforces it. + * - If the account OWNS local spaces: do NOT rename it. Space ownership must be + * resolved by a human (admin Remove → transfer/delete first). Renaming an owner + * would orphan the ownerId reference into a `!orphaned:` handle, confusing to + * members. It stays frozen + surfaced. + * - Otherwise: rename `username → !orphaned:{uid}@{domain}` to FREE the handle so + * a returning same-name user re-registers into a clean fresh account instead of + * colliding (defends BOTH login uniqueness AND the registration tier-2 + * stub-resolution upgrade path — see the plan's collision analysis). + * - Clear `federation_heal_pending` (processed). + * + * Content (space messages, memberships, reactions) is preserved in all cases. + * + * @returns the number of accounts quarantined (frozen) — used to refresh the + * journal's `orphaned_account_count`. + */ +export function quarantineOrphanedAccounts(origin: string): number { + const db = getDb(); + const domain = extractDomain(origin); + + const accounts = db + .select({ id: schema.users.id }) + .from(schema.users) + .where(and( + eq(schema.users.federationHealPending, 1), + eq(schema.users.isDeleted, 0), + sql`${schema.users.passwordHash} != ${REPLICATED_STUB_SENTINEL}`, + homeInstanceMatch(origin), + )) + .all(); + + for (const acct of accounts) { + const ownsSpace = db + .select({ id: schema.spaces.id }) + .from(schema.spaces) + .where(eq(schema.spaces.ownerId, acct.id)) + .get(); + + const updates: Record = { + federationHomeOrphaned: 1, + federationHealPending: 0, + }; + if (!ownsSpace) { + // Free the handle only for non-owners. + updates.username = `!orphaned:${acct.id}@${domain}`; + } + db.update(schema.users).set(updates).where(eq(schema.users.id, acct.id)).run(); + } + + return accounts.length; +}