fix(federation): close detached-account gaps from final review — presence/hydrate guards, ack re-detect clear, self-delete password (detach spec §4.3/§4.4/§4.6)

This commit is contained in:
Jannis Braun
2026-07-02 19:34:32 +02:00
parent 172398171a
commit 13d050c1bb
10 changed files with 190 additions and 8 deletions
+1 -1
View File
@@ -380,7 +380,7 @@ When a user changes their password on their home instance, `authStore.changePass
**Pre-checks:**
1. `username` must match stored username (confirmation safeguard)
2. Local users must provide and verify `password`; federated users rely on JWT auth
2. Native local users **and detached accounts** (`federation_home_orphaned = 1`) must provide and verify `password` against the local hash; non-detached federated users rely on JWT auth (their home instance already vouches for them). A detached account is a sovereign local account with no home verifying anything, so it follows the LOCAL rule — the same self-destruct protection as a native account, and mirroring the change-password rule (§5, detach spec §4.4). Condition: `!user.homeInstance || user.federationHomeOrphaned === 1`. Missing password → 400; wrong password → 403.
3. Must not own any spaces (returns 400 with `ownedSpaces` list)
**Client-side flow** (`authStore.deleteAccount()`):
+6 -2
View File
@@ -366,7 +366,7 @@ Detection (`markPeerReset`) only snapshots + journals + notifies; it destroys no
- For every flagged real account (`federation_heal_pending = 1`, non-stub, `isDeleted = 0`, `homeInstanceMatch`): set `federation_home_orphaned = 1` and clear `federation_heal_pending`. **That is all** — this is a flag-only **detach**, not a freeze.
- **`federation_home_orphaned = 1` means "DETACHED / sovereign local account,"** not "frozen." The account was cut loose from its (now-reset) home instance and operates as a purely local account from here on: it logs in with its **local password** (`auth.ts` no longer blocks a detached account before password verify — only the self-heal branch is permanently disabled for it, see `auth.md` §4), and it gains local profile edit + local change-password (`users.ts`). There is **no login freeze**.
- **No rename.** The username is preserved — first-come-first-served on this instance. There is **no `!orphaned:{uid}@{domain}` handle-freeing** and **no space-owner special case**: all real accounts are treated uniformly and owners simply keep managing their spaces.
- **What closes the post-re-peer hijack** is NOT a login freeze. It is the combination of (a) the login self-heal being **permanently disabled** for detached accounts (`auth.ts` returns 401 in the failed-local-password branch without contacting the home domain — a new incarnation can never re-hash its way in) and (b) the S2S identity-binding guards, which **exclude detached rows** on every domain-keyed surface: `findFederatedUser` tier-2 (`federation_home_orphaned = 0` predicate, ~line 3522), the S2S `profile_update` handler (accept-and-skip, ~line 6162), and the S2S `DELETE /api/federation/identity` guard (idempotent 200, no deletion, ~line 2357). See "S2S Identity Deletion" above and design §4.3.
- **What closes the post-re-peer hijack** is NOT a login freeze. It is the combination of (a) the login self-heal being **permanently disabled** for detached accounts (`auth.ts` returns 401 in the failed-local-password branch without contacting the home domain — a new incarnation can never re-hash its way in) and (b) the S2S identity-binding guards, which **exclude detached rows** on every domain-keyed surface: `findFederatedUser` tier-2 (`federation_home_orphaned = 0` predicate, ~line 3522), the S2S `profile_update` handler (accept-and-skip, ~line 6162), the S2S `presence_update` handler (accept-and-skip, after the domain-collision check), the `hydrateReplicatedUserProfile` fill-empty path (no-op early return alongside the native-user skip), and the S2S `DELETE /api/federation/identity` guard (idempotent 200, no deletion, ~line 2357). Every domain-keyed **mutation** that a tier-1 (`homeUserId`) hit can reach is guarded at its own site — a tier-1 hit on a detached row is a legitimate historical read, but no write is applied. See "S2S Identity Deletion" above and design §4.3.
- Content (space messages, memberships, reactions) and usernames are preserved in all cases. No `user_updated` broadcast is emitted (nothing visible changes). The returned count refreshes the journal's `orphaned_account_count`.
**Login self-heal epoch guard (design §6.3a).** The federated password self-heal (`auth.ts` §4) gates re-hashing on the home instance's current epoch, read via the authenticated `fetchPeerEpoch(peer)` (HMAC-signed both ways): no baseline on record → allow (legacy); baseline differs from the fetched epoch → refuse; epoch can't be determined (`fetchPeerEpoch` null — 404/unreachable/bad-sig/desynced secret) → **fail closed/refuse**; match → allow. This closes the *pre*-re-peer hijack (a reset home accepting a new same-name user's password during the undetected-reset window). The *post*-re-peer window is closed by detach: once an account is detached, its self-heal path is permanently disabled and the S2S binding guards exclude it (above), so the new incarnation has zero influence over it. Full three-way in `auth.md` §4.
@@ -505,7 +505,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).
- **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, presence_update handler, `hydrateReplicatedUserProfile` fill-empty, 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
@@ -1294,6 +1294,8 @@ When relay events carry `FederationRelayProfileSnapshot` data:
`hydrateReplicatedUserProfile` is **best-effort fill-empty only**: it never overwrites an existing avatar/banner/displayName/bio. This protects locally-downloaded bare filenames produced by `processProfileUpdateEvent` (which carries the monotonic `profileUpdatedAt` version) from being clobbered back to absolute URLs on the next DM/friend relay. Authoritative updates flow exclusively through the version-checked `profile_update` event.
**Detached-account guard:** Alongside the native-user skip (`!user.homeInstance → return`), the function also **no-ops on detached rows** (`federation_home_orphaned = 1 → return user`). A detached account retains its `homeInstance` for provenance (design §7), so the native-user skip alone would not catch it; without this guard a DM/friend relay from the reset domain's new incarnation, resolved via an old `homeUserId` tier-1 hit, could fill the sovereign account's empty fields. This is the same domain-keyed mutation class as `profile_update`/`presence_update`, guarded at its own site (design §4.3).
When the function does fill an empty avatar/banner, it calls `downloadProfileAsset` against the user's home instance and stores the resulting **local bare filename**. Only on download failure does it fall back to the absolute URL — matching the behavior of `processProfileUpdateEvent`.
#### Profile Sync (S2S)
@@ -1370,6 +1372,8 @@ No-op for replicated users (we don't own their presence).
**Receiver:** `processPresenceUpdateEvent` (`routes/federation.ts`). Strict attribution — `payload.homeInstance` domain MUST equal source peer's domain. Resolves the local stub by `homeUserId`, validates the stub's `homeInstance` matches the payload's domain, updates the stub's `status`, and broadcasts a WS `presence_update` to local users via `collectProfileBroadcastTargetIds(stub.id)` — friends, DM members, and space co-members.
**Detached-account guard:** After the domain-collision check and before the status write, if the resolved stub 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 flip an established account's presence by replaying its old `homeUserId`. Ack (not reject) mirrors the `profile_update` guard rationale — the sender considers the identity theirs, so we no-op rather than trigger a retry loop.
**Peer lifecycle hooks** (`utils/federationPresence.ts`):
- **`onPeerActivated`** invokes `snapshotPresenceForPeer(origin)` — emits a `presence_update` only for online natives that have an S2S relationship with the peer (friend/DM with a peer-stub, or `replicatedInstances` opt-in for the peer origin). Snapshot work scales with relationship count, not native count.
- **`onPeerDeactivated`** invokes `markPeerStubsOffline(origin)` — flips every stub from that peer to `offline` and broadcasts a local `presence_update` so users see them go offline immediately.