feat(federation): owner-initiated detached-account re-attach — proof-gated re-bind with stub merge (re-attach spec §3.2, §3.3)

This commit is contained in:
Jannis Braun
2026-07-03 02:06:00 +02:00
parent 7bff6c1a1b
commit d45366c4ff
6 changed files with 474 additions and 1 deletions
+10 -1
View File
@@ -11,9 +11,12 @@ POST /auth/register { username, password, displayName?, avatarColor?, ho
GET /auth/check-username ?username= → { available, reason? }
GET /auth/check-invite ?token= → CheckInviteResponse
POST /auth/login { username, password } → { token, user }
POST /auth/attach-proof (JWT, rate-limited 5/15min) { targetDomain } → { token } (AttachProofResponse)
```
**`POST /auth/login`** — request/response shape unchanged, but two internal controls from instance-epoch self-healing gate the flow: (1) an account with `federationHomeOrphaned = 1` (home instance factory-reset) is rejected with the generic 401 *before* password verification; (2) the federated password self-heal now runs an **epoch guard** — it re-hashes the stale local password only if the home instance's authenticated epoch (`fetchPeerEpoch`) matches the trusted baseline, failing closed when the epoch differs or can't be determined. No wire-shape change. See `auth.md` §4.
**`POST /auth/attach-proof`** — JWT-authenticated. Mints a one-time 256-bit token (`randomBytes(32).toString('hex')`) for the logged-in **native** user, stored in `federation_attach_proofs` bound to `{ homeUserId, targetDomain, expiresAt = now+60s }`; expired/used rows are janitored on each mint. The token is handed to the peer named by `targetDomain`, which redeems it via `POST /federation/verify-attach-proof` to re-attach the caller's detached account there. See `federation.md` "S2S Detached-Account Re-Attach Proof" and re-attach spec §3.1.
**`POST /auth/login`** — request/response shape unchanged, but two internal controls from instance-epoch self-healing gate the flow: (1) an account with `federationHomeOrphaned = 1` (home instance factory-reset) is **detached** — a sovereign local account whose local password hash is the sole authority; it logs in normally with that local password (the detach pivot removed the old pre-verification freeze), and the flag's only login effect is to permanently disable self-heal (step 7); (2) for non-detached federated accounts, the password self-heal runs an **epoch guard** — it re-hashes the stale local password only if the home instance's authenticated epoch (`fetchPeerEpoch`) matches the trusted baseline, failing closed when the epoch differs or can't be determined. A detached account can be re-bound to the owner's new home identity via `POST /users/@me/reattach` (re-attach spec §3.2), which clears the flag and re-enables normal federated semantics. No wire-shape change. See `auth.md` §4.
**`POST /auth/register` gating** — branches on whether `homeInstance` is set:
- **Federated path** (`homeInstance` set): gated solely by `instance_settings.federatedRegistrationOpen`. `inviteToken` is ignored entirely (not validated, not consumed). 403 `Federated registration is closed on this instance` when closed. Existing federated stubs (relay-created, `passwordHash = '!federation-replicated'`) upgrade in place — login is never blocked by this gate.
@@ -333,6 +336,8 @@ POST /federation/relay (HMAC-signed S2S) FederationRelayRequest (+
POST /federation/sync (HMAC-signed S2S) { sinceTimestamp, limit?, dmChannelId?, federatedId?, contextType? } → { events[], hasMore, checkpoint }
POST /federation/users/lookup (HMAC-signed S2S, rate-limited 60/min/peer) { username } → { found, user? }
POST /federation/epoch (HMAC-signed S2S, HMAC-signed response) {} → { instanceId }
POST /federation/verify-attach-proof (HMAC-signed S2S, HMAC-signed response, rate-limited 60/min/peer) { token } → { valid:true, homeUserId, username } | { valid:false }
POST /users/@me/reattach (JWT as detached account, rate-limited 5/15min) { token } → { success:true, user } (ReattachResponse) | 400/401/403/404/409
```
**`POST /api/federation/peer/accept`** — public, IP-rate-limited. Optional `approvalToken` (64-hex) on the request body proves mutual admin approval; required to promote an `awaiting_approval` row to `active` when the receiver has `autoAcceptPeering=0`. The receiver returns it in the 202 body when queueing the request for admin review (`{ queued: true, message, approvalToken }`); the initiator stores it and the receiver's `/approve` later forwards it back. See `federation.md` §1 "Approval Token Verification" for the full lifecycle and threat model.
@@ -368,6 +373,10 @@ Disposition actions reuse existing endpoints (no new mutating routes): one-click
**`POST /api/federation/users/lookup`** — HMAC-authenticated S2S endpoint. Resolves a username on this instance to its canonical `(homeUserId, profile snapshot)`. Used by the cross-instance friend-add flow on the sender's home server before queuing a `friend_request_create` event. Responds to native, non-deleted users only; ignores `discoverable`. Returns `{ found: false, code: 'user_not_found' }` for stubs, tombstoned users, or unknown handles. See `federation.md` §1 "S2S User Lookup" for the full contract.
**`POST /api/users/@me/reattach`** — the owner-initiated detached-account re-attach (re-attach spec §3.2). JWT-authenticated as the detached account but registered in `routes/federation.ts` (consumes the peer HMAC channel + profile machinery). Body `{ token }` (64-hex; else 400). Re-binds the sovereign detached row to the owner's new home identity **only** when both proofs hold: the session IS the detached account AND the token verifies with the home peer over signed S2S (`POST /federation/verify-attach-proof`). Guards: non-detached/native → 403; missing/tombstoned session → 404 (already 401'd at `authenticate`); home not an active peer → 409; proof invalid → 401; new identity held by a **non-stub** local account → 409. On success: merges any pre-existing replicated stub for the new identity into the detached row (repoint+dedupe every `users.id` FK a DM/friend replica can hold, then delete the stub), sets `home_user_id`/`federation_home_orphaned=0`, adopts the new home username base if it differs (collision-suffix), nulls `profile_updated_at`, pulls+applies the home profile (best-effort), and broadcasts `user_updated`. The paired mint endpoint is `POST /api/auth/attach-proof` (see Auth). See `federation.md` "Peer-Side Re-Attach" and re-attach spec §3.23.3.
**`POST /api/federation/verify-attach-proof`** — HMAC-authenticated S2S endpoint on the home instance that redeems a one-time attach-proof token (single-use, bound to the calling peer's domain, HMAC-signed fail-closed response). See `federation.md` "S2S Detached-Account Re-Attach Proof".
**`POST /api/federation/epoch`** — HMAC-authenticated S2S endpoint returning this instance's persistent epoch (`{ instanceId }`). The **request** is HMAC-signed (only a peer holding the shared secret may call it; unknown/revoked peers → 403, bad signature → 401, missing headers → 400) **and the response body is HMAC-signed** with the same secret (`X-Federation-Signature/Timestamp/Nonce` response headers), so the caller can verify the epoch before writing it as the peer's trusted baseline (`federation_peers.peer_instance_id`). The value is already public via `/instance/info`; signing is for baseline-integrity, not confidentiality. Caller: `fetchPeerEpoch(peer)` (`utils/federationEpoch.ts`), which fails safe — 404 (not-yet-upgraded peer), bad/absent response signature, or network/timeout all return `null` (retry next tick). Populates the epoch baseline deterministically via the bounded periodic epoch-refresh. See `federation.md` "Instance Epoch" §3.2.
### Federation Peering Approval Queue
+4
View File
@@ -298,6 +298,10 @@ Before re-hashing, the self-heal confirms the home instance is the **same incarn
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.
#### Re-attach: leaving the detached state (re-attach spec §3.2)
Detach is sovereign but not permanent: the legitimate owner who re-created their account on the reset home can re-bind the detached account to the new home identity via `POST /api/users/@me/reattach` (registered in `routes/federation.ts`; see `federation.md` "Peer-Side Re-Attach"). It re-binds **only** on possession of BOTH identities — the session IS the detached account (local password authority, via `authenticate`) AND a one-time proof token minted on the home via `POST /api/auth/attach-proof` verifies with the home peer over signed S2S. Identity is never username-matched (that is the tier-2 hijack). On success the endpoint merges any pre-existing replicated stub for the new identity into the detached row, sets `home_user_id = <new homeUserId>`, **clears `federation_home_orphaned` (0)**, nulls `profile_updated_at`, and applies the current home profile. Clearing the flag automatically **re-enables** normal federated-account semantics: login self-heal resumes (the epoch guard above runs again) and the S2S binding guards stop excluding the account — live profile/presence sync from the home is restored. The local password hash is kept; a detached tombstone (`is_deleted = 1`) is never re-attachable.
---
## 5. Password Change
+11
View File
@@ -322,6 +322,17 @@ The home-instance verifier for the detached-account re-attach flow (re-attach sp
- **Re-confirms native identity:** after the claim, the home user must still be native and live (`isDeleted=0 AND home_instance IS NULL`) — a user tombstoned or turned into a replicated stub after mint fails closed.
- **Signed response (epoch pattern):** the body is HMAC-signed with the peer's shared secret (`X-Federation-Signature/Timestamp/Nonce` response headers) so the caller can trust the identity it carries. `{ valid: true, homeUserId, username }` on success; every failure mode (unknown/expired/used/wrong-domain/malformed token, deleted/non-native home user) fails closed to a **still-signed** `{ valid: false }`.
### Peer-Side Re-Attach (`POST /api/users/@me/reattach`)
The owner-initiated exception to the detach invariant (re-attach spec §3.2), on peer R. **JWT-authenticated as the detached account, not S2S** — but registered in `routes/federation.ts` (not `users.ts`) because it consumes federation-internal machinery (`verifyAttachProofWithPeer`, `fetchHomeProfileByHomeId`, `downloadProfileAsset`, the peer HMAC channel). It re-binds the sovereign detached row back to the owner's new home identity, restoring live sync while keeping history. It links **only** when BOTH proofs hold: the session IS the detached account (local password authority, via `authenticate`) AND the one-time token verifies with the home peer over signed S2S (`verifyAttachProofWithPeer`). Identity is never guessed and never username-matched — the latter is exactly the tier-2 hijack the detach branch closed.
- **Body:** `{ token: string }` (64-char hex; else 400). **Response:** `{ success: true, user: User }` (sanitized self-view; `ReattachResponse`).
- **Guards, in order:** (1) session user is a **live detached** federated account (`home_instance` set, `federation_home_orphaned = 1`, `is_deleted = 0`) → else 403; a missing/tombstoned row → 404 (a tombstoned session is already 401'd at `authenticate`, so the handler's 404 is defense-in-depth for a concurrent-delete race — detached tombstones are not re-attachable). (2) The home domain is an **active peer** → else 409 (the proof is only as trustworthy as the S2S channel it verifies over). (3) `verifyAttachProofWithPeer` returns `valid:true` → else 401 (fails closed). (4) If the verified new identity already has a live local row for that domain, it MUST be a replicated stub (`password_hash = '!federation-replicated'`) → a real account holding it is state corruption, aborted with **409 + a `console.error`** (impossible while the detached row holds the username).
- **Effect (one `rawDb.transaction`):** merges any pre-existing stub for the new identity into the detached row (below), sets `home_user_id = <new homeUserId>`, `federation_home_orphaned = 0`, adopts the new home username base if it differs (existing collision-suffix `<base>_<n>@<domain>` scheme; base match keeps the current handle), and **nulls `profile_updated_at`** so the home's next `profile_update` (any version) tier-1 matches and applies. Group-DM authority the old identity held is migrated by `owner_home_user_id` (the S2S authority key, home-domain-normalized). After the transaction, a best-effort `fetchHomeProfileByHomeId` pull applies the current home profile (avatar/banner via `downloadProfileAsset`, fail-open); then `user_updated` is broadcast to friends/DM/space co-members + all self connections (`collectProfileBroadcastTargetIds`).
- **Guard re-enablement:** clearing `federation_home_orphaned` automatically re-enables normal federated-account semantics — login self-heal resumes, and the S2S `profile_update`/`presence_update`/tier-2/identity-delete guards correctly stop firing for this account (they only fire on `federation_home_orphaned = 1`). This is intended, not a guard regression.
**Stub merge (spec §3.3).** By the time the owner re-attaches, R may already hold a replicated stub for the new home identity (from ordinary DM/friend relay, e.g. `youruser_1@<domain>`). Two rows must not share `(homeUserId, homeInstance)`, so the stub is merged into the detached row inside the transaction: every `users.id` FK a replicated stub **can** populate is repointed, with collision rows deduped **before** repoint. Tables (audited against `schema.ts`): `dm_members` (dedupe on `dm_channel_id`), `dm_messages`, `messages`, `dm_reactions` (dedupe on `dm_message_id+emoji`), `reactions` (dedupe on `message_id+emoji`), `friends` (both columns + drop self-rows), `friend_requests` (both columns + drop self-rows), `read_states` (dedupe on `channel_id`), `dm_channels.owner_id` (plain-text column, no FK). Space-scoped FKs (`space_members`, `member_roles`, `*_overrides`, `bans`, `join_requests`, `voice_restrictions`, layouts/folders) and moderator/owner RESTRICT columns are **not** repointed — a DM/friend replica can never hold them. The stub row is then deleted. Only a `'!federation-replicated'` row is ever a merge source (guard 4).
### S2S Epoch Refresh (`POST /api/federation/epoch`)
HMAC-authenticated in **both directions**: the request is signed (only a peer holding the shared secret may call it — unknown/revoked peers → 403, bad signature → 401, missing headers → 400) **and the response body `{ instanceId }` is HMAC-signed** with the same secret (`X-Federation-Signature/Timestamp/Nonce` response headers). The caller (`fetchPeerEpoch(peer)` in `utils/federationEpoch.ts`) verifies that response signature with the same secret before trusting the value, then writes it to `federation_peers.peer_instance_id`. Response-signing (not TLS-only) is deliberate: a poisoned baseline could drive a spurious data-heal on a live peer, so the newly-trusted epoch is authenticated (design §9). `fetchPeerEpoch` **fails safe** — a `404` from a not-yet-upgraded peer, an absent/invalid response signature, or a network/timeout error all return `null` (10s timeout via `AbortSignal.timeout`); the caller treats `null` as "retry on the next tick," never as an error to surface. This is the deterministic populator of the epoch baseline (the bounded periodic epoch-refresh, design §3.2), independent of organic relay traffic.