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
@@ -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;
}