diff --git a/apps/backend/docs/signal-migration.md b/apps/backend/docs/signal-migration.md new file mode 100644 index 0000000..886ca12 --- /dev/null +++ b/apps/backend/docs/signal-migration.md @@ -0,0 +1,217 @@ +# Phase-1 → Signal migration + +Clicked shipped on the **Phase-1 sealed box**: ECDH ephemeral key + HKDF + +AES-256-GCM, one independent box per recipient device per message. It has no +forward secrecy and no ratchet. The **Signal Double Ratchet** replaces it (see +[../../../docs/signal-integration.md](../../../docs/signal-integration.md) for +the library decision). + +Clients update at their own pace, so a conversation contains devices on both +sides of that line for as long as the slowest one takes. This document defines +how that transition happens without losing any history. + +## What this builds on + +Capability advertisement already exists: `devices.capabilities` is a JSON +document listing the `protocols` a device can decrypt, and `selectProtocol` +(`src/lib/capabilities.ts`) picks the strongest one two devices share, falling +back to the universal `sealed_box` baseline. + +This migration adds the two things negotiation alone does not provide: + +1. **Enforcement on the way in.** A sender can _claim_ any protocol on an + envelope. Two claims have to be rejected. +2. **A record of what was actually used.** Capabilities change over time, so + they cannot be used to interpret an envelope written months ago. + +## The three rules + +1. **History is never re-encrypted.** Every envelope records the construction + that produced it, so anything written before a pair cut over keeps + decrypting on the Phase-1 path forever. The cutover changes what is written + next, never what was written before. +2. **A device pair uses the strongest protocol both sides advertise.** Not the + weakest in the conversation — see below. +3. **A weaker protocol than both sides support is refused.** Without this a + patched or compromised client could quietly keep a peer on the sealed box + forever and nobody would notice. + +### Negotiation is per device pair, not per conversation + +The envelope model already encrypts once per recipient device. There is +therefore no reason to hold a Signal-capable device back because some other +member of the conversation is still on an old client: that pair gets Signal +today, and the laggard keeps sealed box until it upgrades. + +A conversation-wide "everyone must be ready" gate would be simpler to describe +but strictly worse — it would delay forward secrecy for every pair in a group +until the last straggler updated, which in a large group may be never. + +## Data model + +Migration `drizzle/0004_envelope_protocol.sql` adds one column: + +`message_envelopes.protocol` — `e2ee_protocol NOT NULL DEFAULT 'sealed_box'`, +an enum of `sealed_box | signal | mls` mirroring `KNOWN_PROTOCOLS`. + +Adding it with that default backfills every existing row in the same statement. +**This is the no-history-loss guarantee:** every envelope ever written is +labelled with the construction that produced it, so a client picks the +decryption path from the data rather than inferring it from the ciphertext or +from when the message was sent. + +The column is per envelope, not per conversation or per device, because a +conversation legitimately contains both during the transition — and because +`devices.capabilities` describes what a device can do _now_, which says nothing +about what encrypted a message last month. + +No column is added to `devices`: capability advertisement already lives in +`devices.capabilities`. + +## Advertising Signal support + +A client that has shipped Signal support re-verifies with a widened protocol +list. `POST /auth/verify` treats a newer `capabilities` document as the upgrade +path and updates the stored one — no re-registration, no separate endpoint: + +```json +{ + "walletAddress": "G...", + "signature": "...", + "nonce": "...", + "identityPublicKey": "base64", + "device": { + "deviceName": "laptop", + "platform": "web", + "identityPublicKey": "base64", + "capabilities": { "protocols": ["sealed_box", "signal"] } + } +} +``` + +A device that has never advertised capabilities — including every row written +before the column existed — is treated as `sealed_box`-only. + +## Reading the negotiated protocol + +`GET /conversations/:id/devices` already returns, per device, its +`capabilities` and the `negotiatedProtocol` the calling device should use with +it. That is the same value the send path enforces, so a client that echoes it +back on each envelope cannot trip the checks below. + +## Sending + +Each envelope declares its protocol. It is optional and defaults to +`sealed_box`, so clients written before this change keep working untouched: + +```json +{ + "conversationId": "uuid", + "messageId": "uuid", + "contentType": "text", + "ciphertext": "base64", + "envelopes": [ + { "recipientDeviceId": "uuid", "ciphertext": "base64", "protocol": "signal" }, + { "recipientDeviceId": "uuid", "ciphertext": "base64", "protocol": "sealed_box" } + ] +} +``` + +Note the mixed batch: that is normal and correct during the migration. + +The server rejects two things, and they are different problems: + +| Status | Reason | Meaning | +| ------ | -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `400` | `unsupported_by_recipient` | The envelope names a protocol the recipient does not advertise. It would be undecryptable on arrival, which the recipient cannot distinguish from tampering. | +| `409` | `downgrade` | Both devices can do better than what the envelope claims. Re-encrypt and retry. | + +Both responses list every offending envelope so the client can re-fetch the +device set and rebuild rather than retrying blind: + +```json +{ + "error": "Envelope protocol is weaker than both devices support", + "violations": [ + { + "recipientDeviceId": "uuid", + "declared": "sealed_box", + "expected": "signal", + "reason": "downgrade" + } + ] +} +``` + +When a batch contains both kinds, the undecryptable envelope decides the status +code — it is the more specific failure. + +The WebSocket `send_message` path enforces the identical rule and emits an +`error` event with `event: "protocol_mismatch"` carrying the same `violations`. + +MLS group messages carry a single group ciphertext and no per-device envelopes +(#372), so there is nothing to negotiate and the check does not run. + +## Reading + +Every read path reports the protocol per envelope so the client picks the right +decryption routine: + +- `GET /sync` — each envelope carries `protocol`. +- `message_envelope` socket events — carry `protocol`. +- `GET /conversations/:id/messages` — envelope rows carry `protocol`. + +A client catching up across the cutover therefore receives a mix of +`sealed_box` and `signal` envelopes in one page and decrypts each with the +matching path. Nothing is migrated, re-encrypted, or re-downloaded. + +## Cutover timeline for one device pair + +```text + both Phase-1 one upgraded both upgraded + ───────────────── ────────────────────── ──────────────────────── +negotiated sealed_box sealed_box signal + +new msgs sealed box sealed box Signal only + (the other side (sealed box is + could not read refused) + a Signal envelope) + +old msgs sealed box sealed box sealed box + decrypt on decrypt on decrypt on + Phase-1 path Phase-1 path Phase-1 path +``` + +The pair flips the moment the second device advertises support. Nothing is +rewritten at that instant — only the next message differs. + +## Rollout order + +1. Apply `drizzle/0004_envelope_protocol.sql`. Existing envelopes are labelled + `sealed_box`. Behaviour is unchanged at this point. +2. Ship a client that can **decrypt** both paths, keyed off the envelope + `protocol` field, but still encrypts with sealed box. Safe to deploy widely + because it changes nothing about what is written. +3. Ship the client that can **encrypt** with Signal, and have it re-verify with + `protocols: ["sealed_box", "signal"]` on first run. +4. Pairs cut over on their own as both sides upgrade. `GET /conversations/:id/devices` + shows which devices are still on the baseline. +5. Phase-1 decryption stays in the client indefinitely — history predating the + cutover only decrypts that way. + +Step 2 must fully precede step 3. A device that can encrypt with Signal while +talking to one that cannot decrypt it produces envelopes nobody can open — +which is exactly the `400` above, but it is better not to rely on the server +catching it. + +## Implementation references + +- schema: `apps/backend/src/db/schema.ts` (`messageEnvelopes.protocol`, `e2eeProtocolEnum`) +- migration: `apps/backend/drizzle/0004_envelope_protocol.sql` +- capability model + negotiation: `apps/backend/src/lib/capabilities.ts` +- enforcement: `apps/backend/src/services/e2eeProtocol.ts` +- envelope persistence: `apps/backend/src/lib/messageFanout.ts` +- send paths: `apps/backend/src/routes/messages.ts`, `apps/backend/src/socket/messaging.ts` +- read paths: `apps/backend/src/routes/sync.ts`, `apps/backend/src/services/deliveryPipeline.ts` +- client crypto layer: `apps/web/src/lib/session.ts`, `apps/web/src/lib/signalClient.ts` +- tests: `apps/backend/src/__tests__/e2eeProtocol.test.ts`, `signalMigration.routes.test.ts` diff --git a/apps/backend/drizzle/0004_envelope_protocol.sql b/apps/backend/drizzle/0004_envelope_protocol.sql new file mode 100644 index 0000000..6fe451a --- /dev/null +++ b/apps/backend/drizzle/0004_envelope_protocol.sql @@ -0,0 +1,14 @@ +-- Per-envelope E2EE protocol (#364). +-- +-- `message_envelopes.protocol` is added NOT NULL DEFAULT 'sealed_box', which +-- backfills every existing row to the Phase-1 sealed box in the same +-- statement. That is the no-history-loss guarantee: envelopes written before a +-- device pair cut over are labelled with the construction that actually +-- encrypted them, so they keep decrypting on the Phase-1 path even after that +-- pair's `devices.capabilities` has moved on. +-- +-- Values mirror KNOWN_PROTOCOLS in src/lib/capabilities.ts. No column is added +-- to `devices`: capability advertisement already lives in `devices.capabilities` +-- (0002_device_capabilities.sql). +CREATE TYPE "public"."e2ee_protocol" AS ENUM('sealed_box', 'signal', 'mls');--> statement-breakpoint +ALTER TABLE "message_envelopes" ADD COLUMN "protocol" "e2ee_protocol" DEFAULT 'sealed_box' NOT NULL; diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index c9ab709..0777af9 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -26,6 +26,13 @@ "when": 1785412264325, "tag": "0002_strengthen_system_payload_check", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785700000000, + "tag": "0004_envelope_protocol", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/backend/src/__tests__/e2eeProtocol.test.ts b/apps/backend/src/__tests__/e2eeProtocol.test.ts new file mode 100644 index 0000000..56479ce --- /dev/null +++ b/apps/backend/src/__tests__/e2eeProtocol.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for Phase-1 → Signal protocol enforcement (#364). + * + * Negotiation itself (`selectProtocol` over `devices.capabilities`) belongs to + * the capabilities layer. What is tested here is what the send path does with + * a protocol a client *claims*: + * - an envelope the recipient cannot decrypt is refused + * - an envelope weaker than what both devices support is refused + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockDeviceFindMany = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst, findMany: mockDeviceFindMany }, + }, + }, +})); + +vi.mock('../db/schema.js', () => ({ + devices: { id: 'id' }, +})); + +vi.mock('drizzle-orm', () => ({ + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + inArray: vi.fn((col: unknown, vals: unknown) => ({ op: 'inArray', col, vals })), +})); + +const { checkEnvelopeProtocols, protocolsForRecipients } = + await import('../services/e2eeProtocol.js'); + +const SENDER = 'device-sender'; +const PHASE1_DEVICE = 'device-phase1'; +const SIGNAL_DEVICE = 'device-signal'; + +/** A capability document advertising the given protocols. */ +function caps(...protocols: string[]) { + return { protocols, ciphersuites: [], fileTransfer: [] }; +} + +function setupDevices(senderProtocols: string[], recipients: Record) { + mockDeviceFindFirst.mockResolvedValue({ capabilities: caps(...senderProtocols) }); + mockDeviceFindMany.mockResolvedValue( + Object.entries(recipients).map(([id, protocols]) => ({ + id, + capabilities: protocols === null ? null : caps(...protocols), + })), + ); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('checkEnvelopeProtocols', () => { + it('accepts sealed box when the recipient is Phase-1 only', async () => { + setupDevices(['sealed_box', 'signal'], { [PHASE1_DEVICE]: ['sealed_box'] }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: PHASE1_DEVICE, protocol: 'sealed_box' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('accepts Signal when both devices advertise it', async () => { + setupDevices(['sealed_box', 'signal'], { [SIGNAL_DEVICE]: ['sealed_box', 'signal'] }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: SIGNAL_DEVICE, protocol: 'signal' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('rejects a Signal envelope aimed at a Phase-1 device', async () => { + setupDevices(['sealed_box', 'signal'], { [PHASE1_DEVICE]: ['sealed_box'] }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: PHASE1_DEVICE, protocol: 'signal' }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(400); + expect(result.violations).toEqual([ + { + recipientDeviceId: PHASE1_DEVICE, + declared: 'signal', + expected: 'sealed_box', + reason: 'unsupported_by_recipient', + }, + ]); + }); + + it('rejects a sealed-box downgrade when both devices can do Signal', async () => { + setupDevices(['sealed_box', 'signal'], { [SIGNAL_DEVICE]: ['sealed_box', 'signal'] }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: SIGNAL_DEVICE, protocol: 'sealed_box' }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(409); + expect(result.violations[0]).toMatchObject({ + declared: 'sealed_box', + expected: 'signal', + reason: 'downgrade', + }); + }); + + it('negotiates per device pair, not per conversation', async () => { + // One upgraded recipient and one laggard in the same batch: the upgraded + // pair uses Signal and the laggard stays on sealed box. A conversation-wide + // rule would hold the upgraded pair back for no benefit. + setupDevices(['sealed_box', 'signal'], { + [SIGNAL_DEVICE]: ['sealed_box', 'signal'], + [PHASE1_DEVICE]: ['sealed_box'], + }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: SIGNAL_DEVICE, protocol: 'signal' }, + { recipientDeviceId: PHASE1_DEVICE, protocol: 'sealed_box' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('reports the undecryptable envelope ahead of a downgrade in a mixed batch', async () => { + setupDevices(['sealed_box', 'signal'], { + [SIGNAL_DEVICE]: ['sealed_box', 'signal'], + [PHASE1_DEVICE]: ['sealed_box'], + }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: SIGNAL_DEVICE, protocol: 'sealed_box' }, // downgrade + { recipientDeviceId: PHASE1_DEVICE, protocol: 'signal' }, // undecryptable + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(400); + expect(result.violations).toHaveLength(2); + }); + + it('treats a sender with no advertised capabilities as Phase-1 only', async () => { + // An old client that never sent capabilities must not be pushed onto + // Signal just because the recipient supports it. + mockDeviceFindFirst.mockResolvedValue({ capabilities: null }); + mockDeviceFindMany.mockResolvedValue([ + { id: SIGNAL_DEVICE, capabilities: caps('sealed_box', 'signal') }, + ]); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: SIGNAL_DEVICE, protocol: 'sealed_box' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('treats a recipient with a null capability document as Phase-1 only', async () => { + setupDevices(['sealed_box', 'signal'], { [PHASE1_DEVICE]: null }); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: PHASE1_DEVICE, protocol: 'sealed_box' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('skips envelopes naming a device that does not resolve', async () => { + // The send paths drop these before persisting; failing the whole request + // on one would block delivery to every other recipient. + setupDevices(['sealed_box'], {}); + + const result = await checkEnvelopeProtocols(SENDER, [ + { recipientDeviceId: 'device-gone', protocol: 'signal' }, + ]); + + expect(result).toEqual({ ok: true }); + }); + + it('accepts an empty envelope list without querying', async () => { + expect(await checkEnvelopeProtocols(SENDER, [])).toEqual({ ok: true }); + expect(mockDeviceFindMany).not.toHaveBeenCalled(); + }); +}); + +describe('protocolsForRecipients', () => { + it('maps each recipient to the protocol the pair should use', async () => { + setupDevices(['sealed_box', 'signal'], { + [SIGNAL_DEVICE]: ['sealed_box', 'signal'], + [PHASE1_DEVICE]: ['sealed_box'], + }); + + const result = await protocolsForRecipients(SENDER, [SIGNAL_DEVICE, PHASE1_DEVICE]); + + expect(result.get(SIGNAL_DEVICE)).toBe('signal'); + expect(result.get(PHASE1_DEVICE)).toBe('sealed_box'); + }); + + it('falls back to the baseline for a device that does not resolve', async () => { + setupDevices(['sealed_box', 'signal'], {}); + + const result = await protocolsForRecipients(SENDER, ['device-gone']); + + expect(result.get('device-gone')).toBe('sealed_box'); + }); + + it('returns an empty map for no recipients', async () => { + expect((await protocolsForRecipients(SENDER, [])).size).toBe(0); + }); +}); diff --git a/apps/backend/src/__tests__/signalMigration.routes.test.ts b/apps/backend/src/__tests__/signalMigration.routes.test.ts new file mode 100644 index 0000000..e1aca70 --- /dev/null +++ b/apps/backend/src/__tests__/signalMigration.routes.test.ts @@ -0,0 +1,245 @@ +/** + * Tests for the Phase-1 → Signal migration send path (#364). + * + * Capability advertisement rides on the existing `devices.capabilities` + * document (set at registration and updated by re-verifying), and per-device + * negotiation is already surfaced by `GET /conversations/:id/devices`. What is + * specific to the migration, and tested here, is `POST /messages`: + * - each envelope's declared protocol is enforced before anything persists + * - the protocol actually used is recorded on the envelope row + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockMemberFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockMessageFindFirst = vi.fn(); +const mockTransaction = vi.fn(); +const mockCheckEnvelopeProtocols = vi.fn(); +const mockInsertMessageEnvelopes = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + messages: { findFirst: mockMessageFindFirst }, + devices: { findMany: vi.fn(), findFirst: vi.fn() }, + }, + insert: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + transaction: mockTransaction, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + messages: { id: 'id' }, + messageEnvelopes: {}, + devices: { id: 'id' }, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + inArray: vi.fn(), +})); + +vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn() })); +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => null) })); +vi.mock('../services/fileCleanup.js', () => ({ softDeleteFile: vi.fn() })); +vi.mock('../lib/messageFanout.js', () => ({ + insertMessageEnvelopes: mockInsertMessageEnvelopes, +})); +vi.mock('../services/e2eeProtocol.js', () => ({ + checkEnvelopeProtocols: mockCheckEnvelopeProtocols, +})); + +const USER_ID = 'user-1'; +const DEVICE_ID = 'device-1'; +const CONVERSATION_ID = '11111111-1111-4111-8111-111111111111'; +const RECIPIENT_DEVICE_ID = '22222222-2222-4222-8222-222222222222'; +const MESSAGE_ID = '33333333-3333-4333-8333-333333333333'; + +vi.mock('../middleware/auth.js', () => ({ + requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { + (req as express.Request & { auth: { userId: string; deviceId: string } }).auth = { + userId: USER_ID, + deviceId: DEVICE_ID, + }; + next(); + }, +})); + +const { messagesRouter } = await import('../routes/messages.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/messages', messagesRouter); + return app; +} + +const BODY = { + conversationId: CONVERSATION_ID, + messageId: MESSAGE_ID, + contentType: 'text', + ciphertext: 'body-ciphertext', + envelopes: [{ recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env-ciphertext' }], +}; + +function setupInsertTransaction() { + const tx = { + insert: vi.fn().mockReturnValue({ + values: vi.fn().mockReturnValue({ + returning: vi.fn().mockResolvedValue([{ id: MESSAGE_ID, conversationId: CONVERSATION_ID }]), + }), + }), + }; + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockMemberFindFirst.mockResolvedValue({ id: 'membership-1' }); + mockMemberFindMany.mockResolvedValue([{ userId: USER_ID }]); + mockMessageFindFirst.mockResolvedValue(undefined); + mockCheckEnvelopeProtocols.mockResolvedValue({ ok: true }); + mockInsertMessageEnvelopes.mockResolvedValue([RECIPIENT_DEVICE_ID]); + setupInsertTransaction(); +}); + +describe('POST /messages — protocol enforcement (#364)', () => { + it('defaults an envelope with no protocol to sealed box', async () => { + const res = await request(makeApp()).post('/messages').send(BODY); + + expect(res.status).toBe(201); + expect(mockCheckEnvelopeProtocols).toHaveBeenCalledWith(DEVICE_ID, [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, protocol: 'sealed_box' }, + ]); + }); + + it('passes a declared Signal protocol through to the check', async () => { + const res = await request(makeApp()) + .post('/messages') + .send({ + ...BODY, + envelopes: [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env', protocol: 'signal' }, + ], + }); + + expect(res.status).toBe(201); + expect(mockCheckEnvelopeProtocols).toHaveBeenCalledWith(DEVICE_ID, [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, protocol: 'signal' }, + ]); + }); + + it('persists the declared protocol on the envelope row', async () => { + await request(makeApp()) + .post('/messages') + .send({ + ...BODY, + envelopes: [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env', protocol: 'signal' }, + ], + }); + + const [, , envelopes] = mockInsertMessageEnvelopes.mock.calls[0]!; + expect(envelopes).toEqual([ + expect.objectContaining({ recipientDeviceId: RECIPIENT_DEVICE_ID, protocol: 'signal' }), + ]); + }); + + it('returns 400 with the violations when an envelope is undecryptable', async () => { + const violations = [ + { + recipientDeviceId: RECIPIENT_DEVICE_ID, + declared: 'signal', + expected: 'sealed_box', + reason: 'unsupported_by_recipient', + }, + ]; + mockCheckEnvelopeProtocols.mockResolvedValue({ + ok: false, + code: 400, + error: 'Envelope protocol is not supported by the recipient device', + violations, + }); + + const res = await request(makeApp()) + .post('/messages') + .send({ + ...BODY, + envelopes: [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env', protocol: 'signal' }, + ], + }); + + expect(res.status).toBe(400); + expect(res.body.violations).toEqual(violations); + expect(mockTransaction).not.toHaveBeenCalled(); + }); + + it('returns 409 on a downgrade and persists nothing', async () => { + mockCheckEnvelopeProtocols.mockResolvedValue({ + ok: false, + code: 409, + error: 'Envelope protocol is weaker than both devices support', + violations: [ + { + recipientDeviceId: RECIPIENT_DEVICE_ID, + declared: 'sealed_box', + expected: 'signal', + reason: 'downgrade', + }, + ], + }); + + const res = await request(makeApp()).post('/messages').send(BODY); + + expect(res.status).toBe(409); + expect(res.body.violations[0].reason).toBe('downgrade'); + expect(mockTransaction).not.toHaveBeenCalled(); + }); + + it('rejects an unknown protocol value at the schema layer', async () => { + const res = await request(makeApp()) + .post('/messages') + .send({ + ...BODY, + envelopes: [{ recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env', protocol: 'pgp' }], + }); + + expect(res.status).toBe(400); + expect(mockCheckEnvelopeProtocols).not.toHaveBeenCalled(); + }); + + it('checks membership before running the protocol check', async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).post('/messages').send(BODY); + + expect(res.status).toBe(403); + expect(mockCheckEnvelopeProtocols).not.toHaveBeenCalled(); + }); + + it('skips the protocol check for a message with no envelopes', async () => { + // MLS group messages carry one group ciphertext and no per-device + // envelopes (#372), so there is nothing to negotiate. + const res = await request(makeApp()).post('/messages').send({ + conversationId: CONVERSATION_ID, + messageId: MESSAGE_ID, + contentType: 'text', + ciphertext: 'group-ciphertext', + mlsEpoch: 7, + }); + + expect(res.status).toBe(201); + expect(mockCheckEnvelopeProtocols).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/__tests__/uploads.test.ts b/apps/backend/src/__tests__/uploads.test.ts index 886a08e..61a5ae0 100644 --- a/apps/backend/src/__tests__/uploads.test.ts +++ b/apps/backend/src/__tests__/uploads.test.ts @@ -59,6 +59,8 @@ vi.mock('../lib/storage.js', () => ({ vi.mock('../services/mlsGroups.js', () => ({ getGroupByConversation: vi.fn().mockResolvedValue(null), isActiveMember: vi.fn().mockResolvedValue(false), +})); + vi.mock('../lib/fileIntegrity.js', () => ({ verifyFileIntegrity: mockVerifyFileIntegrity, })); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 1038bd4..cc36f2b 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -11,7 +11,6 @@ import { jsonb, uniqueIndex, check, - jsonb, type AnyPgColumn, } from 'drizzle-orm/pg-core'; import { relations, sql } from 'drizzle-orm'; @@ -170,6 +169,23 @@ export const messages = pgTable( ], ); +// Which E2EE construction produced an envelope's ciphertext (#364). +// +// `sealed_box` is the Phase-1 path: ECDH ephemeral key + HKDF + AES-256-GCM, +// one independent box per message. `signal` is the Double Ratchet. +// +// `devices.capabilities.protocols` says what a device *can* decrypt; this says +// what a given envelope actually *was* encrypted with. Both are needed: the +// capability set changes over time, so it cannot be used to interpret an +// envelope written months ago. Recording it per envelope is what lets +// pre-cutover history keep decrypting on the Phase-1 path indefinitely. +// +// Values mirror `KNOWN_PROTOCOLS` in lib/capabilities.ts so the two cannot +// drift. `mls` is included for that reason even though MLS group messages +// carry a single group ciphertext on `messages` rather than per-device +// envelopes — an envelope row for it should be representable, not a migration. +export const e2eeProtocolEnum = pgEnum('e2ee_protocol', ['sealed_box', 'signal', 'mls']); + export const messageEnvelopes = pgTable( 'message_envelopes', { @@ -184,6 +200,9 @@ export const messageEnvelopes = pgTable( .notNull() .references(() => users.id, { onDelete: 'cascade' }), ciphertext: text('ciphertext').notNull(), + // Defaults to sealed_box so every envelope written before this column + // existed is labelled correctly by the migration's backfill. + protocol: e2eeProtocolEnum('protocol').notNull().default('sealed_box'), deliveredAt: timestamp('delivered_at'), readAt: timestamp('read_at'), createdAt: timestamp('created_at').notNull().defaultNow(), @@ -232,9 +251,10 @@ export const devices = pgTable( // baseline so rows written before this column existed, or clients that // never send it, negotiate correctly rather than erroring — see // lib/capabilities.ts `normalizeCapabilities`. - capabilities: jsonb('capabilities').$type().notNull().default( - DEFAULT_CAPABILITIES, - ), + capabilities: jsonb('capabilities') + .$type() + .notNull() + .default(DEFAULT_CAPABILITIES), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), }, @@ -435,15 +455,6 @@ export const mlsWelcomes = pgTable( export const mlsKeyPackages = pgTable( 'mls_key_packages', -// ─── Device key history (#379 — key-transparency) ──────────────────────────── -// -// Append-only log of identity-key changes per device. Written whenever a -// device's `identityPublicKey` changes (rotation or re-registration). Clients -// use this log to detect silent key swaps and display safety-number warnings. -// Never deleted — immutability is the whole point. - -export const deviceKeyHistory = pgTable( - 'device_key_history', { id: uuid('id').primaryKey().defaultRandom(), deviceId: uuid('device_id') @@ -470,6 +481,20 @@ export const deviceKeyHistory = pgTable( ], ); +// ─── Device key history (#379 — key-transparency) ──────────────────────────── +// +// Append-only log of identity-key changes per device. Written whenever a +// device's `identityPublicKey` changes (rotation or re-registration). Clients +// use this log to detect silent key swaps and display safety-number warnings. +// Never deleted — immutability is the whole point. + +export const deviceKeyHistory = pgTable( + 'device_key_history', + { + id: uuid('id').primaryKey().defaultRandom(), + deviceId: uuid('device_id') + .notNull() + .references(() => devices.id, { onDelete: 'cascade' }), userId: uuid('user_id') .notNull() .references(() => users.id, { onDelete: 'cascade' }), diff --git a/apps/backend/src/lib/messageFanout.ts b/apps/backend/src/lib/messageFanout.ts index 9758ca1..165f0d4 100644 --- a/apps/backend/src/lib/messageFanout.ts +++ b/apps/backend/src/lib/messageFanout.ts @@ -12,11 +12,18 @@ import { and, eq, inArray, isNull, ne } from 'drizzle-orm'; import { db } from '../db/index.js'; import { devices, messageEnvelopes } from '../db/schema.js'; +import { BASELINE_PROTOCOL, type KnownProtocol } from './capabilities.js'; /** A client-supplied (or server-derived) envelope destined for one device. */ export interface EnvelopeInput { recipientDeviceId: string; ciphertext: string; + /** + * Which construction produced `ciphertext` (#364). Omitted means the + * Phase-1 sealed box — the protocol every device in this codebase already + * implements, and what a client that predates the migration sends. + */ + protocol?: KnownProtocol; } /** @@ -95,6 +102,9 @@ export async function insertMessageEnvelopes( recipientDeviceId: env.recipientDeviceId, recipientUserId: deviceToUser.get(env.recipientDeviceId)!, ciphertext: env.ciphertext, + // Recorded per envelope so pre-cutover history stays interpretable + // after a device's capabilities change (#364). + protocol: env.protocol ?? BASELINE_PROTOCOL, })); if (validEnvelopes.length === 0) return []; diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 5fddfe1..a41a486 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -1,6 +1,19 @@ import { Router } from 'express'; import type { IRouter } from 'express'; -import { asc, and, count, desc, eq, inArray, isNotNull, lt, notInArray, or, sql, ne } from 'drizzle-orm'; +import { + asc, + and, + count, + desc, + eq, + inArray, + isNotNull, + lt, + notInArray, + or, + sql, + ne, +} from 'drizzle-orm'; import { db } from '../db/index.js'; import { conversationMembers, @@ -68,9 +81,7 @@ type SerializedConversationPayload = { [key: string]: unknown; }; -function serializeConversation( - conversation: ConversationPayload, -): SerializedConversationPayload { +function serializeConversation(conversation: ConversationPayload): SerializedConversationPayload { return { id: conversation.id, type: conversation.type, @@ -340,6 +351,9 @@ conversationsRouter.post('/:id/members', async (req: AuthRequest, res) => { const inviteCheck = await checkGroupInviteLimit(redis, requesterId); if (!inviteCheck.allowed) { res.status(429).json({ error: 'Too many group invites. Please try again later.' }); + return; + } + const targetUser = await db.query.users.findFirst({ where: eq(users.id, newUserId), columns: { allowGroupInvites: true }, diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 848a2de..47e08de 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -20,8 +20,8 @@ import { mlsKeyPackages, conversationMembers, messages, + wallets, } from '../db/schema.js'; -import { devices, devicePrekeys, conversationMembers, messages, wallets } from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; import { DeviceLinkVerifySchema, type DeviceLinkVerifyBody } from '../schemas/auth.schemas.js'; @@ -43,6 +43,7 @@ import { countAvailableKeyPackages, hashKeyPackage, } from '../services/mlsKeyPackages.js'; +import { PREKEY_LOW_THRESHOLD, countAvailableOneTimePreKeys, releasePrekeysLowLatch, @@ -621,26 +622,6 @@ devicesRouter.post( return; } - void recordAuditEvent({ - action: 'device_linked', - ...actorFromRequest(req), - targetType: 'device', - targetId: row.id, - metadata: { - deviceName: body.deviceName ?? null, - platform: body.platform ?? null, - // Re-activating a revoked identity key is not the same event as - // linking a brand-new device, and the difference matters after a - // revocation that was meant to lock someone out. - reactivatedRevokedDevice: Boolean(existing), - }, - }); - - res.status(201).json({ id: row.id, createdAt: row.createdAt }); - } catch (err) { - console.error('Failed to register device:', err); - res.status(500).json({ error: 'Failed to register device' }); - } if ( !verifyWalletSignature(walletAddress, deviceLinkMessage(userId, body.nonce), body.signature) ) { @@ -694,6 +675,21 @@ devicesRouter.post( void emitDeviceChangeEvent(userId, 'device_added'); + void recordAuditEvent({ + action: 'device_linked', + ...actorFromRequest(req), + targetType: 'device', + targetId: row.id, + metadata: { + deviceName: body.deviceName ?? null, + platform: body.platform ?? null, + // Re-activating a revoked identity key is not the same event as + // linking a brand-new device, and the difference matters after a + // revocation that was meant to lock someone out. + reactivatedRevokedDevice: Boolean(existing), + }, + }); + res.status(201).json({ id: row.id, createdAt: row.createdAt }); } catch (err) { console.error('Failed to register device:', err); diff --git a/apps/backend/src/routes/files.ts b/apps/backend/src/routes/files.ts index a20543d..ae6ade3 100644 --- a/apps/backend/src/routes/files.ts +++ b/apps/backend/src/routes/files.ts @@ -24,8 +24,6 @@ filesRouter.use(requireAuth); // ciphertext and never sees the key, so "who can open this file" is decided by // who could decrypt the message that carried the key. This route mirrors that // decision rather than inventing a second, weaker rule. -filesRouter.get('/:fileId', async (req: AuthRequest, res) => { -// and decrypt it locally (#166). Access is gated on conversation membership. filesRouter.get('/:fileId', rateLimit('file_download'), async (req: AuthRequest, res) => { const userId = req.auth!.userId; const deviceId = req.auth!.deviceId as string | undefined; @@ -78,7 +76,6 @@ filesRouter.get('/:fileId', rateLimit('file_download'), async (req: AuthRequest, const reachable = referencing.filter((m) => memberOf.has(m.conversationId)); if (reachable.length === 0) { - if (!membership) { // A non-member reaching for a file id is the clearest signal of an // attempt to read someone else's attachments (#376). void recordAuditEvent({ @@ -86,7 +83,7 @@ filesRouter.get('/:fileId', rateLimit('file_download'), async (req: AuthRequest, ...actorFromRequest(req), targetType: 'file', targetId: fileId, - metadata: { conversationId: message.conversationId, reason: 'not_a_member' }, + metadata: { conversationId: referencing[0]!.conversationId, reason: 'not_a_member' }, }); res.status(403).json({ error: 'Not authorized to access this file' }); diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index b1261b3..124446e 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -1,8 +1,8 @@ import { Router } from 'express'; import type { IRouter } from 'express'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { conversationMembers, messages, messageEnvelopes, devices } from '../db/schema.js'; +import { conversationMembers, messages, messageEnvelopes } from '../db/schema.js'; import { softDeleteFile } from '../services/fileCleanup.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { validate } from '../middleware/validate.js'; @@ -10,6 +10,9 @@ import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { getSocketServer } from '../lib/socket.js'; import { validateMessagePayload } from '../lib/validateMessagePayload.js'; import { SendMessageSchema } from '../schemas/message.schemas.js'; +import { checkEnvelopeProtocols, type E2eeProtocol } from '../services/e2eeProtocol.js'; +import { insertMessageEnvelopes } from '../lib/messageFanout.js'; +import { BASELINE_PROTOCOL } from '../lib/capabilities.js'; export const messagesRouter: IRouter = Router(); @@ -98,25 +101,21 @@ async function applyMembershipCommit( messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, res) => { const userId = req.auth!.userId; const deviceId = req.auth!.deviceId as string | undefined; + const body = req.body as Record; const { conversationId, messageId, contentType, ciphertext, envelopes, fileId, mlsEpoch } = - req.body as { + body as { conversationId: string; messageId: string; contentType?: string; ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + envelopes?: Array<{ + recipientDeviceId: string; + ciphertext: string; + protocol?: E2eeProtocol; + }>; fileId?: string; mlsEpoch?: number; }; - const body = req.body as Record; - const { conversationId, messageId, contentType, ciphertext, envelopes, fileId } = body as { - conversationId: string; - messageId: string; - contentType?: string; - ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; - fileId?: string; - }; const membershipChange = membershipChangeFromBody(body); if (membershipChange) { @@ -153,6 +152,28 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r return; } + // ── E2EE protocol enforcement (#364) ─────────────────────────────────────── + // Rejects an envelope naming a protocol its recipient cannot decrypt, and a + // fallback weaker than what both devices support. + if (envelopes && envelopes.length > 0) { + const protocolCheck = await checkEnvelopeProtocols( + deviceId, + envelopes.map((e) => ({ + recipientDeviceId: e.recipientDeviceId, + protocol: e.protocol ?? BASELINE_PROTOCOL, + })), + ); + + if (!protocolCheck.ok) { + res.status(protocolCheck.code).json({ + error: protocolCheck.error, + violations: protocolCheck.violations, + }); + return; + } + } + + // ── idempotency ──────────────────────────────────────────────────────────── const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), columns: { createdAt: true }, @@ -180,27 +201,9 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r }) .returning(); - if (envelopes && envelopes.length > 0) { - const deviceIds = envelopes.map((e) => e.recipientDeviceId); - const devicesList = await tx.query.devices.findMany({ - where: inArray(devices.id, deviceIds), - columns: { id: true, userId: true }, - }); - const deviceToUser = new Map(devicesList.map((d: { id: string; userId: string }) => [d.id, d.userId])); - - const validEnvelopes = envelopes - .filter((env) => deviceToUser.has(env.recipientDeviceId)) - .map((env) => ({ - messageId, - recipientDeviceId: env.recipientDeviceId, - recipientUserId: deviceToUser.get(env.recipientDeviceId)!, - ciphertext: env.ciphertext, - })); - - if (validEnvelopes.length > 0) { - await tx.insert(messageEnvelopes).values(validEnvelopes); - } - } + // Shared with the socket send paths (#188/#337) so the per-envelope + // protocol default cannot drift between the two implementations. + await insertMessageEnvelopes(tx, messageId, envelopes); if (membershipChange && isCommit(membershipChange)) { await applyMembershipCommit(tx, conversationId, membershipChange); diff --git a/apps/backend/src/routes/sync.ts b/apps/backend/src/routes/sync.ts index be32b21..35eff79 100644 --- a/apps/backend/src/routes/sync.ts +++ b/apps/backend/src/routes/sync.ts @@ -102,6 +102,7 @@ syncRouter.get('/', async (req: AuthRequest, res) => { id: messageEnvelopes.id, messageId: messageEnvelopes.messageId, ciphertext: messageEnvelopes.ciphertext, + protocol: messageEnvelopes.protocol, deliveredAt: messageEnvelopes.deliveredAt, envelopeCreatedAt: messageEnvelopes.createdAt, conversationId: messages.conversationId, @@ -160,6 +161,10 @@ syncRouter.get('/', async (req: AuthRequest, res) => { senderDeviceId: r.senderDeviceId, contentType: r.contentType, ciphertext: r.ciphertext, + // #364 — which construction encrypted this envelope. Envelopes written + // before the cutover stay `sealed_box` and decrypt on the Phase-1 path, + // so catching up across the cutover loses nothing. + protocol: r.protocol, deliveredAt: r.deliveredAt, createdAt: r.envelopeCreatedAt, messageCreatedAt: r.messageCreatedAt, diff --git a/apps/backend/src/routes/uploads.ts b/apps/backend/src/routes/uploads.ts index 6407d7a..ee5a80e 100644 --- a/apps/backend/src/routes/uploads.ts +++ b/apps/backend/src/routes/uploads.ts @@ -86,6 +86,8 @@ uploadsRouter.post('/', rateLimit('upload_slot'), async (req: AuthRequest, res) res.status(403).json({ error: 'Device is not a member of this conversation MLS group' }); return; } + } + // Daily volume quota (#375). Charged in bytes rather than requests: the // per-minute slot limit says nothing about a caller requesting twenty // hundred-megabyte slots an hour, which is the shape that actually fills diff --git a/apps/backend/src/routes/users.ts b/apps/backend/src/routes/users.ts index 5f94635..177facd 100644 --- a/apps/backend/src/routes/users.ts +++ b/apps/backend/src/routes/users.ts @@ -2,7 +2,14 @@ import { createHash } from 'node:crypto'; import { Router, type Router as RouterType } from 'express'; import { eq, and, or, ilike, exists, sql, isNull, count } from 'drizzle-orm'; import { db } from '../db/index.js'; -import { users, wallets, devices, devicePrekeys, conversationMembers, deviceKeyHistory } from '../db/schema.js'; +import { + users, + wallets, + devices, + devicePrekeys, + conversationMembers, + deviceKeyHistory, +} from '../db/schema.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { rateLimit } from '../middleware/rateLimit.js'; import { redis } from '../lib/redis.js'; @@ -290,70 +297,36 @@ usersRouter.get( return { keyId: candidate.keyId, publicKey: candidate.publicKey }; }); - const claimed = await db.transaction(async (tx) => { - const [candidate] = await tx - .select({ - id: devicePrekeys.id, - keyId: devicePrekeys.keyId, - publicKey: devicePrekeys.publicKey, - }) - .from(devicePrekeys) - .where( - and( - eq(devicePrekeys.deviceId, deviceId), - eq(devicePrekeys.keyType, 'one_time'), - eq(devicePrekeys.consumed, false), - ), - ) - .orderBy(devicePrekeys.createdAt) - .limit(1) - .for('update', { skipLocked: true }); - - if (!candidate) return null; - - await tx - .update(devicePrekeys) - .set({ consumed: true }) - .where(eq(devicePrekeys.id, candidate.id)); - - // Counted inside the claiming transaction so `remaining` reflects this - // consumption and cannot race a concurrent fetch into a wrong value. - const [remainingRow] = await tx - .select({ total: count() }) - .from(devicePrekeys) - .where( - and( - eq(devicePrekeys.deviceId, deviceId), - eq(devicePrekeys.keyType, 'one_time'), - eq(devicePrekeys.consumed, false), - ), - ); + const claimed = await db.transaction(async (tx) => { + const [candidate] = await tx + .select({ + id: devicePrekeys.id, + keyId: devicePrekeys.keyId, + publicKey: devicePrekeys.publicKey, + }) + .from(devicePrekeys) + .where( + and( + eq(devicePrekeys.deviceId, deviceId), + eq(devicePrekeys.keyType, 'one_time'), + eq(devicePrekeys.consumed, false), + ), + ) + .orderBy(devicePrekeys.createdAt) + .limit(1) + .for('update', { skipLocked: true }); - return { - oneTimePreKey: { keyId: candidate.keyId, publicKey: candidate.publicKey }, - remaining: remainingRow?.total ?? 0, - }; - }); + if (!candidate) return null; - const claimedOneTimePreKey = claimed?.oneTimePreKey ?? null; - - // Fire-and-forget: the device that owns this bundle is told to replenish - // once per threshold crossing. Never blocks or fails the bundle response. - if (claimed) { - void signalPrekeysLowIfNeeded(deviceId, claimed.remaining); - // A one-time prekey was consumed and cannot be handed out again (#376). - // Draining a device's supply forces every later session with it down from - // 4-DH to 3-DH, and it happens quietly, so the count left is the signal an - // incident responder actually needs. Subject is the device owner — the - // account this was done *to* — while the actor is whoever fetched it. - if (claimedOneTimePreKey) { - // The remaining count is the useful part but only a nice-to-have: if the - // count query fails, still record that a prekey was consumed rather than - // losing the event, and never fail the bundle fetch over bookkeeping. - let remaining: number | null = null; - try { - const [remainingRow] = await db - .select({ remaining: sql`count(*)::int` }) + await tx + .update(devicePrekeys) + .set({ consumed: true }) + .where(eq(devicePrekeys.id, candidate.id)); + + // Counted inside the claiming transaction so `remaining` reflects this + // consumption and cannot race a concurrent fetch into a wrong value. + const [remainingRow] = await tx + .select({ total: count() }) .from(devicePrekeys) .where( and( @@ -362,36 +335,75 @@ usersRouter.get( eq(devicePrekeys.consumed, false), ), ); - remaining = remainingRow?.remaining ?? 0; - } catch { - // Leave it null — the event itself is what must not be lost. - } - void recordAuditEvent({ - action: 'key_bundle_drained', - ...actorFromRequest(req), - subjectUserId: targetUserId, - targetType: 'device', - targetId: deviceId, - metadata: { oneTimePreKeysRemaining: remaining, exhausted: remaining === 0 }, + return { + oneTimePreKey: { keyId: candidate.keyId, publicKey: candidate.publicKey }, + remaining: remainingRow?.total ?? 0, + }; }); - } - res.json({ - deviceId: device.id, - identityPublicKey: device.identityPublicKey, - registrationId: device.registrationId, - // Lets the initiating sender pick an encryption path this recipient - // device supports before running X3DH (#180-follow-on). - capabilities: normalizeCapabilities(device.capabilities), - signedPreKey: { - keyId: signedPreKey.keyId, - publicKey: signedPreKey.publicKey, - signature: signedPreKey.signature, - }, - oneTimePreKey: claimedOneTimePreKey, - }); -}); + const claimedOneTimePreKey = claimed?.oneTimePreKey ?? null; + + // Fire-and-forget: the device that owns this bundle is told to replenish + // once per threshold crossing. Never blocks or fails the bundle response. + // A one-time prekey was consumed and cannot be handed out again (#376). + // Draining a device's supply forces every later session with it down from + // 4-DH to 3-DH, and it happens quietly, so the count left is the signal an + // incident responder actually needs. Subject is the device owner — the + // account this was done *to* — while the actor is whoever fetched it. + if (claimedOneTimePreKey) { + // The remaining count is the useful part but only a nice-to-have: if the + // count query fails, still record that a prekey was consumed rather than + // losing the event, and never fail the bundle fetch over bookkeeping. + let remaining: number | null = null; + try { + const [remainingRow] = await db + .select({ remaining: sql`count(*)::int` }) + .from(devicePrekeys) + .where( + and( + eq(devicePrekeys.deviceId, deviceId), + eq(devicePrekeys.keyType, 'one_time'), + eq(devicePrekeys.consumed, false), + ), + ); + remaining = remainingRow?.remaining ?? 0; + } catch { + // Leave it null — the event itself is what must not be lost. + } + + // Fire-and-forget: the device that owns this bundle is told to replenish + // once per threshold crossing. Never blocks or fails the bundle response. + if (remaining !== null) { + void signalPrekeysLowIfNeeded(deviceId, remaining); + } + + void recordAuditEvent({ + action: 'key_bundle_drained', + ...actorFromRequest(req), + subjectUserId: targetUserId, + targetType: 'device', + targetId: deviceId, + metadata: { oneTimePreKeysRemaining: remaining, exhausted: remaining === 0 }, + }); + } + + res.json({ + deviceId: device.id, + identityPublicKey: device.identityPublicKey, + registrationId: device.registrationId, + // Lets the initiating sender pick an encryption path this recipient + // device supports before running X3DH (#180-follow-on). + capabilities: normalizeCapabilities(device.capabilities), + signedPreKey: { + keyId: signedPreKey.keyId, + publicKey: signedPreKey.publicKey, + signature: signedPreKey.signature, + }, + oneTimePreKey: claimedOneTimePreKey, + }); + }, +); /** * GET /users/:userId/devices/:deviceId/mls-key-package @@ -499,47 +511,47 @@ usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { columns: { id: true }, }); - if (!device || device.userId !== targetUserId || device.revokedAt) { - res.status(404).json({ error: 'Device not found or has been revoked' }); + if (!user) { + res.status(404).json({ error: 'User not found' }); return; } - const signedPreKey = await db.query.devicePrekeys.findFirst({ - where: and(eq(devicePrekeys.deviceId, deviceId), eq(devicePrekeys.keyType, 'signed')), + // Fetch all active (non-revoked) device identity public keys. + const activeDevices = await db.query.devices.findMany({ + where: and(eq(devices.userId, id), isNull(devices.revokedAt)), + columns: { identityPublicKey: true }, }); - if (!signedPreKey) { - res.status(409).json({ error: 'Device has not uploaded a signed prekey yet' }); + if (activeDevices.length === 0) { + res.status(404).json({ error: 'No active devices found for this user' }); return; } - const claimedOneTimePreKey = await db.transaction(async (tx) => { - const [candidate] = await tx - .select({ - id: devicePrekeys.id, - keyId: devicePrekeys.keyId, - publicKey: devicePrekeys.publicKey, - }) - .from(devicePrekeys) - .where( - and( - eq(devicePrekeys.deviceId, deviceId), - eq(devicePrekeys.keyType, 'one_time'), - eq(devicePrekeys.consumed, false), - ), - ) - .orderBy(devicePrekeys.createdAt) - .limit(1) - .for('update', { skipLocked: true }); + // Sort lexicographically, then concatenate with a newline separator, so + // the value is identical on every client regardless of device order. + const sortedKeys = activeDevices + .map((d) => d.identityPublicKey) + .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)); - if (!candidate) return null; + const digest = createHash('sha256').update(sortedKeys.join('\n'), 'utf8').digest(); - await tx - .update(devicePrekeys) - .set({ consumed: true }) - .where(eq(devicePrekeys.id, candidate.id)); + // Two 30-digit segments from non-overlapping 15-byte halves of the digest. + function bytesToSafetySegment(buf: Buffer, offset: number, length: number): string { + let value = BigInt(0); + for (let i = 0; i < length; i++) { + value = (value << BigInt(8)) | BigInt(buf[offset + i]!); + } + return (value % BigInt('1' + '0'.repeat(30))).toString().padStart(30, '0'); + } - return { keyId: candidate.keyId, publicKey: candidate.publicKey }; + const raw = bytesToSafetySegment(digest, 0, 15) + bytesToSafetySegment(digest, 15, 15); + + res.json({ + userId: id, + /** Raw 60-digit numeric fingerprint; clients compare this. */ + fingerprint: raw, + /** Groups of 5, matching Signal's safety-number display format. */ + formatted: raw.match(/.{5}/g)!.join(' '), }); } catch { res.status(500).json({ error: 'Failed to compute key fingerprint' }); @@ -616,19 +628,31 @@ usersRouter.patch('/me', async (req: AuthRequest, res) => { const existing = await db.query.users.findFirst({ where: eq(users.username, username), }); - }, -); + if (existing && existing.id !== userId) { + res.status(409).json({ error: 'Username is already taken' }); + return; + } -usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { - const userId = req.params['id'] as string; + updateData.username = username; + } + + updateData.updatedAt = new Date(); try { + // Read the previous visibility so the presence broadcast below only fires + // when the setting actually changed. const oldUser = await db.query.users.findFirst({ where: eq(users.id, userId), columns: { presenceVisible: true, lastSeenVisible: true }, }); - if (rows.length === 0) { + const [updatedUser] = await db + .update(users) + .set(updateData) + .where(eq(users.id, userId)) + .returning(); + + if (!updatedUser) { res.status(404).json({ error: 'User not found' }); return; } @@ -672,9 +696,9 @@ usersRouter.get('/:id/key-fingerprint', async (req: AuthRequest, res) => { } } - res.json({ userId, fingerprint }); + res.json(updatedUser); } catch { - res.status(404).json({ error: 'User not found' }); + res.status(409).json({ error: 'Username conflict or database error' }); } }); diff --git a/apps/backend/src/schemas/message.schemas.ts b/apps/backend/src/schemas/message.schemas.ts index b8df1da..f439306 100644 --- a/apps/backend/src/schemas/message.schemas.ts +++ b/apps/backend/src/schemas/message.schemas.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { BASELINE_PROTOCOL, KNOWN_PROTOCOLS } from '../lib/capabilities.js'; /** * Zod schema for the REST POST /messages send path. @@ -10,33 +11,26 @@ import { z } from 'zod'; */ // `.strict()` on both schemas: a message envelope only ever carries a -// recipient device id and opaque ciphertext. An unrecognized field (e.g. a -// client attaching `ratchetState` or `privateKey`) must fail validation -// (400) instead of being silently stripped — the server never stores or -// relays Signal session/ratchet/private-key state. +// recipient device id, opaque ciphertext, and the name of the construction +// that produced it. An unrecognized field (e.g. a client attaching +// `ratchetState` or `privateKey`) must fail validation (400) instead of being +// silently stripped — the server never stores or relays Signal +// session/ratchet/private-key state. export const EnvelopeSchema = z .object({ recipientDeviceId: z.string().uuid('recipientDeviceId must be a valid UUID'), ciphertext: z.string().min(1, 'envelope ciphertext is required'), + /** + * Which construction produced `ciphertext` (#364). Defaults to the Phase-1 + * sealed box so clients that predate the migration keep working unchanged. + * Must be a protocol the recipient device advertises in its capabilities, + * and may not downgrade below what both devices support — enforced in + * services/e2eeProtocol.ts. + */ + protocol: z.enum(KNOWN_PROTOCOLS).optional().default(BASELINE_PROTOCOL), }) .strict(); -export const SendMessageSchema = z.object({ - conversationId: z.string().uuid('conversationId must be a valid UUID'), - messageId: z.string().uuid('messageId must be a valid UUID'), - contentType: z.string().trim().toLowerCase().optional().default('text'), - ciphertext: z.string().optional(), - envelopes: z.array(EnvelopeSchema).optional(), - /** UUID of an already-uploaded file; required when contentType is file/image/video/audio */ - fileId: z.string().uuid('fileId must be a valid UUID').optional(), - /** - * MLS epoch whose secrets encrypted `ciphertext` (#372). Present only on MLS - * group messages, which carry one group ciphertext instead of per-device - * envelopes. Recorded so the history read paths know which devices can - * derive the key. - */ - mlsEpoch: z.number().int().nonnegative().optional(), -}); export const SendMessageSchema = z .object({ conversationId: z.string().uuid('conversationId must be a valid UUID'), @@ -44,7 +38,15 @@ export const SendMessageSchema = z contentType: z.string().trim().toLowerCase().optional().default('text'), ciphertext: z.string().min(1, 'ciphertext is required').optional(), envelopes: z.array(EnvelopeSchema).optional(), + /** UUID of an already-uploaded file; required when contentType is file/image/video/audio */ fileId: z.string().uuid('fileId must be a valid UUID').optional(), + /** + * MLS epoch whose secrets encrypted `ciphertext` (#372). Present only on MLS + * group messages, which carry one group ciphertext instead of per-device + * envelopes. Recorded so the history read paths know which devices can + * derive the key. + */ + mlsEpoch: z.number().int().nonnegative().optional(), }) .strict(); diff --git a/apps/backend/src/services/deliveryPipeline.ts b/apps/backend/src/services/deliveryPipeline.ts index 0146ead..3a304f3 100644 --- a/apps/backend/src/services/deliveryPipeline.ts +++ b/apps/backend/src/services/deliveryPipeline.ts @@ -44,6 +44,13 @@ export async function deliverMessage( if (members.length === 0) return; + // MLS Welcome messages ride the normal device-scoped delivery path but are + // tagged so the client routes them to the group-join handler rather than the + // timeline. The Welcome itself stays an opaque string. + const welcomeTransport = isMlsWelcomeContentType(message.contentType) + ? mlsWelcomeTransport() + : null; + const userIds = members.map((m) => m.userId); const activeDevices = await db @@ -64,6 +71,7 @@ export async function deliverMessage( id: messageEnvelopes.id, recipientDeviceId: messageEnvelopes.recipientDeviceId, ciphertext: messageEnvelopes.ciphertext, + protocol: messageEnvelopes.protocol, }) .from(messageEnvelopes) .where( @@ -89,6 +97,8 @@ export async function deliverMessage( createdAt: message.createdAt, envelopeId: envelope.id, ciphertext: envelope.ciphertext, + // #364 — tells the receiving device which decryption path to use. + protocol: envelope.protocol, ...(welcomeTransport ?? {}), }); } diff --git a/apps/backend/src/services/e2eeProtocol.ts b/apps/backend/src/services/e2eeProtocol.ts new file mode 100644 index 0000000..7706456 --- /dev/null +++ b/apps/backend/src/services/e2eeProtocol.ts @@ -0,0 +1,183 @@ +/** + * Phase-1 → Signal migration enforcement (#364). + * + * The product shipped on the Phase-1 sealed box (ECDH + HKDF + AES-256-GCM, + * one independent box per recipient device). Signal's Double Ratchet replaces + * it, but not everywhere at once: clients update at their own pace, so a + * conversation contains devices on both sides of that line for as long as the + * slowest one takes. + * + * *Which* protocol a pair of devices should use is already answered by + * `selectProtocol` over `devices.capabilities` (lib/capabilities.ts, #180 + * follow-on). This module adds the two things negotiation alone does not give + * the migration: + * + * 1. **Enforcement on the way in.** A sender can claim any protocol it likes + * on an envelope. Two claims must be rejected: one the recipient cannot + * decrypt, and one weaker than what both sides can actually do. + * 2. **A record of what was used.** `message_envelopes.protocol` stores the + * construction each envelope was built with, so history written before a + * pair cut over keeps decrypting on the Phase-1 path forever. + * + * Negotiation is deliberately *per device pair*, not per conversation. The + * envelope model already encrypts once per recipient device, so there is no + * reason to hold a Signal-capable device back because some other member of the + * conversation is still on an old client — that device gets Signal today and + * the laggard keeps sealed box until it upgrades. + */ + +import { eq, inArray } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { devices } from '../db/schema.js'; +import { + BASELINE_PROTOCOL, + normalizeCapabilities, + selectProtocol, + type KnownProtocol, +} from '../lib/capabilities.js'; + +export type E2eeProtocol = KnownProtocol; + +export interface EnvelopeProtocolInput { + recipientDeviceId: string; + protocol: E2eeProtocol; +} + +export interface ProtocolViolation { + recipientDeviceId: string; + /** What the envelope claimed. */ + declared: E2eeProtocol; + /** What the two devices should have used. */ + expected: E2eeProtocol; + reason: 'unsupported_by_recipient' | 'downgrade'; +} + +export type EnvelopeProtocolCheck = + | { ok: true } + | { ok: false; code: 400 | 409; error: string; violations: ProtocolViolation[] }; + +/** + * Validates the protocol each outgoing envelope claims against what the sender + * device and each recipient device actually advertise. + * + * Two failures are possible, and they are different problems: + * + * - **`unsupported_by_recipient` (`400`).** The envelope names a protocol the + * recipient does not advertise. It would be undecryptable on arrival, which + * the recipient cannot distinguish from tampering — so it is refused at the + * door rather than delivered as a message that mysteriously fails to open. + * - **`downgrade` (`409`).** Both devices can do better than what the envelope + * claims. Without this check a patched or compromised client could quietly + * keep a peer on the weaker construction forever and nobody would notice. + * + * Envelopes naming a device that does not resolve are skipped; the send paths + * already drop those before persisting. + */ +export async function checkEnvelopeProtocols( + senderDeviceId: string | undefined, + envelopes: EnvelopeProtocolInput[], +): Promise { + if (envelopes.length === 0) return { ok: true }; + + const senderDevice = senderDeviceId + ? await db.query.devices.findFirst({ + where: eq(devices.id, senderDeviceId), + columns: { capabilities: true }, + }) + : undefined; + + const recipientIds = [...new Set(envelopes.map((e) => e.recipientDeviceId))]; + const recipientRows = await db.query.devices.findMany({ + where: inArray(devices.id, recipientIds), + columns: { id: true, capabilities: true }, + }); + const capabilitiesByDevice = new Map(recipientRows.map((d) => [d.id, d.capabilities])); + + const violations: ProtocolViolation[] = []; + + for (const envelope of envelopes) { + if (!capabilitiesByDevice.has(envelope.recipientDeviceId)) continue; + + const recipientCapabilities = capabilitiesByDevice.get(envelope.recipientDeviceId); + const recipientProtocols = new Set(normalizeCapabilities(recipientCapabilities).protocols); + const expected = selectProtocol(senderDevice?.capabilities, recipientCapabilities).protocol; + + if (!recipientProtocols.has(envelope.protocol)) { + violations.push({ + recipientDeviceId: envelope.recipientDeviceId, + declared: envelope.protocol, + expected, + reason: 'unsupported_by_recipient', + }); + continue; + } + + if (envelope.protocol !== expected) { + violations.push({ + recipientDeviceId: envelope.recipientDeviceId, + declared: envelope.protocol, + expected, + reason: 'downgrade', + }); + } + } + + if (violations.length === 0) return { ok: true }; + + // An undecryptable envelope is the more specific failure, so it decides the + // status code when a batch contains both kinds. + const hasUnsupported = violations.some((v) => v.reason === 'unsupported_by_recipient'); + + if (hasUnsupported) { + return { + ok: false, + code: 400, + error: 'Envelope protocol is not supported by the recipient device', + violations, + }; + } + + return { + ok: false, + code: 409, + error: 'Envelope protocol is weaker than both devices support', + violations, + }; +} + +/** + * The protocol a sender device should use with each recipient device. Exposed + * so callers can answer "what should I use here" with the same rule the send + * path enforces, rather than re-deriving it. + * + * Devices that do not resolve fall back to the universal baseline. + */ +export async function protocolsForRecipients( + senderDeviceId: string | undefined, + recipientDeviceIds: string[], +): Promise> { + const result = new Map(); + if (recipientDeviceIds.length === 0) return result; + + const senderDevice = senderDeviceId + ? await db.query.devices.findFirst({ + where: eq(devices.id, senderDeviceId), + columns: { capabilities: true }, + }) + : undefined; + + const rows = await db.query.devices.findMany({ + where: inArray(devices.id, [...new Set(recipientDeviceIds)]), + columns: { id: true, capabilities: true }, + }); + + for (const row of rows) { + result.set(row.id, selectProtocol(senderDevice?.capabilities, row.capabilities).protocol); + } + + for (const id of recipientDeviceIds) { + if (!result.has(id)) result.set(id, BASELINE_PROTOCOL); + } + + return result; +} diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index bb63e69..78e295c 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -1,5 +1,5 @@ import type { Server } from 'socket.io'; -import { and, eq, lt, desc, sql, inArray, isNull, ne, or, lte } from 'drizzle-orm'; +import { and, eq, lt, desc, sql, inArray, isNull, or, lte } from 'drizzle-orm'; import { db } from '../db/index.js'; import { @@ -26,6 +26,8 @@ import { deliverMessage } from '../services/deliveryPipeline.js'; import { publishEphemeral, readMissedEvents } from '../services/resumeStream.js'; import { handleDeviceDeliveryReceipt } from '../services/deliveryAggregation.js'; import { conversationRoom } from '../services/roomManager.js'; +import { checkEnvelopeProtocols, type E2eeProtocol } from '../services/e2eeProtocol.js'; +import { BASELINE_PROTOCOL } from '../lib/capabilities.js'; import { applyMlsVisibility } from '../lib/mlsVisibility.js'; import { getConversationEpochWindow } from '../services/mlsGroups.js'; import { handleHeartbeat } from '../services/heartbeat.js'; @@ -36,24 +38,6 @@ import { checkFirstContactLimit } from '../services/rateLimit.js'; const PAGE_SIZE = 30; -/** - * Returns the UUIDs of all active (non-revoked) devices that belong to - * `userId` but are NOT the sending device (`senderDeviceId`). These are the - * "sibling" devices that must each receive their own envelope so they can - * decrypt the message locally. Issue #188. - */ -async function fetchSiblingDeviceIds(userId: string, senderDeviceId: string): Promise { - const siblings = await db.query.devices.findMany({ - where: and( - eq(devices.userId, userId), - ne(devices.id, senderDeviceId), - isNull(devices.revokedAt), - ), - columns: { id: true }, - }); - return siblings.map((d) => d.id); -} - async function findUsersBlockingConversationAccess( type: 'dm' | 'group', memberIds: string[], @@ -154,7 +138,11 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void content?: string; contentType?: string; ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + envelopes?: Array<{ + recipientDeviceId: string; + ciphertext: string; + protocol?: E2eeProtocol; + }>; fileId?: string; mlsEpoch?: number; }; @@ -233,22 +221,10 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } // Enforce full sibling-device coverage (#188). MLS group messages are - // exempt: a single group ciphertext already reaches every member device in - // the tree, so there are no per-device envelopes that could be missing. - const siblingIds = mlsEpoch === undefined ? await fetchSiblingDeviceIds(userId, deviceId) : []; - if (siblingIds.length > 0) { - const providedIds = new Set(envelopes?.map((e) => e.recipientDeviceId) ?? []); - const missing = siblingIds.filter((id) => !providedIds.has(id)); - if (missing.length > 0) { - socket.emit('error', { - event: 'device_set_mismatch', - message: `Missing envelopes for ${missing.length} sibling device(s)`, - missingDeviceIds: missing, - }); - return; - } - // Enforce full sibling-device coverage (#188). - const missingSiblings = await findMissingSiblingDeviceIds(userId, deviceId, envelopes); + // exempt (#372): a single group ciphertext already reaches every member + // device in the tree, so there are no per-device envelopes to be missing. + const missingSiblings = + mlsEpoch === undefined ? await findMissingSiblingDeviceIds(userId, deviceId, envelopes) : []; if (missingSiblings.length > 0) { socket.emit('error', { event: 'device_set_mismatch', @@ -258,6 +234,27 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // Enforce the negotiated E2EE protocol (#364) — same rule as POST /messages. + if (envelopes && envelopes.length > 0) { + const protocolCheck = await checkEnvelopeProtocols( + deviceId, + envelopes.map((e) => ({ + recipientDeviceId: e.recipientDeviceId, + protocol: e.protocol ?? BASELINE_PROTOCOL, + })), + ); + + if (!protocolCheck.ok) { + socket.emit('error', { + event: 'protocol_mismatch', + code: protocolCheck.code, + message: protocolCheck.error, + violations: protocolCheck.violations, + }); + return; + } + } + let fileId: string | null = inputFileId || null; if (FILE_CONTENT_TYPES.has(resolvedContentType) && !fileId) { const [fileRow] = await db @@ -466,135 +463,136 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void content: string; ciphertext?: string; contentType: 'file' | 'image' | 'video' | 'audio'; - messageId?: string; - }; - - if (!messageId) { - socket.emit('error', { - event: 'send_file_message', - message: 'messageId is required', - }); - return; - } + }) => { + const { conversationId, messageId, fileId, content, contentType } = payload; - if (!content?.trim()) { - socket.emit('error', { - event: 'send_file_message', - message: 'Content (envelope ciphertext) must not be empty', - }); - return; - } + if (!messageId) { + socket.emit('error', { + event: 'send_file_message', + message: 'messageId is required', + }); + return; + } - const validContentTypes = ['file', 'image', 'video', 'audio'] as const; - if (!validContentTypes.includes(contentType)) { - socket.emit('error', { - event: 'send_file_message', - message: 'contentType must be one of: file, image, video, audio', - }); - return; - } + if (!content?.trim()) { + socket.emit('error', { + event: 'send_file_message', + message: 'Content (envelope ciphertext) must not be empty', + }); + return; + } - const membership = await db.query.conversationMembers.findFirst({ - where: and( - eq(conversationMembers.conversationId, conversationId), - eq(conversationMembers.userId, userId), - ), - }); + const validContentTypes = ['file', 'image', 'video', 'audio'] as const; + if (!validContentTypes.includes(contentType)) { + socket.emit('error', { + event: 'send_file_message', + message: 'contentType must be one of: file, image, video, audio', + }); + return; + } - if (!membership) { - socket.emit('error', { - event: 'send_file_message', - message: 'Not a member of this conversation', + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), }); - return; - } - - const existing = await db.query.messages.findFirst({ - where: eq(messages.id, messageId), - columns: { createdAt: true }, - }); - if (existing) { - socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); - return; - } + if (!membership) { + socket.emit('error', { + event: 'send_file_message', + message: 'Not a member of this conversation', + }); + return; + } - const file = await db.query.files.findFirst({ - where: eq(files.id, fileId), - }); + const existing = await db.query.messages.findFirst({ + where: eq(messages.id, messageId), + columns: { createdAt: true }, + }); - if (!file) { - socket.emit('error', { event: 'send_file_message', message: 'File not found' }); - return; - } + if (existing) { + socket.emit('message_ack', { messageId, createdAt: existing.createdAt }); + return; + } - if (file.status !== 'ready') { - socket.emit('error', { - event: 'send_file_message', - message: 'File is not ready for use', + const file = await db.query.files.findFirst({ + where: eq(files.id, fileId), }); - return; - } - if (file.conversationId !== conversationId) { - socket.emit('error', { - event: 'send_file_message', - message: 'File does not belong to this conversation', - }); - return; - } + if (!file) { + socket.emit('error', { event: 'send_file_message', message: 'File not found' }); + return; + } - if (file.uploaderId !== userId) { - socket.emit('error', { - event: 'send_file_message', - message: 'Access denied: you are not the uploader of this file', - }); - return; - } + if (file.status !== 'ready') { + socket.emit('error', { + event: 'send_file_message', + message: 'File is not ready for use', + }); + return; + } - let message; - try { - message = await db.transaction(async (tx) => { - const [insertedMessage] = await tx - .insert(messages) - .values({ - id: messageId, - conversationId, - senderId: userId, - ciphertext: content.trim(), - contentType, - fileId, - }) - .returning(); + if (file.conversationId !== conversationId) { + socket.emit('error', { + event: 'send_file_message', + message: 'File does not belong to this conversation', + }); + return; + } - return insertedMessage; - }); - } catch (error) { - console.error('Transaction failed for file message:', error); - socket.emit('error', { - event: 'send_file_message', - message: 'Failed to persist file message', - }); - return; - } + if (file.uploaderId !== userId) { + socket.emit('error', { + event: 'send_file_message', + message: 'Access denied: you are not the uploader of this file', + }); + return; + } - if (message) { - socket.emit('message_ack', { messageId, createdAt: message.createdAt }); - io.to(conversationId).emit('new_message', message); + let message; + try { + message = await db.transaction(async (tx) => { + const [insertedMessage] = await tx + .insert(messages) + .values({ + id: messageId, + conversationId, + senderId: userId, + ciphertext: content.trim(), + contentType, + fileId, + }) + .returning(); + + return insertedMessage; + }); + } catch (error) { + console.error('Transaction failed for file message:', error); + socket.emit('error', { + event: 'send_file_message', + message: 'Failed to persist file message', + }); + return; + } - const members = await db.query.conversationMembers.findMany({ - where: eq(conversationMembers.conversationId, conversationId), - columns: { userId: true }, - }); - await invalidateConversationCaches(members.map((member) => member.userId)); + if (message) { + socket.emit('message_ack', { messageId, createdAt: message.createdAt }); + io.to(conversationId).emit('new_message', message); - sendPushForMessage({ - conversationId, - messageId: message.id, - senderId: userId, - }); - } - }); + const members = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + await invalidateConversationCaches(members.map((member) => member.userId)); + + sendPushForMessage({ + conversationId, + messageId: message.id, + senderId: userId, + }); + } + }, + ); // ── message_history ──────────────────────────────────────────────────────── dispatcher.register('message_history', async (payload) => { @@ -924,7 +922,9 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void memberIds: string[]; }; - const requestedMembers = Array.from(new Set(memberIds.filter((memberId) => memberId !== userId))); + const requestedMembers = Array.from( + new Set(memberIds.filter((memberId) => memberId !== userId)), + ); const blockedMemberIds = await findUsersBlockingConversationAccess(type, requestedMembers); if (blockedMemberIds.length > 0) { diff --git a/docs/signal-integration.md b/docs/signal-integration.md index de9601a..80b529f 100644 --- a/docs/signal-integration.md +++ b/docs/signal-integration.md @@ -79,6 +79,21 @@ Activation requires filling in the stub and changing `defaultSession` in `sessio --- +## Migration path + +Activating Phase-2 is not a flag flip for the whole product: clients update at +their own pace, so a conversation contains Phase-1 and Signal devices at the +same time for as long as the slowest device takes. + +The cutover — how a device advertises Signal support via `devices.capabilities`, +the per-envelope `protocol` column that keeps Phase-1 history decryptable, the +downgrade guard on the send path, and the rollout order — is specified in +[apps/backend/docs/signal-migration.md](../apps/backend/docs/signal-migration.md). + +The short version: each device *pair* switches to Signal once both sides +advertise it, old envelopes are never re-encrypted, and the client keeps its +Phase-1 decryption path forever. + ## Activation checklist ```bash