diff --git a/docs/systems/federation.md b/docs/systems/federation.md index c278144d..c6b85ffd 100644 --- a/docs/systems/federation.md +++ b/docs/systems/federation.md @@ -406,6 +406,7 @@ Allows a home instance to remove a user's replicated identity from a remote inst **Behavior:** - **Attribution guard:** Rejects with `403` if the user's `homeInstance` doesn't match the `X-Federation-Origin` of the signing peer. Prevents one instance from deleting another instance's users. +- **Detached-account guard:** After the attribution guard, if the resolved user has `federation_home_orphaned = 1` (detached — its home domain was reset and it is now a sovereign local account, see §4.2/§4.3 of the orphaned-account-detach design), returns an idempotent `200 { success: true }` **without deleting**. A new incarnation on the reset domain must never delete an established account by replaying its old `homeUserId`. - **Idempotent:** Returns `{ success: true }` for already-deleted or nonexistent users (no error). - **Owned spaces check:** Returns `409` with `{ ownedSpaces: string[] }` if the user owns any spaces on the remote. The user must transfer or delete those spaces before identity removal proceeds. - **Mode `"soft"`:** Calls `tombstoneUser(uid, { purgeContent: false })` — anonymizes the user row and removes the user from spaces, friends, DM membership (`dm_members`), and read-states. The `purgeContent: false` flag skips only `reactions`, `dm_reactions`, and the user's space `messages` (with attachments + embeds); DM membership cleanup and orphaned-DM purge always run in both modes (per `userDeletion.ts:121-126, 169-202`) because zero-member DM channels are unreachable garbage regardless of authorship retention. @@ -503,6 +504,7 @@ Two layers of replay protection: - Three-tier lookup: homeUserId match → domain + username hint match → not found - Tier 1: delegates to `resolveLocalUser` (fast path) - Tier 2: uses `extractDomain(homeInstance)` + `hints.username` to match stubs created by the auth registration path (which may have a different homeUserId) +- **Tier 2 excludes detached accounts** (`federation_home_orphaned = 1`): a detached account is sovereign and must never be re-bound to the reset domain's new incarnation via username heuristics — that is exactly how a new same-name user would capture the established account. **Tier 1 (`homeUserId` match) is deliberately NOT excluded:** the new incarnation mints fresh `homeUserId`s, so a tier-1 hit on a detached row is a legitimate historical reference (e.g. an old group-DM attribution relayed by a third instance), not the new incarnation. Mutations are blocked at their own sites (profile_update handler, S2S identity delete). - Side-effect-free — does not modify any records - When multiple candidates match in tier 2, prefers real accounts over stubs, then most profile data - **Use when:** Read-only lookup that needs to find users created by either auth or relay path @@ -1311,6 +1313,8 @@ Profile data is synced server-to-server. The home instance is authoritative — **Processing:** Remote overwrites all 6 mutable fields unconditionally; `displayName` falls back to `payload.displayName ?? payload.username`. Rejects if incoming `profileUpdatedAt ≤ stored`. Broadcasts `user_updated` to local WS clients. +**Detached-account guard:** After the domain-collision check and before the version check, if the resolved `localUser` has `federation_home_orphaned = 1` (detached — home domain was reset, now a sovereign local account), the event is **acked (messageId pushed to `accepted`) and skipped without applying**. The reset domain's new incarnation must never overwrite an established account's profile by replaying its old `homeUserId`. Ack rather than reject because the sender legitimately considers the identity theirs to update; from this side the update simply no-ops. + #### Profile Image File Replication When a `profile_update` relay carries avatar or banner absolute URLs, the receiving instance downloads the image files locally rather than storing remote URLs. This eliminates cross-origin dependencies — avatars render from the local `/api/uploads/` endpoint. diff --git a/packages/server/src/routes/federation.detachedGuards.test.ts b/packages/server/src/routes/federation.detachedGuards.test.ts new file mode 100644 index 00000000..d0f2c0c8 --- /dev/null +++ b/packages/server/src/routes/federation.detachedGuards.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import Database from 'better-sqlite3'; +import { drizzle } from 'drizzle-orm/better-sqlite3'; +import { eq } from 'drizzle-orm'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { randomUUID } from 'node:crypto'; +import * as schema from '../db/schema.js'; +import { setWorkerId } from '../utils/snowflake.js'; +import { signRequest } from '../utils/federationAuth.js'; +import type { FederationRelayEvent } from '@backspace/shared'; + +setWorkerId(9); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +type TestDb = ReturnType>; +let sqlite: Database.Database; +let testDb: TestDb; + +const PEER_ORIGIN = 'https://orbit.test'; +const PEER_DOMAIN = 'orbit.test'; +const PEER_SECRET = 'a'.repeat(64); + +vi.mock('../db/index.js', () => ({ + getDb: () => testDb, + getRawDb: () => sqlite, + schema, +})); + +vi.mock('../ws/handler.js', () => ({ + connectionManager: { + sendToUser: vi.fn(), + sendToSpace: vi.fn(), + sendToDmMembers: vi.fn(), + sendToAdmins: vi.fn(), + getAllOnlineUserIds: () => [], + evictFederatedCallsForHost: vi.fn(), + federatedCalls: new Map(), + isUserOnline: vi.fn(), + lateBindFederatedCall: vi.fn(), + forceDisconnectUser: vi.fn(), + }, +})); + +function applyMigrations(db: Database.Database): void { + const dir = path.resolve(__dirname, '../../drizzle'); + for (const f of fs.readdirSync(dir).filter(f => f.endsWith('.sql')).sort()) { + const sqlText = fs.readFileSync(path.join(dir, f), 'utf8'); + for (const stmt of sqlText.split(/-->\s*statement-breakpoint/)) { + const clean = stmt.trim(); + if (clean) db.exec(clean); + } + } +} + +function seedActivePeer(): void { + testDb.insert(schema.federationPeers).values({ + id: 'peer-1', + origin: PEER_ORIGIN, + hmacSecret: PEER_SECRET, + status: 'active', + nonceSupported: 1, + createdAt: Date.now(), + lastSeenAt: Date.now(), + consecutiveFailures: 0, + consecutiveAuthFailures: 0, + } as typeof schema.federationPeers.$inferInsert).run(); +} + +const DETACHED_ID = 'detached-1'; +const DETACHED_HOME_UID = 'old-home-uid'; + +// A REAL federated account whose home domain (orbit.test) was reset. It has been +// detached (federationHomeOrphaned = 1): sovereign local account, never re-bindable +// to the reset domain's new incarnation. +function seedDetachedAccount(): void { + testDb.insert(schema.users).values({ + id: DETACHED_ID, + username: 'alice@orbit.test', + displayName: 'Alice', + passwordHash: '$2b$10$abcdefghijklmnopqrstuv', // real bcrypt-like hash + status: 'offline', + isAdmin: 0, + isDeleted: 0, + homeInstance: PEER_DOMAIN, + homeUserId: DETACHED_HOME_UID, + federationHomeOrphaned: 1, + profileUpdatedAt: 1000, + createdAt: Date.now(), + }).run(); +} + +async function buildApp(): Promise { + const app = Fastify({ logger: false }); + const { federationRoutes } = await import('./federation.js'); + await app.register(federationRoutes); + await app.ready(); + return app; +} + +function signedHeaders(body: string): Record { + const timestamp = Date.now(); + const nonce = randomUUID(); + const sig = signRequest(body, PEER_SECRET, timestamp, nonce); + return { + 'X-Federation-Origin': PEER_ORIGIN, + 'X-Federation-Timestamp': String(timestamp), + 'X-Federation-Nonce': nonce, + 'X-Federation-Signature': `sha256=${sig}`, + 'Content-Type': 'application/json', + }; +} + +beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + seedActivePeer(); + seedDetachedAccount(); +}); + +describe('S2S identity delete — detached account guard', () => { + it('skips a detached account (idempotent 200, row intact)', async () => { + const app = await buildApp(); + const body = JSON.stringify({ + homeUserId: DETACHED_HOME_UID, + homeInstance: PEER_DOMAIN, + mode: 'full', + }); + const res = await app.inject({ + method: 'DELETE', + url: '/api/federation/identity', + headers: signedHeaders(body), + payload: body, + }); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body)).toEqual({ success: true }); + + const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get(); + expect(row?.isDeleted).toBe(0); // NOT deleted — detached account is sovereign + await app.close(); + }); +}); + +describe('S2S profile_update — detached account guard', () => { + it('skips a detached account (acked, profile unchanged)', async () => { + const fed = await import('./federation.js'); + const event: FederationRelayEvent = { + eventType: 'profile_update', + contextType: 'profile', + messageId: 'm-hijack', + encryptionVersion: 0, + timestamp: Date.now(), + profileUpdate: { + homeUserId: DETACHED_HOME_UID, + homeInstance: PEER_DOMAIN, + profileUpdatedAt: 999999, // newer than stored 1000 — would apply if not guarded + username: 'alice', + displayName: 'Hijacked', + avatar: null, + banner: null, + accentColor: null, + avatarColor: null, + bio: null, + }, + }; + const accepted: string[] = []; + const rejected: Array<{ messageId: string; reason: string }> = []; + await fed.processProfileUpdateEvent(event, PEER_DOMAIN, testDb, accepted, rejected); + + // Acked (not rejected) — the sender considers this identity theirs to update. + expect(rejected).toEqual([]); + expect(accepted).toEqual(['m-hijack']); + + // But the detached row's profile is untouched. + const row = testDb.select().from(schema.users).where(eq(schema.users.id, DETACHED_ID)).get(); + expect(row?.displayName).toBe('Alice'); + expect(row?.profileUpdatedAt).toBe(1000); + }); +}); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 49fb7ec0..d65cba19 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2322,6 +2322,15 @@ export async function federationRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'Attribution mismatch: you can only delete users from your own instance', statusCode: 403 }); } + // Detached (home-orphaned) accounts are sovereign local accounts. The + // domain's new incarnation must not delete them by replaying old + // homeUserIds. Idempotent 200: from the caller's perspective this + // identity does not exist here. + if (user.federationHomeOrphaned === 1) { + console.log(`[federation] Ignoring S2S identity delete for detached account ${user.id} from ${fedHeaders.origin}`); + return reply.code(200).send({ success: true }); + } + // 5. Check for owned spaces const ownedSpaces = db.select({ id: schema.spaces.id, name: schema.spaces.name }) .from(schema.spaces) @@ -3478,6 +3487,10 @@ export function findFederatedUser( and( eq(schema.users.homeInstance, domain), eq(schema.users.isDeleted, 0), + // Detached (home-orphaned) accounts are sovereign: never re-bindable to + // the domain's new incarnation via username heuristics — that is exactly + // how a new same-name user would capture the established account. + eq(schema.users.federationHomeOrphaned, 0), or( sql`lower(substr(${schema.users.username}, 1, instr(${schema.users.username}, '@') - 1)) = ${hintLower}`, and( @@ -6110,6 +6123,16 @@ export async function processProfileUpdateEvent( return; } + // Detached accounts are sovereign: the domain now belongs to a different + // incarnation, which must never overwrite the established account's profile + // by replaying its old homeUserId. Ack (not reject) — the sender considers + // this identity theirs to update; from our side the update simply no-ops. + if (localUser.federationHomeOrphaned === 1) { + console.log(`[federation] Skipping profile_update for detached account ${localUser.id} (home-orphaned)`); + accepted.push(event.messageId); + return; + } + // Version check: reject stale/duplicate events const storedTs = localUser.profileUpdatedAt ?? 0; const incomingTs = payload.profileUpdatedAt ?? 0; diff --git a/packages/server/src/routes/federation.userLookup.test.ts b/packages/server/src/routes/federation.userLookup.test.ts index 2826af4a..e3872452 100644 --- a/packages/server/src/routes/federation.userLookup.test.ts +++ b/packages/server/src/routes/federation.userLookup.test.ts @@ -71,12 +71,14 @@ function seedUser(opts: { avatarColor?: string | null; banner?: string | null; bio?: string | null; + passwordHash?: string; + federationHomeOrphaned?: 0 | 1; }): void { testDb.insert(schema.users).values({ id: opts.id, username: opts.username, displayName: opts.displayName ?? null, - passwordHash: 'x', + passwordHash: opts.passwordHash ?? 'x', status: 'offline', isAdmin: 0, isDeleted: opts.isDeleted ?? 0, @@ -87,6 +89,7 @@ function seedUser(opts: { avatarColor: opts.avatarColor ?? null, banner: opts.banner ?? null, bio: opts.bio ?? null, + federationHomeOrphaned: opts.federationHomeOrphaned ?? 0, createdAt: Date.now(), }).run(); } @@ -249,3 +252,37 @@ describe('POST /api/federation/users/lookup', () => { expect(blocked.headers['retry-after']).toBe('60'); }); }); + +describe('findFederatedUser — detached (home-orphaned) accounts', () => { + beforeEach(() => { + sqlite = new Database(':memory:'); + testDb = drizzle(sqlite, { schema }); + applyMigrations(sqlite); + // A REAL federated account whose home domain was reset. It has been detached + // (federationHomeOrphaned = 1): it now owns its identity locally and must + // never be re-captured by the reset domain's new incarnation. + seedUser({ + id: 'detached-1', + username: 'alice@peer.example', + homeInstance: 'peer.example', + homeUserId: 'old-home-uid', + passwordHash: '$2b$10$abcdefghijklmnopqrstuv', // real bcrypt-like hash, not a stub + federationHomeOrphaned: 1, + }); + }); + + it('tier-2 never matches a detached (home-orphaned) account', async () => { + const { findFederatedUser } = await import('./federation.js'); + // Fresh homeUserId → tier-1 miss; the reset domain replays 'alice' as a hint. + const found = findFederatedUser('new-home-uid', 'peer.example', testDb, { username: 'alice' }); + expect(found).toBeUndefined(); + }); + + it('tier-1 (homeUserId) still resolves a detached account for historical references', async () => { + const { findFederatedUser } = await import('./federation.js'); + // The original homeUserId is a legitimate historical reference (e.g. an old + // group-DM attribution relayed by a third instance) — tier-1 must still resolve it. + const found = findFederatedUser('old-home-uid', 'peer.example', testDb, { username: 'alice' }); + expect(found?.federationHomeOrphaned).toBe(1); + }); +});