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
+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.