diff --git a/docs/systems/api.md b/docs/systems/api.md index adcbc9b8..bb15eaf3 100644 --- a/docs/systems/api.md +++ b/docs/systems/api.md @@ -14,7 +14,7 @@ POST /auth/login { username, password } → { token, user } POST /auth/attach-proof (JWT, rate-limited 5/15min) { targetDomain } → { token } (AttachProofResponse) ``` -**`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/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 rows are janitored on each mint (the delete targets `expires_at < now`; a used-but-unexpired row lingers until it expires). 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. diff --git a/packages/server/src/routes/auth.attachProof.test.ts b/packages/server/src/routes/auth.attachProof.test.ts index 2e8a1d0c..7b5f09da 100644 --- a/packages/server/src/routes/auth.attachProof.test.ts +++ b/packages/server/src/routes/auth.attachProof.test.ts @@ -112,6 +112,19 @@ describe('POST /api/auth/attach-proof', () => { expect(res.statusCode).toBe(400); }); + it('rejects a targetDomain that normalizes to empty and inserts no row', async () => { + // "https://" passes the pre-normalization length check but collapses to "" + // after protocol/slash stripping — must 400, never persist an inert row. + const res = await app.inject({ + method: 'POST', + url: '/api/auth/attach-proof', + headers: { authorization: `Bearer ${signJwt({ userId: 'native-1', username: 'youruser' })}` }, + payload: { targetDomain: 'https://' }, + }); + expect(res.statusCode).toBe(400); + expect(testDb.select().from(schema.federationAttachProofs).all()).toHaveLength(0); + }); + it('rejects unauthenticated requests', async () => { const res = await app.inject({ method: 'POST', url: '/api/auth/attach-proof', payload: { targetDomain: 'nova.ddns.net' } }); expect(res.statusCode).toBe(401); diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index dfb426ec..732fa6e1 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -504,6 +504,12 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(400).send({ error: 'targetDomain is required (string)', statusCode: 400 }); } const targetDomain = rawTarget.trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/+$/, ''); + // Re-check emptiness AFTER normalization: inputs like "https://" or "/" + // pass the pre-normalization guard but collapse to "" — never persist an + // inert target_domain='' proof row. + if (targetDomain.length === 0) { + return reply.code(400).send({ error: 'targetDomain is required (string)', statusCode: 400 }); + } // Native accounts only — a federated/replicated account has no authority // to mint proofs for this domain's identities. diff --git a/packages/server/src/routes/federation.reattach.test.ts b/packages/server/src/routes/federation.reattach.test.ts index 04287a86..2f96b166 100644 --- a/packages/server/src/routes/federation.reattach.test.ts +++ b/packages/server/src/routes/federation.reattach.test.ts @@ -226,6 +226,13 @@ describe('POST /api/users/@me/reattach — stub merge', () => { testDb.insert(schema.dmMessages).values({ id: 'm-stub', dmChannelId: 'ch-1', userId: 'stub-new', content: 'from new incarnation', createdAt: 3, }).run(); + // An attachment the stub uploaded onto its DM message — uploader_id is a + // plain text column (no FK), so it must be repointed explicitly or attribution + // dangles at the deleted stub's id. + testDb.insert(schema.attachments).values({ + id: 'att-stub', dmMessageId: 'm-stub', uploaderId: 'stub-new', + filename: 'f.webp', originalName: 'f.webp', mimetype: 'image/webp', size: 100, createdAt: 3, + }).run(); testDb.insert(schema.friends).values([ { userId: 'alice', friendId: 'detached-1', createdAt: 1 }, { userId: 'alice', friendId: 'stub-new', createdAt: 2 }, @@ -240,6 +247,9 @@ describe('POST /api/users/@me/reattach — stub merge', () => { // Message repointed. const msg = testDb.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, 'm-stub')).get()!; expect(msg.userId).toBe('detached-1'); + // Attachment attribution repointed off the deleted stub. + const att = testDb.select().from(schema.attachments).where(eq(schema.attachments.id, 'att-stub')).get()!; + expect(att.uploaderId).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']); diff --git a/packages/server/src/routes/federation.ts b/packages/server/src/routes/federation.ts index 2a6b0323..d2f85862 100644 --- a/packages/server/src/routes/federation.ts +++ b/packages/server/src/routes/federation.ts @@ -2959,6 +2959,11 @@ export async function federationRoutes(app: FastifyInstance): Promise { // 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); + // attachments.uploader_id (plain text column, NO FK, no unique → straight + // repoint). A replicated stub that uploaded a DM/channel attachment would + // otherwise leave uploader_id dangling at the deleted stub's id — broken + // attribution. + rawDb.prepare(`UPDATE attachments SET uploader_id = ? WHERE uploader_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); diff --git a/packages/server/src/utils/federationAttach.test.ts b/packages/server/src/utils/federationAttach.test.ts index a76a45d0..e0e51c7c 100644 --- a/packages/server/src/utils/federationAttach.test.ts +++ b/packages/server/src/utils/federationAttach.test.ts @@ -37,6 +37,30 @@ describe('verifyAttachProofWithPeer', () => { expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false }); }); + it('treats a PRESENT-but-INVALID signature as valid:false (signature must verify against the peer secret)', async () => { + // A 200 response with a well-formed signature header that was computed with + // the WRONG secret — it must NOT verify against the peer's real secret. This + // hardens the core "never trust unauthenticated bodies" gate: a malicious or + // misconfigured peer that returns valid:true with a bogus signature is rejected. + const wrongSignedResponse = (bodyObj: object): Response => { + const body = JSON.stringify(bodyObj); + const ts = Date.now(); + const nonce = 'resp-nonce'; + const sig = signRequest(body, 'c'.repeat(64) /* wrong secret */, ts, nonce); + return new Response(body, { + status: 200, + headers: { + 'x-federation-signature': `sha256=${sig}`, + 'x-federation-timestamp': String(ts), + 'x-federation-nonce': nonce, + }, + }); + }; + vi.stubGlobal('fetch', vi.fn(async () => wrongSignedResponse({ valid: true, homeUserId: 'h1', username: 'youruser' }))); + const { verifyAttachProofWithPeer } = await import('./federationAttach.js'); + expect(await verifyAttachProofWithPeer(PEER, 'a'.repeat(64))).toEqual({ valid: false }); + }); + it('network error → valid:false', async () => { vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); })); const { verifyAttachProofWithPeer } = await import('./federationAttach.js'); @@ -56,9 +80,15 @@ describe('fetchHomeProfileByHomeId', () => { expect(result?.profile.avatar).toBe('a.webp'); }); - it('found:false or network error → null', async () => { + it('found:false → null', async () => { vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ found: false }), { status: 200 }))); const { fetchHomeProfileByHomeId } = await import('./federationAttach.js'); expect(await fetchHomeProfileByHomeId(PEER, 'h1')).toBeNull(); }); + + it('network error → null', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ECONNREFUSED'); })); + const { fetchHomeProfileByHomeId } = await import('./federationAttach.js'); + expect(await fetchHomeProfileByHomeId(PEER, 'h1')).toBeNull(); + }); }); diff --git a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx index 084a0d29..434b45e9 100644 --- a/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx +++ b/packages/web/src/components/modals/settingsPanels/AccountPanel.tsx @@ -90,7 +90,9 @@ export function AccountPanel() { const homeDomain = user.homeInstance.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase(); return instances.find( (i) => i.status === 'connected' - && i.origin.replace(/^https?:\/\//, '').replace(/\/+$/, '').toLowerCase() === homeDomain, + // Portless hostname — must agree with the server's extractDomain + // (new URL(origin).hostname) so a ported home instance still matches. + && new URL(i.origin).hostname.toLowerCase() === homeDomain, ) ?? null; }, [instances, user?.homeInstance]); @@ -104,7 +106,8 @@ export function AccountPanel() { setReattachError(null); try { // Target domain = THIS instance (where the detached account lives). - const { token } = await homeConnection.api.auth.attachProof(window.location.host); + // Portless hostname to match the server's extractDomain contract. + const { token } = await homeConnection.api.auth.attachProof(window.location.hostname); const res = await api.users.reattach({ token }); useAuthStore.getState().setUser(res.user); addToast(`Account re-linked with ${homeConnection.username}`, 'success', 3000); diff --git a/packages/web/src/stores/instanceStore.autoReattach.test.ts b/packages/web/src/stores/instanceStore.autoReattach.test.ts index ef2b25b9..d77ea06e 100644 --- a/packages/web/src/stores/instanceStore.autoReattach.test.ts +++ b/packages/web/src/stores/instanceStore.autoReattach.test.ts @@ -80,6 +80,38 @@ describe('maybeAutoReattach', () => { useInstanceStore.setState({ instances: [homeConn, detachedConn] }); await maybeAutoReattach(detachedConn); expect((homeConn.api as unknown as { auth: { attachProof: ReturnType } }).auth.attachProof).not.toHaveBeenCalled(); + expect((detachedConn.api as unknown as { users: { reattach: ReturnType } }).users.reattach).not.toHaveBeenCalled(); + }); + + it('mints the PORTLESS target host for a ported instance origin (matches server extractDomain)', async () => { + // Both instances served on a non-443 port. The server binds/verifies the + // proof against extractDomain(peer.origin) = new URL(origin).hostname, which + // is portless — so the client must mint the portless host too, or the + // exchange 401s forever. homeInstance is stored bare (portless hostname). + const homeConn = makeInstance({ + origin: 'https://orbit.test:8443', + username: 'youruser', + user: { id: 'new-home-1', username: 'youruser' } as User, + }); + const attachProof = vi.fn().mockResolvedValue({ token: 'a'.repeat(64) }); + (homeConn.api as unknown as { auth: { attachProof: typeof attachProof } }).auth.attachProof = attachProof; + + const updatedUser = { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: false, homeInstance: 'orbit.test' } as User; + const reattach = vi.fn().mockResolvedValue({ success: true, user: updatedUser }); + const detachedConn = makeInstance({ + origin: 'https://nova.test:8443', + user: { id: 'detached-1', username: 'youruser@orbit.test', federationHomeOrphaned: true, homeInstance: 'orbit.test' } as User, + }); + (detachedConn.api as unknown as { users: { reattach: typeof reattach } }).users.reattach = reattach; + + useInstanceStore.setState({ instances: [homeConn, detachedConn] }); + await maybeAutoReattach(detachedConn); + + // Portless — 'nova.test', NOT 'nova.test:8443'. + expect(attachProof).toHaveBeenCalledWith('nova.test'); + expect(reattach).toHaveBeenCalledWith({ token: 'a'.repeat(64) }); + const stored = useInstanceStore.getState().instances.find(i => i.origin === 'https://nova.test:8443')!; + expect(stored.user.federationHomeOrphaned).toBe(false); }); it('skips when the account is not detached', async () => { diff --git a/packages/web/src/stores/instanceStore.ts b/packages/web/src/stores/instanceStore.ts index 8afc90a1..266bd6cb 100644 --- a/packages/web/src/stores/instanceStore.ts +++ b/packages/web/src/stores/instanceStore.ts @@ -155,12 +155,12 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise i.status === 'connected' && new URL(i.origin).host.toLowerCase() === homeDomain, + (i) => i.status === 'connected' && new URL(i.origin).hostname.toLowerCase() === homeDomain, ); if (conn) { homeApi = conn.api; @@ -175,7 +175,10 @@ export async function maybeAutoReattach(instance: ConnectedInstance): Promise ({