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.
@@ -0,0 +1,260 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { eq } from 'drizzle-orm';
import * as schema from '../db/schema.js';
import { setWorkerId } from '../utils/snowflake.js';
import { signJwt } from '../utils/auth.js';
setWorkerId(13);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Mock the Task-4 module so the endpoint never touches the network — the
// re-attach flow's only outbound calls (proof verification + profile fetch)
// go through these two functions.
const verifyMock = vi.fn();
const profileMock = vi.fn();
vi.mock('../utils/federationAttach.js', () => ({
verifyAttachProofWithPeer: (...args: unknown[]) => verifyMock(...args),
fetchHomeProfileByHomeId: (...args: unknown[]) => profileMock(...args),
}));
type TestDb = ReturnType<typeof drizzle<typeof schema>>;
let sqlite: Database.Database;
let testDb: TestDb;
let app: FastifyInstance;
vi.mock('../db/index.js', () => ({
getDb: () => testDb,
getRawDb: () => sqlite,
schema,
}));
function applyMigrations(db: Database.Database): void {
const migrationsDir = path.resolve(__dirname, '../../drizzle');
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
for (const f of files) {
const sqlText = fs.readFileSync(path.join(migrationsDir, f), 'utf8');
const statements = sqlText.split(/-->\s*statement-breakpoint/);
for (const stmt of statements) {
const clean = stmt.trim();
if (clean) db.exec(clean);
}
}
}
async function buildApp(): Promise<FastifyInstance> {
const { federationRoutes } = await import('./federation.js');
const f = Fastify();
await f.register(federationRoutes);
return f;
}
async function reattach(userId: string, username: string, token = 'a'.repeat(64)) {
return app.inject({
method: 'POST',
url: '/api/users/@me/reattach',
headers: { authorization: `Bearer ${signJwt({ userId, username })}` },
payload: { token },
});
}
beforeEach(async () => {
verifyMock.mockReset();
profileMock.mockReset();
sqlite = new Database(':memory:');
sqlite.pragma('foreign_keys = ON');
applyMigrations(sqlite);
testDb = drizzle(sqlite, { schema });
// Peer row for the home domain (orbit), ACTIVE.
testDb.insert(schema.federationPeers).values({
id: 'peer-1', origin: 'https://orbit.test', hmacSecret: 's'.repeat(64), status: 'active', createdAt: 1,
}).run();
// The detached account (session user) — old identity dead-home-1.
testDb.insert(schema.users).values({
id: 'detached-1', username: 'youruser@orbit.test', passwordHash: 'local-hash',
homeInstance: 'orbit.test', homeUserId: 'dead-home-1', federationHomeOrphaned: 1,
avatarColor: 'coral', createdAt: 1,
}).run();
// A native friend for broadcast/merge fixtures.
testDb.insert(schema.users).values({
id: 'alice', username: 'alice', passwordHash: 'x', homeInstance: null, createdAt: 1,
}).run();
app = await buildApp();
});
afterEach(async () => {
await app.close();
});
describe('POST /api/users/@me/reattach — guards', () => {
it('403 for a non-detached account', async () => {
testDb.update(schema.users).set({ federationHomeOrphaned: 0 }).where(eq(schema.users.id, 'detached-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(403);
expect(verifyMock).not.toHaveBeenCalled();
});
it('403 for a native account', async () => {
const res = await reattach('alice', 'alice');
expect(res.statusCode).toBe(403);
});
it('rejects a tombstoned session before any re-attach work (authenticate gate)', async () => {
// Detached tombstones are not re-attachable (spec §2). `authenticate` 401s a
// deleted account before the handler runs; the handler's own is_deleted 404
// remains as defense-in-depth for a concurrent-delete race. Either way the
// proof exchange never happens.
testDb.update(schema.users).set({ isDeleted: 1 }).where(eq(schema.users.id, 'detached-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(401);
expect(verifyMock).not.toHaveBeenCalled();
});
it('409 when the home peer is not active', async () => {
testDb.update(schema.federationPeers).set({ status: 'unreachable' }).where(eq(schema.federationPeers.id, 'peer-1')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(409);
});
it('400 when the token is not 64-char hex', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test', 'not-hex');
expect(res.statusCode).toBe(400);
expect(verifyMock).not.toHaveBeenCalled();
});
it('401 when the proof does not verify', async () => {
verifyMock.mockResolvedValue({ valid: false });
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(401);
// Nothing changed.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.federationHomeOrphaned).toBe(1);
expect(row.homeUserId).toBe('dead-home-1');
});
});
describe('POST /api/users/@me/reattach — success', () => {
beforeEach(() => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' });
profileMock.mockResolvedValue({
username: 'youruser',
profile: { displayName: 'Jannis', avatar: null, avatarColor: 'lavender', banner: null, bio: null },
});
});
it('re-binds identity, clears the flag, applies the home profile', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.success).toBe(true);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('new-home-1');
expect(row.federationHomeOrphaned).toBe(0);
expect(row.displayName).toBe('Jannis');
expect(row.avatarColor).toBe('lavender');
expect(row.profileUpdatedAt).toBeNull(); // next profile_update always applies
expect(row.username).toBe('youruser@orbit.test'); // same base → no rename
});
it('renames when the new home username base differs (collision-suffix scheme)', async () => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'hans' });
testDb.insert(schema.users).values({
id: 'squatter', username: 'hans@orbit.test', passwordHash: '!federation-replicated',
homeInstance: 'orbit.test', homeUserId: 'other', createdAt: 1,
}).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.username).toBe('hans_1@orbit.test');
});
it('proceeds without a profile when the home profile fetch fails', async () => {
profileMock.mockResolvedValue(null);
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('new-home-1');
expect(row.avatarColor).toBe('coral'); // untouched
});
it('subsequent S2S profile_update APPLIES after re-attach (guard no longer fires)', async () => {
await reattach('detached-1', 'youruser@orbit.test');
const { processProfileUpdateEvent } = await import('./federation.js');
const accepted: string[] = [];
const rejected: Array<{ messageId: string; reason: string }> = [];
await processProfileUpdateEvent({
eventType: 'profile_update', contextType: 'profile', messageId: 'pu-1',
encryptionVersion: 0, timestamp: Date.now(),
profileUpdate: {
homeUserId: 'new-home-1', homeInstance: 'https://orbit.test',
profileUpdatedAt: Date.now(), username: 'youruser',
displayName: 'NewName', avatar: null, banner: null,
accentColor: null, avatarColor: 'mint', bio: null,
},
} as Parameters<typeof processProfileUpdateEvent>[0], 'https://orbit.test', testDb, accepted, rejected);
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.displayName).toBe('NewName');
expect(row.avatarColor).toBe('mint');
});
});
describe('POST /api/users/@me/reattach — stub merge', () => {
beforeEach(() => {
verifyMock.mockResolvedValue({ valid: true, homeUserId: 'new-home-1', username: 'youruser' });
profileMock.mockResolvedValue(null);
// Stub for the NEW identity, created earlier by ordinary relay.
testDb.insert(schema.users).values({
id: 'stub-new', username: 'youruser_1@orbit.test', passwordHash: '!federation-replicated',
homeInstance: 'orbit.test', homeUserId: 'new-home-1', createdAt: 2,
}).run();
// Stub state: a DM with alice (which the detached row is ALSO in → dedupe),
// a message, a friendship with alice (detached row also friends → dedupe).
testDb.insert(schema.dmChannels).values({ id: 'ch-1', federatedId: 'fed-1', createdAt: 1 }).run();
testDb.insert(schema.dmMembers).values([
{ dmChannelId: 'ch-1', userId: 'alice', closed: 0 },
{ dmChannelId: 'ch-1', userId: 'detached-1', closed: 0 },
{ dmChannelId: 'ch-1', userId: 'stub-new', closed: 0 },
]).run();
testDb.insert(schema.dmMessages).values({
id: 'm-stub', dmChannelId: 'ch-1', userId: 'stub-new', content: 'from new incarnation', createdAt: 3,
}).run();
testDb.insert(schema.friends).values([
{ userId: 'alice', friendId: 'detached-1', createdAt: 1 },
{ userId: 'alice', friendId: 'stub-new', createdAt: 2 },
]).run();
});
it('merges the stub into the detached row: repointed, deduped, deleted', async () => {
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(200);
// Stub gone.
expect(testDb.select().from(schema.users).all().some(u => u.id === 'stub-new')).toBe(false);
// Message repointed.
const msg = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, 'm-stub')).get()!;
expect(msg.userId).toBe('detached-1');
// Membership deduped (detached row already a member).
const members = testDb.select().from(schema.dmMembers).all().filter(m => m.dmChannelId === 'ch-1');
expect(members.map(m => m.userId).sort()).toEqual(['alice', 'detached-1']);
// Friendship deduped.
const friendRows = testDb.select().from(schema.friends).all();
expect(friendRows).toHaveLength(1);
expect(friendRows[0]!.friendId).toBe('detached-1');
});
it('409 when the new identity is held by a REAL account (not a stub)', async () => {
testDb.update(schema.users).set({ passwordHash: 'real-hash' }).where(eq(schema.users.id, 'stub-new')).run();
const res = await reattach('detached-1', 'youruser@orbit.test');
expect(res.statusCode).toBe(409);
// Nothing changed on the detached row.
const row = testDb.select().from(schema.users).where(eq(schema.users.id, 'detached-1')).get()!;
expect(row.homeUserId).toBe('dead-home-1');
});
});
+171
View File
@@ -22,6 +22,7 @@ import { onPeerActivated, onPeerDeactivated } from '../utils/federationPeerActiv
import { getInstanceId, fetchPeerEpoch } from '../utils/federationEpoch.js';
import { probePeerReachable, recoverOrDetectReset } from '../utils/federationRecovery.js';
import { markPeerReset, homeInstanceMatch } from '../utils/federationReset.js';
import { verifyAttachProofWithPeer, fetchHomeProfileByHomeId } from '../utils/federationAttach.js';
import { getDmMessageWithUser } from './dm.js';
import type { FederationRelayRequest, FederationRelayResponse, FederationRelayEvent, FederationRelayAttachment, FederationSyncRequest, FederationSyncResponse, DmMessageWithUser, DmChannel, FederationRelayProfileSnapshot, FederationIdentityDeleteS2SRequest, FederationProfileUpdatePayload, ServerEvent, ApprovalRequestSubscriberSummary, PeeringTriggerReason } from '@backspace/shared';
import { GROUP_DM_NAME_MIN_LENGTH, GROUP_DM_NAME_MAX_LENGTH } from '@backspace/shared/src/constants.js';
@@ -2863,6 +2864,176 @@ export async function federationRoutes(app: FastifyInstance): Promise<void> {
},
);
// ─── POST /api/users/@me/reattach ────────────────────────────────────────────
// Owner-initiated exception to the detach invariant (re-attach spec §3.2).
// Requires BOTH identities: the session proves the detached account (local
// password authority), the one-time token — verified with the home peer over
// signed S2S — proves the new home account. Registered here rather than in
// users.ts because it consumes federation-internal machinery (peer HMAC
// channel, profile fetch, asset download). URL path stays /api/users/@me/*.
app.post<{ Body: { token?: unknown } }>('/api/users/@me/reattach', {
preHandler: authenticate,
config: { rateLimit: { max: 5, timeWindow: '15 minutes' } },
}, async (request, reply) => {
const db = getDb();
const rawDb = getRawDb();
const rawToken = (request.body as { token?: unknown } | null)?.token;
if (typeof rawToken !== 'string' || !/^[0-9a-f]{64}$/i.test(rawToken)) {
return reply.code(400).send({ error: 'token is required (64-char hex)', statusCode: 400 });
}
// Guard 1: session user must be a LIVE detached federated account. A missing
// or tombstoned row is a 404 (nothing to re-attach); a live non-detached /
// native account is a 403 (re-attach is meaningless — it already syncs).
const detached = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
if (!detached || detached.isDeleted === 1) {
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
}
if (!detached.homeInstance || detached.federationHomeOrphaned !== 1) {
return reply.code(403).send({ error: 'Only detached accounts can re-attach', statusCode: 403 });
}
// Guard 2: the home domain must be an ACTIVE peer — the proof is only as
// trustworthy as the S2S channel it is verified over.
const homeDomain = extractDomain(detached.homeInstance).toLowerCase();
const normPeer = (origin: string) => extractDomain(origin).toLowerCase();
const peerRow = db.select().from(schema.federationPeers).all()
.find(p => normPeer(p.origin) === homeDomain && p.status === 'active');
if (!peerRow) {
return reply.code(409).send({ error: 'Home instance is not an active peer', statusCode: 409 });
}
// Guard 3: verify the one-time proof with the home instance (fails closed).
const verified = await verifyAttachProofWithPeer(peerRow, rawToken);
if (!verified.valid) {
return reply.code(401).send({ error: 'Attach proof could not be verified', statusCode: 401 });
}
// Guard 4: if the new identity already has a local row for this domain, it
// MUST be a replicated stub (the merge source, §3.3). A real account holding
// it means state corruption — abort loudly, do not merge.
const normHome = `lower(replace(replace(coalesce(home_instance, ''), 'https://', ''), 'http://', ''))`;
const existingRow = rawDb.prepare(`
SELECT id, password_hash FROM users
WHERE home_user_id = ? AND ${normHome} = ? AND is_deleted = 0 AND id != ?
`).get(verified.homeUserId, homeDomain, detached.id) as { id: string; password_hash: string } | undefined;
if (existingRow && existingRow.password_hash !== '!federation-replicated') {
console.error(`[federation] Re-attach conflict: identity ${verified.homeUserId}@${homeDomain} held by non-stub account ${existingRow.id}`);
return reply.code(409).send({ error: 'The new identity is already bound to another account on this instance', statusCode: 409 });
}
// Username: adopt the new home base when it differs (existing collision-suffix
// scheme). Usernames are not identity, so a base match keeps the current handle.
const currentBase = detached.username.includes('@')
? detached.username.slice(0, detached.username.indexOf('@'))
: detached.username;
let newUsername = detached.username;
const newBase = verified.username.toLowerCase();
if (newBase !== currentBase.toLowerCase()) {
let candidate = `${newBase}@${homeDomain}`;
let attempt = 0;
while (rawDb.prepare(`SELECT 1 FROM users WHERE username = ? AND id != ?`).get(candidate, detached.id)) {
attempt++;
candidate = `${newBase}_${attempt}@${homeDomain}`;
if (attempt > 10) {
candidate = `${newBase}_${randomBytes(4).toString('hex')}@${homeDomain}`;
break;
}
}
newUsername = candidate;
}
// Merge + re-bind, atomically. All users.id FK repointing lives here; dedupe
// rows that would collide on a composite PK / unique index BEFORE repointing
// (spec §3.3). The stub row is the only source — a real account holding the
// identity was already rejected by guard 4.
rawDb.transaction(() => {
if (existingRow) {
const stubId = existingRow.id;
const targetId = detached.id;
// dm_members (composite PK dm_channel_id+user_id → dedupe): drop the
// stub's membership where the detached row is already a member.
rawDb.prepare(`DELETE FROM dm_members WHERE user_id = ? AND dm_channel_id IN (SELECT dm_channel_id FROM dm_members WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE dm_members SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// dm_messages / messages (RESTRICT FK, no unique on user_id → straight repoint).
rawDb.prepare(`UPDATE dm_messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE messages SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// dm_reactions (dedupe on dm_message_id+emoji per user).
rawDb.prepare(`DELETE FROM dm_reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM dm_reactions r2 WHERE r2.user_id = ? AND r2.dm_message_id = dm_reactions.dm_message_id AND r2.emoji = dm_reactions.emoji)`).run(stubId, targetId);
rawDb.prepare(`UPDATE dm_reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// reactions (dedupe on message_id+emoji per user).
rawDb.prepare(`DELETE FROM reactions WHERE user_id = ? AND EXISTS (SELECT 1 FROM reactions r2 WHERE r2.user_id = ? AND r2.message_id = reactions.message_id AND r2.emoji = reactions.emoji)`).run(stubId, targetId);
rawDb.prepare(`UPDATE reactions SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// friends (composite PK user_id+friend_id → dedupe both directions, then
// repoint, then drop any self-friendship the repoint created).
rawDb.prepare(`DELETE FROM friends WHERE user_id = ? AND friend_id IN (SELECT friend_id FROM friends WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`DELETE FROM friends WHERE friend_id = ? AND user_id IN (SELECT user_id FROM friends WHERE friend_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE friends SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE friends SET friend_id = ? WHERE friend_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM friends WHERE user_id = friend_id`).run();
// friend_requests (unique on neither col alone; repoint both, drop self-rows).
rawDb.prepare(`UPDATE friend_requests SET from_id = ? WHERE from_id = ?`).run(targetId, stubId);
rawDb.prepare(`UPDATE friend_requests SET to_id = ? WHERE to_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM friend_requests WHERE from_id = to_id`).run();
// read_states (composite PK user_id+channel_id → dedupe).
rawDb.prepare(`DELETE FROM read_states WHERE user_id = ? AND channel_id IN (SELECT channel_id FROM read_states WHERE user_id = ?)`).run(stubId, targetId);
rawDb.prepare(`UPDATE read_states SET user_id = ? WHERE user_id = ?`).run(targetId, stubId);
// dm_channels.owner_id (plain text column, NO FK → straight repoint).
rawDb.prepare(`UPDATE dm_channels SET owner_id = ? WHERE owner_id = ?`).run(targetId, stubId);
rawDb.prepare(`DELETE FROM users WHERE id = ?`).run(stubId);
}
// Group-DM ownership continuity: channels the OLD identity owned keep
// authority under the NEW identity (owner_home_user_id is the S2S
// authority key, not a users.id FK).
const normOwnerHome = `lower(replace(replace(coalesce(owner_home_instance, ''), 'https://', ''), 'http://', ''))`;
rawDb.prepare(`UPDATE dm_channels SET owner_home_user_id = ? WHERE owner_home_user_id = ? AND ${normOwnerHome} = ?`)
.run(verified.homeUserId, detached.homeUserId, homeDomain);
// Re-bind. profile_updated_at is nulled so the home's next profile_update
// (any version) tier-1 matches and applies (the accept-and-skip guards
// only fire on federation_home_orphaned = 1).
rawDb.prepare(`UPDATE users SET home_user_id = ?, federation_home_orphaned = 0, username = ?, profile_updated_at = NULL WHERE id = ?`)
.run(verified.homeUserId, newUsername, detached.id);
})();
// Best-effort initial profile pull (spec §3.2 step 4). Failure is fine — the
// account is re-attached; the next relay fills the profile.
const home = await fetchHomeProfileByHomeId(peerRow, verified.homeUserId);
if (home) {
let avatar: string | null = null;
let banner: string | null = null;
if (home.profile.avatar) {
const url = home.profile.avatar.startsWith('http') ? home.profile.avatar : `${peerRow.origin}/api/uploads/${home.profile.avatar}`;
avatar = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
}
if (home.profile.banner) {
const url = home.profile.banner.startsWith('http') ? home.profile.banner : `${peerRow.origin}/api/uploads/${home.profile.banner}`;
banner = (await downloadProfileAsset(url, peerRow.origin)) ?? url;
}
db.update(schema.users).set({
displayName: home.profile.displayName ?? home.username,
avatar,
banner,
avatarColor: home.profile.avatarColor ?? detached.avatarColor,
bio: home.profile.bio,
}).where(eq(schema.users.id, detached.id)).run();
}
const updated = db.select().from(schema.users).where(eq(schema.users.id, detached.id)).get()!;
console.log(`[federation] Re-attached account ${updated.id} (${updated.username}): ${detached.homeUserId}${verified.homeUserId} @ ${homeDomain}`);
// Broadcast to friends / DM / space co-members + all self connections.
const targetIds = collectProfileBroadcastTargetIds(updated.id);
targetIds.add(updated.id);
for (const uid of targetIds) {
connectionManager.sendToUser(uid, { type: 'user_updated' as const, user: sanitizeUser(updated, uid === updated.id) });
}
return reply.code(200).send({ success: true, user: sanitizeUser(updated, true) });
});
// ─── POST /api/federation/sync ──────────────────────────────────────────────
// Server-to-server: checkpoint catch-up sync. A peer calls this after downtime
// to retrieve missed DM mutations from the mutation log.
+18
View File
@@ -1163,6 +1163,24 @@ export interface FederationSyncResponse {
checkpoint: number;
}
// Detached-account re-attach (re-attach spec §3.13.2).
// Minted on the home instance D for a logged-in native user.
export interface AttachProofResponse {
token: string;
}
// Body of POST /api/users/@me/reattach on the peer R — the one-time proof token
// minted by the home instance, verified with D over signed S2S.
export interface ReattachRequest {
token: string;
}
// Success response of POST /api/users/@me/reattach — the re-bound self-view.
export interface ReattachResponse {
success: true;
user: User;
}
export interface FederationUserLookupRequest {
username: string;
}