From 021b488a41e8949dbc27b8bc719bfedc7b4c1786 Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Thu, 30 Jul 2026 08:52:05 +0100 Subject: [PATCH 1/2] @ feat(backend): Phase-1 to Signal migration path Defines how existing sealed-box conversations move to Signal without losing history. Clients update at their own pace, so a conversation holds devices on both sides of the line until the slowest one catches up. Three rules, enforced server side: 1. History is never re-encrypted. message_envelopes.protocol is added NOT NULL DEFAULT sealed_box, which labels every pre-existing envelope with the construction that actually encrypted it in the same statement. A client always knows which decryption path to use instead of inferring it from the ciphertext or from a timestamp. 2. A conversation cuts over only when every active device on every side advertises Signal support. One un-upgraded device holds the whole conversation on sealed box, because the sender has to produce something that device can open. 3. After cutover a sealed-box envelope is refused, so a patched client cannot quietly keep everyone on the weaker construction. - devices.supports_signal is the capability flag, advertised via PATCH /devices/:id/capabilities or at registration. It is monotonic: a device may turn it on but not off, since withdrawal would be a downgrade lever indistinguishable from a genuine rollback. A device that lost its Signal state re-registers under a new identity key - GET /conversations/:id/e2ee-protocol reports the negotiated protocol and which devices are blocking the cutover, so the UI can name them - GET /conversations/:id/devices carries the negotiated protocol alongside the device set the client is about to encrypt for - both send paths reject a Signal envelope aimed at a device that cannot read it (400) and a sealed-box downgrade after cutover (409), each carrying the negotiated protocol and the offending device ids - sync, delivery and history read paths report protocol per envelope, so catching up across the cutover returns a mix and loses nothing Adds docs/signal-migration.md with the data model, endpoints, cutover timeline and rollout order, linked from docs/signal-integration.md. --- apps/backend/docs/signal-migration.md | 215 +++ .../backend/drizzle/0001_signal_migration.sql | 14 + apps/backend/drizzle/meta/0001_snapshot.json | 1524 +++++++++++++++++ apps/backend/drizzle/meta/_journal.json | 7 + .../src/__tests__/e2eeProtocol.test.ts | 189 ++ .../__tests__/signalMigration.routes.test.ts | 412 +++++ apps/backend/src/db/schema.ts | 22 + apps/backend/src/routes/auth.ts | 6 + apps/backend/src/routes/conversations.ts | 41 + apps/backend/src/routes/devices.ts | 71 + apps/backend/src/routes/messages.ts | 31 +- apps/backend/src/routes/sync.ts | 5 + apps/backend/src/schemas/auth.schemas.ts | 5 + apps/backend/src/schemas/message.schemas.ts | 6 + apps/backend/src/services/deliveryPipeline.ts | 3 + apps/backend/src/services/e2eeProtocol.ts | 173 ++ apps/backend/src/socket/messaging.ts | 31 +- docs/signal-integration.md | 15 + 18 files changed, 2768 insertions(+), 2 deletions(-) create mode 100644 apps/backend/docs/signal-migration.md create mode 100644 apps/backend/drizzle/0001_signal_migration.sql create mode 100644 apps/backend/drizzle/meta/0001_snapshot.json create mode 100644 apps/backend/src/__tests__/e2eeProtocol.test.ts create mode 100644 apps/backend/src/__tests__/signalMigration.routes.test.ts create mode 100644 apps/backend/src/services/e2eeProtocol.ts diff --git a/apps/backend/docs/signal-migration.md b/apps/backend/docs/signal-migration.md new file mode 100644 index 00000000..47f12851 --- /dev/null +++ b/apps/backend/docs/signal-migration.md @@ -0,0 +1,215 @@ +# 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 for as long as the slowest device takes, +a conversation contains devices on both sides of the line. This document defines +how that transition happens without losing any history. + +## The three rules + +1. **History is never re-encrypted.** Envelopes written before the cutover keep + `protocol = 'sealed_box'` and stay decryptable by the Phase-1 path forever. + The cutover changes what is written next, never what was written before. +2. **A conversation cuts over only when every active device on every side + advertises Signal support.** A single un-upgraded device holds the whole + conversation on sealed box, because the sender has to produce something that + device can actually open. +3. **After cutover, sealed box is refused.** Without this, a patched or + compromised client could keep a conversation on the weaker construction + indefinitely and nobody would notice. + +## Data model + +Two additions (migration `drizzle/0001_signal_migration.sql`): + +### `devices.supports_signal` — the capability flag + +`boolean NOT NULL DEFAULT false`. Every already-registered device starts at +`false`, so nothing cuts over until each device explicitly says it can. + +**The flag is monotonic: a device may turn it on but not off.** Accepting a +withdrawal would hand any client a lever to pull the whole conversation back +onto the Phase-1 construction, and the other side has no way to tell that from a +genuine rollback. A device that really has lost its Signal state re-registers +under a new identity key, which produces a new row starting at `false`. + +### `message_envelopes.protocol` — what actually encrypted this envelope + +`e2ee_protocol NOT NULL DEFAULT 'sealed_box'`, an enum of `sealed_box | signal`. + +Adding the column 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 always +knows which decryption path to use rather than inferring it from the ciphertext +or from when the message was sent. + +The column is per envelope, not per conversation, because a conversation +legitimately contains both during the transition. + +## Advertising capability + +A client that has shipped Signal support tells the server once, after upgrading: + +```http +PATCH /devices/:id/capabilities +{ "supportsSignal": true } +``` + +```json +{ "id": "uuid", "supportsSignal": true, "changed": true } +``` + +- Owner-only; revoked devices are refused. +- Idempotent — re-sending `true` returns `changed: false`. +- `false` after `true` returns `409` (see monotonicity above). + +Capability can also be declared at registration, on `POST /devices` and inside +the `device` object of `POST /auth/verify`, via the same `supportsSignal` field. +Both paths only ever raise the flag; neither clears it. + +`GET /devices` reports `supportsSignal` for each of the caller's devices. + +## Negotiation + +```http +GET /conversations/:id/e2ee-protocol +``` + +```json +{ + "conversationId": "uuid", + "protocol": "sealed_box", + "totalActiveDevices": 3, + "signalCapableDevices": 2, + "blockingDevices": [ + { "deviceId": "uuid", "userId": "uuid", "deviceName": "old phone", "platform": "ios" } + ] +} +``` + +`protocol` is the answer to "what must new messages here use". It is `signal` +only when `blockingDevices` is empty _and_ there is at least one active device — +an empty device set stays on `sealed_box`, because reporting `signal` there +would flip the conversation to a mode no device can read the moment one joins. + +`blockingDevices` exists so the UI can say _which_ device is holding things up +rather than showing an unexplained "still on legacy encryption" badge. + +`GET /conversations/:id/devices` — the call clients already make immediately +before encrypting — now returns `protocol` alongside the device list, and each +device carries its `supportsSignal` flag. Reading capability and protocol from +two separate requests would leave a window where the client encrypts for a +device set that has since changed. + +## Sending + +Each envelope declares its protocol: + +```json +{ + "conversationId": "uuid", + "messageId": "uuid", + "contentType": "text", + "ciphertext": "base64", + "envelopes": [{ "recipientDeviceId": "uuid", "ciphertext": "base64", "protocol": "signal" }] +} +``` + +`protocol` is optional and defaults to `sealed_box`, so clients written before +this change keep working untouched. + +The server rejects two things, and they are different problems: + +| Status | When | Meaning | +| ------ | ------------------------------------------------------------- | -------------------------------------------------------------- | +| `400` | A `signal` envelope addressed to a device that cannot read it | The sender got ahead of the recipient. | +| `409` | A `sealed_box` envelope after the conversation has cut over | Downgrade. The sender should re-encrypt with Signal and retry. | + +Both responses carry `negotiatedProtocol` and `offendingDeviceIds`, so the +client re-fetches the device set and rebuilds rather than retrying blind: + +```json +{ + "error": "This conversation has cut over to Signal; sealed-box envelopes are no longer accepted", + "negotiatedProtocol": "signal", + "offendingDeviceIds": ["uuid"] +} +``` + +The WebSocket `send_message` path enforces the identical rule and emits an +`error` event with `event: "protocol_mismatch"` carrying the same fields. + +## 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 has to be migrated, re-encrypted, or re-downloaded. + +## Cutover timeline + +```text + all devices Phase-1 mixed all devices Signal + ──────────────────── ────────────────────── ──────────────────────── +protocol sealed_box sealed_box signal + +new msgs sealed box sealed box Signal + only only (the lagging only (sealed box + device would not is refused) + be able to read + 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 + ▲ + │ + one device upgrading does not + change anything on its own +``` + +The conversation flips the moment the last device advertises support. Nothing +is rewritten at that instant — only the next message differs. + +## Rollout checklist + +1. Apply `drizzle/0001_signal_migration.sql`. Existing envelopes are labelled + `sealed_box`; existing devices are `supports_signal = false`. 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. This is safe to deploy + widely because it changes nothing about what is written. +3. Ship the client that can _encrypt_ with Signal, and have it + `PATCH /devices/:id/capabilities` on first run. +4. Conversations cut over on their own as their last device upgrades. Use + `blockingDevices` to prompt the stragglers. +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 but is +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` (`devices.supportsSignal`, `messageEnvelopes.protocol`) +- migration: `apps/backend/drizzle/0001_signal_migration.sql` +- negotiation + enforcement: `apps/backend/src/services/e2eeProtocol.ts` +- capability endpoint: `apps/backend/src/routes/devices.ts` +- negotiation endpoint: `apps/backend/src/routes/conversations.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/0001_signal_migration.sql b/apps/backend/drizzle/0001_signal_migration.sql new file mode 100644 index 00000000..35e5f90e --- /dev/null +++ b/apps/backend/drizzle/0001_signal_migration.sql @@ -0,0 +1,14 @@ +-- Phase-1 -> Signal migration (#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 the cutover +-- are labelled with the construction that actually encrypted them and keep +-- decrypting on the Phase-1 path. +-- +-- `devices.supports_signal` defaults to false, so every already-registered +-- device starts as Phase-1 only and conversations stay on sealed box until +-- each device explicitly advertises Signal support. +CREATE TYPE "public"."e2ee_protocol" AS ENUM('sealed_box', 'signal');--> statement-breakpoint +ALTER TABLE "devices" ADD COLUMN "supports_signal" boolean DEFAULT false NOT NULL;--> statement-breakpoint +ALTER TABLE "message_envelopes" ADD COLUMN "protocol" "e2ee_protocol" DEFAULT 'sealed_box' NOT NULL; diff --git a/apps/backend/drizzle/meta/0001_snapshot.json b/apps/backend/drizzle/meta/0001_snapshot.json new file mode 100644 index 00000000..5b2b15c9 --- /dev/null +++ b/apps/backend/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1524 @@ +{ + "id": "0abee1a3-a86a-490c-bd9d-d954d2d65768", + "prevId": "d5682005-cccf-4e2e-992d-d66a5d6d3f4c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.conversation_members": { + "name": "conversation_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "last_read_message_id": { + "name": "last_read_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_muted": { + "name": "is_muted", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversation_members_conversation_id_conversations_id_fk": { + "name": "conversation_members_conversation_id_conversations_id_fk", + "tableFrom": "conversation_members", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_user_id_users_id_fk": { + "name": "conversation_members_user_id_users_id_fk", + "tableFrom": "conversation_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversation_members_last_read_message_id_messages_id_fk": { + "name": "conversation_members_last_read_message_id_messages_id_fk", + "tableFrom": "conversation_members", + "tableTo": "messages", + "columnsFrom": [ + "last_read_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "type": { + "name": "type", + "type": "conversation_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'dm'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.device_prekeys": { + "name": "device_prekeys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "key_type": { + "name": "key_type", + "type": "prekey_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "key_id": { + "name": "key_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "consumed": { + "name": "consumed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "device_prekeys_device_type_keyid_idx": { + "name": "device_prekeys_device_type_keyid_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_prekeys_signed_device_idx": { + "name": "device_prekeys_signed_device_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"device_prekeys\".\"key_type\" = 'signed'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "device_prekeys_one_time_available_idx": { + "name": "device_prekeys_one_time_available_idx", + "columns": [ + { + "expression": "device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"device_prekeys\".\"key_type\" = 'one_time' AND \"device_prekeys\".\"consumed\" = false", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "device_prekeys_device_id_devices_id_fk": { + "name": "device_prekeys_device_id_devices_id_fk", + "tableFrom": "device_prekeys", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "device_prekeys_signed_requires_signature": { + "name": "device_prekeys_signed_requires_signature", + "value": "\"device_prekeys\".\"key_type\" <> 'signed' OR \"device_prekeys\".\"signature\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "identity_public_key": { + "name": "identity_public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "registration_id": { + "name": "registration_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "device_name": { + "name": "device_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "platform": { + "name": "platform", + "type": "device_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "supports_signal": { + "name": "supports_signal", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "push_enabled": { + "name": "push_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_identity_idx": { + "name": "devices_user_identity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identity_public_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "devices_user_id_active_idx": { + "name": "devices_user_id_active_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"devices\".\"revoked_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.files": { + "name": "files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "uploader_id": { + "name": "uploader_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "file_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_thumbnail": { + "name": "is_thumbnail", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "hard_deleted_at": { + "name": "hard_deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "files_uploader_id_users_id_fk": { + "name": "files_uploader_id_users_id_fk", + "tableFrom": "files", + "tableTo": "users", + "columnsFrom": [ + "uploader_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "files_conversation_id_conversations_id_fk": { + "name": "files_conversation_id_conversations_id_fk", + "tableFrom": "files", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "files_storage_key_unique": { + "name": "files_storage_key_unique", + "nullsNotDistinct": false, + "columns": [ + "storage_key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.message_envelopes": { + "name": "message_envelopes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "message_id": { + "name": "message_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_device_id": { + "name": "recipient_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_user_id": { + "name": "recipient_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "protocol": { + "name": "protocol", + "type": "e2ee_protocol", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'sealed_box'" + }, + "delivered_at": { + "name": "delivered_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "me_recipient_device_created_idx": { + "name": "me_recipient_device_created_idx", + "columns": [ + { + "expression": "recipient_device_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "me_message_idx": { + "name": "me_message_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "message_envelopes_message_id_messages_id_fk": { + "name": "message_envelopes_message_id_messages_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "messages", + "columnsFrom": [ + "message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_device_id_devices_id_fk": { + "name": "message_envelopes_recipient_device_id_devices_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "devices", + "columnsFrom": [ + "recipient_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "message_envelopes_recipient_user_id_users_id_fk": { + "name": "message_envelopes_recipient_user_id_users_id_fk", + "tableFrom": "message_envelopes", + "tableTo": "users", + "columnsFrom": [ + "recipient_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_device_id": { + "name": "sender_device_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "ciphertext": { + "name": "ciphertext", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_id": { + "name": "file_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "edits_message_id": { + "name": "edits_message_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "messages_conversation_created_idx": { + "name": "messages_conversation_created_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_id_users_id_fk": { + "name": "messages_sender_id_users_id_fk", + "tableFrom": "messages", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "messages_sender_device_id_devices_id_fk": { + "name": "messages_sender_device_id_devices_id_fk", + "tableFrom": "messages", + "tableTo": "devices", + "columnsFrom": [ + "sender_device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_file_id_files_id_fk": { + "name": "messages_file_id_files_id_fk", + "tableFrom": "messages", + "tableTo": "files", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "messages_edits_message_id_messages_id_fk": { + "name": "messages_edits_message_id_messages_id_fk", + "tableFrom": "messages", + "tableTo": "messages", + "columnsFrom": [ + "edits_message_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.proposal_votes": { + "name": "proposal_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "treasury_proposal_id": { + "name": "treasury_proposal_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "vote": { + "name": "vote", + "type": "proposal_vote_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "proposal_votes_proposal_user_unique": { + "name": "proposal_votes_proposal_user_unique", + "columns": [ + { + "expression": "treasury_proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk": { + "name": "proposal_votes_treasury_proposal_id_treasury_proposals_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "treasury_proposals", + "columnsFrom": [ + "treasury_proposal_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "proposal_votes_user_id_users_id_fk": { + "name": "proposal_votes_user_id_users_id_fk", + "tableFrom": "proposal_votes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "device_id": { + "name": "device_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "disabled_at": { + "name": "disabled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_device_id_devices_id_fk": { + "name": "push_subscriptions_device_id_devices_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "devices", + "columnsFrom": [ + "device_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_transfers": { + "name": "token_transfers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "sender_id": { + "name": "sender_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "recipient_address": { + "name": "recipient_address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_contract_id": { + "name": "token_contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tx_hash": { + "name": "tx_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "memo": { + "name": "memo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "token_transfers_conversation_id_conversations_id_fk": { + "name": "token_transfers_conversation_id_conversations_id_fk", + "tableFrom": "token_transfers", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "token_transfers_sender_id_users_id_fk": { + "name": "token_transfers_sender_id_users_id_fk", + "tableFrom": "token_transfers", + "tableTo": "users", + "columnsFrom": [ + "sender_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_transfers_tx_hash_unique": { + "name": "token_transfers_tx_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "tx_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.treasury_proposals": { + "name": "treasury_proposals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "contract_id": { + "name": "contract_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposal_id": { + "name": "proposal_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "treasury_proposal_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "approvals_count": { + "name": "approvals_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rejections_count": { + "name": "rejections_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "threshold": { + "name": "threshold", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "treasury_proposals_contract_proposal_idx": { + "name": "treasury_proposals_contract_proposal_idx", + "columns": [ + { + "expression": "contract_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "proposal_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "treasury_proposals_conversation_id_conversations_id_fk": { + "name": "treasury_proposals_conversation_id_conversations_id_fk", + "tableFrom": "treasury_proposals", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "presence_visible": { + "name": "presence_visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "send_read_receipts": { + "name": "send_read_receipts", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.wallets": { + "name": "wallets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_primary": { + "name": "is_primary", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "wallets_user_id_users_id_fk": { + "name": "wallets_user_id_users_id_fk", + "tableFrom": "wallets", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "wallets_address_unique": { + "name": "wallets_address_unique", + "nullsNotDistinct": false, + "columns": [ + "address" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.content_type": { + "name": "content_type", + "schema": "public", + "values": [ + "text", + "file", + "image", + "video", + "audio", + "system" + ] + }, + "public.conversation_type": { + "name": "conversation_type", + "schema": "public", + "values": [ + "dm", + "group" + ] + }, + "public.device_platform": { + "name": "device_platform", + "schema": "public", + "values": [ + "web", + "ios", + "android" + ] + }, + "public.e2ee_protocol": { + "name": "e2ee_protocol", + "schema": "public", + "values": [ + "sealed_box", + "signal" + ] + }, + "public.file_status": { + "name": "file_status", + "schema": "public", + "values": [ + "pending", + "ready", + "deleted" + ] + }, + "public.prekey_type": { + "name": "prekey_type", + "schema": "public", + "values": [ + "signed", + "one_time" + ] + }, + "public.proposal_vote_type": { + "name": "proposal_vote_type", + "schema": "public", + "values": [ + "approve", + "reject" + ] + }, + "public.treasury_proposal_status": { + "name": "treasury_proposal_status", + "schema": "public", + "values": [ + "active", + "approved", + "rejected", + "executed", + "expired" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/backend/drizzle/meta/_journal.json b/apps/backend/drizzle/meta/_journal.json index 52ed62d6..0f6301a2 100644 --- a/apps/backend/drizzle/meta/_journal.json +++ b/apps/backend/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1784899426825, "tag": "0000_stale_mandarin", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1785397530059, + "tag": "0001_signal_migration", + "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 00000000..af438ab2 --- /dev/null +++ b/apps/backend/src/__tests__/e2eeProtocol.test.ts @@ -0,0 +1,189 @@ +/** + * Tests for Phase-1 → Signal protocol negotiation (#364). + * + * The two properties under test: + * - a conversation cuts over to Signal only when *every* active device on + * every side advertises support + * - once it has cut over, a sealed-box fallback is refused + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockMemberFindMany = vi.fn(); +const mockDeviceFindMany = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + conversationMembers: { findMany: mockMemberFindMany }, + devices: { findMany: mockDeviceFindMany }, + }, + }, +})); + +vi.mock('../db/schema.js', () => ({ + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + devices: { userId: 'userId', revokedAt: 'revokedAt' }, +})); + +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((col: unknown, vals: unknown) => ({ op: 'inArray', col, vals })), + isNull: vi.fn((col: unknown) => ({ op: 'isNull', col })), +})); + +const { negotiateConversationProtocol, checkEnvelopeProtocols } = + await import('../services/e2eeProtocol.js'); + +const CONVERSATION_ID = 'conv-1'; + +function device(id: string, supportsSignal: boolean, userId = 'user-1') { + return { id, userId, deviceName: `dev-${id}`, platform: 'web', supportsSignal }; +} + +beforeEach(() => { + vi.clearAllMocks(); + mockMemberFindMany.mockResolvedValue([{ userId: 'user-1' }, { userId: 'user-2' }]); +}); + +describe('negotiateConversationProtocol', () => { + it('stays on sealed box while any active device is Phase-1 only', async () => { + mockDeviceFindMany.mockResolvedValue([ + device('d1', true), + device('d2', false, 'user-2'), + device('d3', true, 'user-2'), + ]); + + const result = await negotiateConversationProtocol(CONVERSATION_ID); + + expect(result.protocol).toBe('sealed_box'); + expect(result.totalActiveDevices).toBe(3); + expect(result.signalCapableDevices).toBe(2); + expect(result.blockingDevices).toEqual([ + { deviceId: 'd2', userId: 'user-2', deviceName: 'dev-d2', platform: 'web' }, + ]); + }); + + it('cuts over once every active device is capable', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', true, 'user-2')]); + + const result = await negotiateConversationProtocol(CONVERSATION_ID); + + expect(result.protocol).toBe('signal'); + expect(result.blockingDevices).toEqual([]); + }); + + it('does not cut over on an empty device set', async () => { + // Reporting `signal` here would flip the conversation to a mode no device + // can read the moment one joins. + mockDeviceFindMany.mockResolvedValue([]); + + const result = await negotiateConversationProtocol(CONVERSATION_ID); + + expect(result.protocol).toBe('sealed_box'); + expect(result.totalActiveDevices).toBe(0); + }); + + it('reports sealed box for a conversation with no members', async () => { + mockMemberFindMany.mockResolvedValue([]); + + const result = await negotiateConversationProtocol(CONVERSATION_ID); + + expect(result.protocol).toBe('sealed_box'); + expect(mockDeviceFindMany).not.toHaveBeenCalled(); + }); + + it('one lagging device blocks the cutover for everyone', async () => { + mockDeviceFindMany.mockResolvedValue([ + device('d1', true), + device('d2', true), + device('d3', true, 'user-2'), + device('d4', false, 'user-2'), + ]); + + const result = await negotiateConversationProtocol(CONVERSATION_ID); + + expect(result.protocol).toBe('sealed_box'); + expect(result.blockingDevices).toHaveLength(1); + expect(result.blockingDevices[0]!.deviceId).toBe('d4'); + }); +}); + +describe('checkEnvelopeProtocols', () => { + it('accepts sealed-box envelopes before the cutover', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', false, 'user-2')]); + + const result = await checkEnvelopeProtocols(CONVERSATION_ID, [ + { recipientDeviceId: 'd1', protocol: 'sealed_box' }, + { recipientDeviceId: 'd2', protocol: 'sealed_box' }, + ]); + + expect(result).toEqual({ ok: true, negotiated: 'sealed_box' }); + }); + + it('rejects a Signal envelope addressed to a Phase-1 device', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', false, 'user-2')]); + + const result = await checkEnvelopeProtocols(CONVERSATION_ID, [ + { recipientDeviceId: 'd2', protocol: 'signal' }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(400); + expect(result.offendingDeviceIds).toEqual(['d2']); + }); + + it('accepts Signal envelopes after the cutover', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', true, 'user-2')]); + + const result = await checkEnvelopeProtocols(CONVERSATION_ID, [ + { recipientDeviceId: 'd1', protocol: 'signal' }, + { recipientDeviceId: 'd2', protocol: 'signal' }, + ]); + + expect(result).toEqual({ ok: true, negotiated: 'signal' }); + }); + + it('refuses a sealed-box downgrade once the conversation has cut over', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', true, 'user-2')]); + + const result = await checkEnvelopeProtocols(CONVERSATION_ID, [ + { recipientDeviceId: 'd1', protocol: 'signal' }, + { recipientDeviceId: 'd2', protocol: 'sealed_box' }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(409); + expect(result.negotiated).toBe('signal'); + expect(result.offendingDeviceIds).toEqual(['d2']); + }); + + it('reports the incapable-recipient error ahead of the downgrade error', async () => { + // A mixed batch is a client that has not re-fetched the device set; the + // undecryptable envelope is the more specific problem to report. + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', false, 'user-2')]); + + const result = await checkEnvelopeProtocols(CONVERSATION_ID, [ + { recipientDeviceId: 'd1', protocol: 'sealed_box' }, + { recipientDeviceId: 'd2', protocol: 'signal' }, + ]); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.code).toBe(400); + }); + + it('accepts an empty envelope list', async () => { + mockDeviceFindMany.mockResolvedValue([device('d1', true), device('d2', true, 'user-2')]); + + expect(await checkEnvelopeProtocols(CONVERSATION_ID, [])).toEqual({ + ok: true, + negotiated: 'signal', + }); + }); +}); 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 00000000..38e1ab0f --- /dev/null +++ b/apps/backend/src/__tests__/signalMigration.routes.test.ts @@ -0,0 +1,412 @@ +/** + * Tests for the Phase-1 → Signal migration HTTP surface (#364): + * + * PATCH /devices/:id/capabilities advertise Signal support + * GET /conversations/:id/e2ee-protocol what new messages must use + * POST /messages enforcement + per-envelope protocol + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import request from 'supertest'; +import express from 'express'; + +// ── Mocks ───────────────────────────────────────────────────────────────────── + +const mockDeviceFindFirst = vi.fn(); +const mockDeviceFindMany = vi.fn(); +const mockMemberFindFirst = vi.fn(); +const mockMemberFindMany = vi.fn(); +const mockMessageFindFirst = vi.fn(); +const mockUpdate = vi.fn(); +const mockTransaction = vi.fn(); +const mockNegotiate = vi.fn(); +const mockCheckEnvelopeProtocols = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { + devices: { findFirst: mockDeviceFindFirst, findMany: mockDeviceFindMany }, + conversationMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + messages: { findFirst: mockMessageFindFirst }, + conversations: { findFirst: vi.fn(), findMany: vi.fn() }, + devicePrekeys: { findFirst: vi.fn() }, + }, + update: mockUpdate, + insert: vi.fn(), + delete: vi.fn(), + select: vi.fn(), + transaction: mockTransaction, + }, +})); + +vi.mock('../db/schema.js', () => ({ + devices: { id: 'id', userId: 'userId', revokedAt: 'revokedAt' }, + devicePrekeys: {}, + conversationMembers: { conversationId: 'conversationId', userId: 'userId' }, + conversations: { id: 'id' }, + messages: { id: 'id', conversationId: 'conversationId', createdAt: 'createdAt' }, + messageEnvelopes: { recipientDeviceId: 'recipientDeviceId' }, + tokenTransfers: {}, + mlsKeyPackages: {}, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => ({ op: 'and', args })), + asc: vi.fn((col: unknown) => col), + count: vi.fn(), + desc: vi.fn((col: unknown) => col), + eq: vi.fn((col: unknown, val: unknown) => ({ op: 'eq', col, val })), + gt: vi.fn(), + inArray: vi.fn(), + isNull: vi.fn(), + lt: vi.fn(), + ne: vi.fn(), + or: vi.fn((...args: unknown[]) => ({ op: 'or', args })), + sql: vi.fn(), +})); + +vi.mock('../lib/redis.js', () => ({ + redis: null, + CONV_CACHE_TTL: 60, + convCacheKey: (id: string) => `conv:${id}`, +})); +vi.mock('../lib/conversationCache.js', () => ({ invalidateConversationCaches: vi.fn() })); +vi.mock('../lib/socket.js', () => ({ getSocketServer: vi.fn(() => null) })); +vi.mock('../services/roomManager.js', () => ({ + conversationRoom: (id: string) => `room:conversation:${id}`, +})); +vi.mock('../services/deviceRevocation.js', () => ({ markDeviceRevoked: vi.fn() })); +vi.mock('../services/fileCleanup.js', () => ({ softDeleteFile: vi.fn() })); +vi.mock('../services/mlsKeyPackages.js', () => ({ + MLS_KEY_PACKAGE_CAP: 100, + MLS_KEY_PACKAGE_MAX_BATCH: 100, + countAvailableKeyPackages: vi.fn(), + hashKeyPackage: vi.fn(), +})); +vi.mock('../services/e2eeProtocol.js', () => ({ + negotiateConversationProtocol: mockNegotiate, + 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 { devicesRouter } = await import('../routes/devices.js'); +const { conversationsRouter } = await import('../routes/conversations.js'); +const { messagesRouter } = await import('../routes/messages.js'); + +function makeApp() { + const app = express(); + app.use(express.json()); + app.use('/devices', devicesRouter); + app.use('/conversations', conversationsRouter); + app.use('/messages', messagesRouter); + return app; +} + +const PHASE1_DEVICE = { + id: DEVICE_ID, + userId: USER_ID, + revokedAt: null, + supportsSignal: false, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockMemberFindFirst.mockResolvedValue({ id: 'membership-1' }); + mockMemberFindMany.mockResolvedValue([{ userId: USER_ID }]); +}); + +// ── Capability advertisement ────────────────────────────────────────────────── + +describe('PATCH /devices/:id/capabilities', () => { + const url = `/devices/${DEVICE_ID}/capabilities`; + + function setupUpdate() { + const where = vi.fn().mockResolvedValue(undefined); + mockUpdate.mockReturnValue({ set: vi.fn().mockReturnValue({ where }) }); + return where; + } + + it('turns Signal support on', async () => { + mockDeviceFindFirst.mockResolvedValue(PHASE1_DEVICE); + const where = setupUpdate(); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: true }); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ id: DEVICE_ID, supportsSignal: true, changed: true }); + expect(where).toHaveBeenCalled(); + }); + + it('is idempotent when the flag already matches', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...PHASE1_DEVICE, supportsSignal: true }); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: true }); + + expect(res.status).toBe(200); + expect(res.body.changed).toBe(false); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('refuses to withdraw Signal support', async () => { + // Allowing this would let any client walk the conversation back onto the + // weaker Phase-1 construction. + mockDeviceFindFirst.mockResolvedValue({ ...PHASE1_DEVICE, supportsSignal: true }); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: false }); + + expect(res.status).toBe(409); + expect(res.body.error).toMatch(/cannot be withdrawn/i); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('allows an explicit false while the device has never advertised', async () => { + mockDeviceFindFirst.mockResolvedValue(PHASE1_DEVICE); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: false }); + + expect(res.status).toBe(200); + expect(res.body.changed).toBe(false); + }); + + it('returns 403 for a device the caller does not own', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...PHASE1_DEVICE, userId: 'someone-else' }); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: true }); + + expect(res.status).toBe(403); + }); + + it('returns 403 for a revoked device', async () => { + mockDeviceFindFirst.mockResolvedValue({ ...PHASE1_DEVICE, revokedAt: new Date() }); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: true }); + + expect(res.status).toBe(403); + }); + + it('returns 404 for an unknown device', async () => { + mockDeviceFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).patch(url).send({ supportsSignal: true }); + + expect(res.status).toBe(404); + }); + + it('rejects a non-boolean flag', async () => { + const res = await request(makeApp()).patch(url).send({ supportsSignal: 'yes' }); + + expect(res.status).toBe(400); + }); +}); + +// ── Negotiation endpoint ────────────────────────────────────────────────────── + +describe('GET /conversations/:id/e2ee-protocol', () => { + const url = `/conversations/${CONVERSATION_ID}/e2ee-protocol`; + + it('reports the protocol and what is blocking the cutover', async () => { + mockNegotiate.mockResolvedValue({ + protocol: 'sealed_box', + totalActiveDevices: 3, + signalCapableDevices: 2, + blockingDevices: [ + { deviceId: 'd3', userId: 'user-2', deviceName: 'old phone', platform: 'ios' }, + ], + }); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + conversationId: CONVERSATION_ID, + protocol: 'sealed_box', + signalCapableDevices: 2, + }); + expect(res.body.blockingDevices[0].deviceName).toBe('old phone'); + }); + + it('returns 403 for a non-member', async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).get(url); + + expect(res.status).toBe(403); + expect(mockNegotiate).not.toHaveBeenCalled(); + }); +}); + +// ── Device set carries capability + negotiated protocol ─────────────────────── + +describe('GET /conversations/:id/devices', () => { + it('returns the negotiated protocol alongside each device capability', async () => { + mockDeviceFindMany.mockResolvedValue([ + { + id: DEVICE_ID, + userId: USER_ID, + identityPublicKey: 'idk', + deviceName: 'laptop', + platform: 'web', + supportsSignal: true, + }, + ]); + mockNegotiate.mockResolvedValue({ + protocol: 'signal', + totalActiveDevices: 1, + signalCapableDevices: 1, + blockingDevices: [], + }); + + const res = await request(makeApp()).get(`/conversations/${CONVERSATION_ID}/devices`); + + expect(res.status).toBe(200); + expect(res.body.protocol).toBe('signal'); + expect(res.body.devices[0].supportsSignal).toBe(true); + }); +}); + +// ── Send-path enforcement ───────────────────────────────────────────────────── + +describe('POST /messages — protocol enforcement', () => { + const body = { + conversationId: CONVERSATION_ID, + messageId: MESSAGE_ID, + contentType: 'text', + ciphertext: 'body-ciphertext', + envelopes: [{ recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env-ciphertext' }], + }; + + function setupInsertTransaction() { + const envelopeValues = vi.fn().mockResolvedValue(undefined); + const tx = { + insert: vi + .fn() + .mockReturnValueOnce({ + values: vi.fn().mockReturnValue({ + returning: vi + .fn() + .mockResolvedValue([{ id: MESSAGE_ID, conversationId: CONVERSATION_ID }]), + }), + }) + .mockReturnValue({ values: envelopeValues }), + query: { + devices: { + findMany: vi.fn().mockResolvedValue([{ id: RECIPIENT_DEVICE_ID, userId: 'user-2' }]), + }, + }, + }; + mockTransaction.mockImplementation(async (cb: (t: typeof tx) => unknown) => cb(tx)); + return envelopeValues; + } + + beforeEach(() => { + mockMessageFindFirst.mockResolvedValue(undefined); + mockCheckEnvelopeProtocols.mockResolvedValue({ ok: true, negotiated: 'sealed_box' }); + }); + + it('defaults an envelope with no protocol to sealed box', async () => { + const envelopeValues = setupInsertTransaction(); + + const res = await request(makeApp()).post('/messages').send(body); + + expect(res.status).toBe(201); + expect(mockCheckEnvelopeProtocols).toHaveBeenCalledWith(CONVERSATION_ID, [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, protocol: 'sealed_box' }, + ]); + expect(envelopeValues).toHaveBeenCalledWith([ + expect.objectContaining({ protocol: 'sealed_box' }), + ]); + }); + + it('persists the protocol a Signal envelope declares', async () => { + mockCheckEnvelopeProtocols.mockResolvedValue({ ok: true, negotiated: 'signal' }); + const envelopeValues = setupInsertTransaction(); + + const res = await request(makeApp()) + .post('/messages') + .send({ + ...body, + envelopes: [ + { recipientDeviceId: RECIPIENT_DEVICE_ID, ciphertext: 'env', protocol: 'signal' }, + ], + }); + + expect(res.status).toBe(201); + expect(envelopeValues).toHaveBeenCalledWith([expect.objectContaining({ protocol: 'signal' })]); + }); + + it('returns 400 when a Signal envelope targets a Phase-1 device', async () => { + mockCheckEnvelopeProtocols.mockResolvedValue({ + ok: false, + code: 400, + error: 'Signal envelope addressed to a device that does not support Signal', + negotiated: 'sealed_box', + offendingDeviceIds: [RECIPIENT_DEVICE_ID], + }); + + 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.negotiatedProtocol).toBe('sealed_box'); + expect(res.body.offendingDeviceIds).toEqual([RECIPIENT_DEVICE_ID]); + expect(mockTransaction).not.toHaveBeenCalled(); + }); + + it('returns 409 for a sealed-box downgrade after the cutover', async () => { + mockCheckEnvelopeProtocols.mockResolvedValue({ + ok: false, + code: 409, + error: 'This conversation has cut over to Signal', + negotiated: 'signal', + offendingDeviceIds: [RECIPIENT_DEVICE_ID], + }); + + const res = await request(makeApp()).post('/messages').send(body); + + expect(res.status).toBe(409); + expect(res.body.negotiatedProtocol).toBe('signal'); + 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('runs the protocol check only after membership is confirmed', async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + + const res = await request(makeApp()).post('/messages').send(body); + + expect(res.status).toBe(403); + expect(mockCheckEnvelopeProtocols).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 46e079c8..ca995fb1 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -135,6 +135,17 @@ export const messages = pgTable( (table) => [index('messages_conversation_created_idx').on(table.conversationId, table.createdAt)], ); +// 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. +// +// This is recorded per envelope rather than inferred, because the two coexist +// across the migration: envelopes written before a conversation cut over stay +// decryptable by the sealed-box path forever, and the receiving client needs to +// know which path to use without guessing from the ciphertext. +export const e2eeProtocolEnum = pgEnum('e2ee_protocol', ['sealed_box', 'signal']); + export const messageEnvelopes = pgTable( 'message_envelopes', { @@ -149,6 +160,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(), @@ -185,6 +199,14 @@ export const devices = pgTable( registrationId: integer('registration_id'), deviceName: text('device_name'), platform: devicePlatformEnum('platform'), + // Signal Protocol capability flag (#364). False means the device only + // understands the Phase-1 sealed-box path. A conversation cuts over to + // Signal once every active device on every side has advertised support. + // + // Treated as monotonic: a device may turn this on but not off. Un-advertising + // would be a downgrade lever, and a device that genuinely lost its Signal + // state re-registers under a new identity key instead. + supportsSignal: boolean('supports_signal').notNull().default(false), lastSeenAt: timestamp('last_seen_at'), pushEnabled: boolean('push_enabled').notNull().default(true), revokedAt: timestamp('revoked_at'), diff --git a/apps/backend/src/routes/auth.ts b/apps/backend/src/routes/auth.ts index a31e07d1..bd4142ac 100644 --- a/apps/backend/src/routes/auth.ts +++ b/apps/backend/src/routes/auth.ts @@ -61,6 +61,10 @@ authRouter.post( const deviceName = device?.deviceName; const platform = device?.platform; const registrationId = device?.registrationId; + // Signal capability is monotonic (#364): sign-in can raise it but never + // clear it, so a downgraded client cannot walk the conversation back onto + // the Phase-1 sealed box. + const supportsSignal = device?.supportsSignal; // Validate and consume nonce const valid = consumeNonce(walletAddress, nonce); @@ -133,6 +137,7 @@ authRouter.post( ...(deviceName ? { deviceName } : {}), ...(platform ? { platform } : {}), ...(registrationId !== undefined ? { registrationId } : {}), + ...(supportsSignal === true ? { supportsSignal: true } : {}), }) .where(eq(devices.id, deviceId)); } else { @@ -144,6 +149,7 @@ authRouter.post( deviceName: deviceName ?? null, platform: platform ?? null, registrationId: registrationId ?? null, + supportsSignal: supportsSignal ?? false, lastSeenAt: new Date(), }) .returning({ id: devices.id }); diff --git a/apps/backend/src/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 1d758f6c..71f38ce1 100644 --- a/apps/backend/src/routes/conversations.ts +++ b/apps/backend/src/routes/conversations.ts @@ -16,6 +16,7 @@ import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; import { getSocketServer } from '../lib/socket.js'; import { MAX_MESSAGES_LIMIT, DEFAULT_MESSAGES_LIMIT } from '../constants.js'; +import { negotiateConversationProtocol } from '../services/e2eeProtocol.js'; export const conversationsRouter: IRouter = Router(); @@ -798,16 +799,56 @@ conversationsRouter.get('/:id/devices', async (req: AuthRequest, res) => { identityPublicKey: true, deviceName: true, platform: true, + supportsSignal: true, }, }); + // #364 — the client encrypts straight after this call, so the negotiated + // protocol travels with the device set it applies to. Reading capability and + // protocol from two separate requests would leave a window where the client + // encrypts for a device set that has since changed. + const negotiation = await negotiateConversationProtocol(conversationId); + res.json({ + protocol: negotiation.protocol, devices: deviceRows.map((d) => ({ id: d.id, userId: d.userId, identityPublicKey: d.identityPublicKey, deviceName: d.deviceName, platform: d.platform, + supportsSignal: d.supportsSignal, })), }); }); + +// ── GET /conversations/:id/e2ee-protocol ────────────────────────────────────── +// Which E2EE construction new messages in this conversation must use (#364), +// and — when it is still sealed box — exactly which devices are holding the +// cutover back, so the UI can prompt those users to update. +conversationsRouter.get('/:id/e2ee-protocol', async (req: AuthRequest, res) => { + const userId = req.auth!.userId; + const conversationId = req.params['id'] as string | undefined; + + if (!conversationId) { + res.status(400).json({ error: 'Conversation id is required' }); + return; + } + + const membership = await db.query.conversationMembers.findFirst({ + where: and( + eq(conversationMembers.conversationId, conversationId), + eq(conversationMembers.userId, userId), + ), + columns: { id: true }, + }); + + if (!membership) { + res.status(403).json({ error: 'Not a member of this conversation' }); + return; + } + + const negotiation = await negotiateConversationProtocol(conversationId); + + res.json({ conversationId, ...negotiation }); +}); diff --git a/apps/backend/src/routes/devices.ts b/apps/backend/src/routes/devices.ts index 8686b7b2..e0c82f27 100644 --- a/apps/backend/src/routes/devices.ts +++ b/apps/backend/src/routes/devices.ts @@ -37,6 +37,11 @@ const UploadPreKeysSchema = z.object({ const RegisterDeviceSchema = DeviceSchema; +/** Capability advertisement (#364). See PATCH /devices/:id/capabilities. */ +const UpdateCapabilitiesSchema = z.object({ + supportsSignal: z.boolean(), +}); + /** Maximum number of stored one-time prekeys per device. */ const OTP_CAP = 200; @@ -79,6 +84,7 @@ devicesRouter.get('/', async (req: AuthRequest, res) => { identityPublicKey: device.identityPublicKey, deviceName: device.deviceName, platform: device.platform, + supportsSignal: device.supportsSignal, lastSeenAt: device.lastSeenAt, revokedAt: device.revokedAt, oneTimePreKeysRemaining: remainingByDevice.get(device.id) ?? 0, @@ -296,6 +302,68 @@ devicesRouter.post('/:id/prekeys', validate(UploadPreKeysSchema), async (req: Au }); }); +// ─── PATCH /devices/:id/capabilities ──────────────────────────────────────── +// +// Advertises what E2EE this device can do (#364). A client that has shipped +// Signal support calls this once after upgrading; the conversations it takes +// part in cut over as soon as every other active device has done the same. +// +// The flag is monotonic. Accepting `supportsSignal: false` from a device that +// already advertised true would hand any client — including a tampered one — a +// lever to pull the whole conversation back onto the Phase-1 sealed box, and +// the other side would have no way to tell that from a genuine rollback. A +// device that really has lost its Signal state re-registers under a new +// identity key, which produces a new row that starts at false. + +devicesRouter.patch( + '/:id/capabilities', + validate(UpdateCapabilitiesSchema), + async (req: AuthRequest, res) => { + const deviceId = req.params['id'] as string; + const callerId = req.auth!.userId; + const { supportsSignal } = req.body as z.infer; + + const device = await db.query.devices.findFirst({ + where: eq(devices.id, deviceId), + columns: { id: true, userId: true, revokedAt: true, supportsSignal: true }, + }); + + if (!device) { + res.status(404).json({ error: 'Device not found' }); + return; + } + + if (device.userId !== callerId) { + res.status(403).json({ error: 'Only the device owner may update capabilities' }); + return; + } + + if (device.revokedAt) { + res.status(403).json({ error: 'Device is revoked' }); + return; + } + + if (device.supportsSignal && !supportsSignal) { + res.status(409).json({ + error: 'Signal capability cannot be withdrawn; re-register the device instead', + }); + return; + } + + if (device.supportsSignal === supportsSignal) { + res.json({ id: deviceId, supportsSignal, changed: false }); + return; + } + + await db + .update(devices) + .set({ supportsSignal, updatedAt: new Date() }) + .where(eq(devices.id, deviceId)); + + res.json({ id: deviceId, supportsSignal, changed: true }); + }, +); + // ─── POST /devices — register a new device for an existing user -------------- devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, res) => { @@ -323,6 +391,8 @@ devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, deviceName: body.deviceName, platform: body.platform, registrationId: body.registrationId ?? null, + // Monotonic (#364) — re-registering never clears the capability. + ...(body.supportsSignal === true ? { supportsSignal: true } : {}), revokedAt: null, updatedAt: new Date(), }) @@ -337,6 +407,7 @@ devicesRouter.post('/', validate(RegisterDeviceSchema), async (req: AuthRequest, deviceName: body.deviceName, platform: body.platform, registrationId: body.registrationId ?? null, + supportsSignal: body.supportsSignal ?? false, }) .returning({ id: devices.id, createdAt: devices.createdAt }); } diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 9a0b5482..3d85f125 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -10,6 +10,7 @@ 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'; export const messagesRouter: IRouter = Router(); @@ -26,7 +27,11 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r messageId: string; contentType?: string; ciphertext?: string; - envelopes?: Array<{ recipientDeviceId: string; ciphertext: string }>; + envelopes?: Array<{ + recipientDeviceId: string; + ciphertext: string; + protocol?: E2eeProtocol; + }>; fileId?: string; }; @@ -55,6 +60,29 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r return; } + // ── E2EE protocol negotiation (#364) ─────────────────────────────────────── + // Rejects an envelope encrypted with a protocol its recipient cannot read, + // and rejects a sealed-box fallback once the conversation has cut over to + // Signal. + if (envelopes && envelopes.length > 0) { + const protocolCheck = await checkEnvelopeProtocols( + conversationId, + envelopes.map((e) => ({ + recipientDeviceId: e.recipientDeviceId, + protocol: e.protocol ?? 'sealed_box', + })), + ); + + if (!protocolCheck.ok) { + res.status(protocolCheck.code).json({ + error: protocolCheck.error, + negotiatedProtocol: protocolCheck.negotiated, + offendingDeviceIds: protocolCheck.offendingDeviceIds, + }); + return; + } + } + // ── idempotency ──────────────────────────────────────────────────────────── const existing = await db.query.messages.findFirst({ where: eq(messages.id, messageId), @@ -98,6 +126,7 @@ messagesRouter.post('/', validate(SendMessageSchema), async (req: AuthRequest, r recipientDeviceId: env.recipientDeviceId, recipientUserId: deviceToUser.get(env.recipientDeviceId)!, ciphertext: env.ciphertext, + protocol: env.protocol ?? ('sealed_box' as const), })); if (validEnvelopes.length > 0) { diff --git a/apps/backend/src/routes/sync.ts b/apps/backend/src/routes/sync.ts index be32b21e..35eff79a 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/schemas/auth.schemas.ts b/apps/backend/src/schemas/auth.schemas.ts index 8d2c6988..e2eda586 100644 --- a/apps/backend/src/schemas/auth.schemas.ts +++ b/apps/backend/src/schemas/auth.schemas.ts @@ -13,6 +13,11 @@ export const DeviceSchema = z.object({ platform: z.enum(['web', 'ios', 'android']), identityPublicKey: IdentityPublicKeySchema, registrationId: z.number().int().nonnegative().optional(), + /** + * Whether this device can do Signal Protocol messaging (#364). Absent means + * no — an older client that predates the flag is by definition Phase-1 only. + */ + supportsSignal: z.boolean().optional(), }); export const VerifySchema = z diff --git a/apps/backend/src/schemas/message.schemas.ts b/apps/backend/src/schemas/message.schemas.ts index 804819ed..7145978e 100644 --- a/apps/backend/src/schemas/message.schemas.ts +++ b/apps/backend/src/schemas/message.schemas.ts @@ -13,6 +13,12 @@ import { z } from 'zod'; 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; + * a conversation whose devices are all Signal-capable requires `signal`. + */ + protocol: z.enum(['sealed_box', 'signal']).optional().default('sealed_box'), }); export const SendMessageSchema = z.object({ diff --git a/apps/backend/src/services/deliveryPipeline.ts b/apps/backend/src/services/deliveryPipeline.ts index 2a5ae47a..ec273b0b 100644 --- a/apps/backend/src/services/deliveryPipeline.ts +++ b/apps/backend/src/services/deliveryPipeline.ts @@ -59,6 +59,7 @@ export async function deliverMessage( id: messageEnvelopes.id, recipientDeviceId: messageEnvelopes.recipientDeviceId, ciphertext: messageEnvelopes.ciphertext, + protocol: messageEnvelopes.protocol, }) .from(messageEnvelopes) .where( @@ -84,6 +85,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, }); } diff --git a/apps/backend/src/services/e2eeProtocol.ts b/apps/backend/src/services/e2eeProtocol.ts new file mode 100644 index 00000000..71332c9b --- /dev/null +++ b/apps/backend/src/services/e2eeProtocol.ts @@ -0,0 +1,173 @@ +/** + * Phase-1 → Signal migration (#364). + * + * The product shipped on the Phase-1 sealed box (ECDH + HKDF + AES-256-GCM, + * one independent box per message). Signal's Double Ratchet replaces it, but + * not everywhere at once: clients update at their own pace, and a conversation + * contains devices on both sides of that line for as long as the slowest one + * takes. + * + * The rules this module encodes: + * + * 1. **History is never re-encrypted.** Old envelopes keep `protocol = + * 'sealed_box'` and stay decryptable by the Phase-1 path forever. The + * cutover changes what is written next, never what was written before. + * 2. **A conversation cuts over only when every active device on every side + * advertises Signal support.** One un-upgraded device holds the whole + * conversation on sealed box, because the sender has to produce an + * envelope that device can actually open. + * 3. **After cutover, sealed box is refused.** Otherwise a patched or + * compromised client could keep everyone on the weaker construction + * indefinitely, and no one would notice. + */ + +import { and, eq, inArray, isNull } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { conversationMembers, devices } from '../db/schema.js'; + +export type E2eeProtocol = 'sealed_box' | 'signal'; + +export interface BlockingDevice { + deviceId: string; + userId: string; + deviceName: string | null; + platform: string | null; +} + +export interface ProtocolNegotiation { + /** What new messages in this conversation must use. */ + protocol: E2eeProtocol; + totalActiveDevices: number; + signalCapableDevices: number; + /** Active devices still on sealed box — what is holding the cutover back. */ + blockingDevices: BlockingDevice[]; +} + +/** Active devices belonging to every member of a conversation. */ +async function activeConversationDevices(conversationId: string) { + const memberRows = await db.query.conversationMembers.findMany({ + where: eq(conversationMembers.conversationId, conversationId), + columns: { userId: true }, + }); + + const userIds = memberRows.map((m) => m.userId); + if (userIds.length === 0) return []; + + return db.query.devices.findMany({ + where: and(inArray(devices.userId, userIds), isNull(devices.revokedAt)), + columns: { + id: true, + userId: true, + deviceName: true, + platform: true, + supportsSignal: true, + }, + }); +} + +/** + * Resolves which protocol new messages in a conversation must use. + * + * A conversation with no active devices reports `sealed_box`: there is nothing + * to negotiate with, and defaulting an empty set to `signal` would flip the + * conversation to a mode no device can read the moment one joins. + */ +export async function negotiateConversationProtocol( + conversationId: string, +): Promise { + const deviceRows = await activeConversationDevices(conversationId); + + const blockingDevices = deviceRows + .filter((d) => !d.supportsSignal) + .map((d) => ({ + deviceId: d.id, + userId: d.userId, + deviceName: d.deviceName, + platform: d.platform, + })); + + const capable = deviceRows.length - blockingDevices.length; + + return { + protocol: deviceRows.length > 0 && blockingDevices.length === 0 ? 'signal' : 'sealed_box', + totalActiveDevices: deviceRows.length, + signalCapableDevices: capable, + blockingDevices, + }; +} + +export interface EnvelopeProtocolInput { + recipientDeviceId: string; + protocol: E2eeProtocol; +} + +export type EnvelopeProtocolCheck = + | { ok: true; negotiated: E2eeProtocol } + | { + ok: false; + code: 400 | 409; + error: string; + negotiated: E2eeProtocol; + offendingDeviceIds: string[]; + }; + +/** + * Validates the protocol each outgoing envelope claims. + * + * Two failures are possible, and they are different problems: + * + * - **`signal` to a device that cannot read it** (`400`). The sender got ahead + * of the recipient. That envelope would be undecryptable on arrival, which + * the recipient cannot distinguish from tampering. + * - **`sealed_box` after the conversation has cut over** (`409`). Everyone can + * do Signal, so falling back is a downgrade. The sender is told the + * negotiated protocol and re-sends. + * + * Both surface the offending device ids so the client can re-fetch the device + * set and rebuild rather than retrying blind. + */ +export async function checkEnvelopeProtocols( + conversationId: string, + envelopes: EnvelopeProtocolInput[], +): Promise { + const negotiation = await negotiateConversationProtocol(conversationId); + const negotiated = negotiation.protocol; + + const signalEnvelopes = envelopes.filter((e) => e.protocol === 'signal'); + + if (signalEnvelopes.length > 0) { + const incapable = new Set(negotiation.blockingDevices.map((d) => d.deviceId)); + const offending = signalEnvelopes + .filter((e) => incapable.has(e.recipientDeviceId)) + .map((e) => e.recipientDeviceId); + + if (offending.length > 0) { + return { + ok: false, + code: 400, + error: 'Signal envelope addressed to a device that does not support Signal', + negotiated, + offendingDeviceIds: offending, + }; + } + } + + if (negotiated === 'signal') { + const downgraded = envelopes + .filter((e) => e.protocol === 'sealed_box') + .map((e) => e.recipientDeviceId); + + if (downgraded.length > 0) { + return { + ok: false, + code: 409, + error: + 'This conversation has cut over to Signal; sealed-box envelopes are no longer accepted', + negotiated, + offendingDeviceIds: downgraded, + }; + } + } + + return { ok: true, negotiated }; +} diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 3b76ffba..474563db 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -21,6 +21,7 @@ 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 { EventDispatcher } from './dispatcher.js'; const PAGE_SIZE = 30; @@ -101,7 +102,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; }; const deviceId = socket.auth!.deviceId; @@ -182,6 +187,28 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void } } + // Enforce the negotiated E2EE protocol (#364) — same rule as POST /messages. + if (envelopes && envelopes.length > 0) { + const protocolCheck = await checkEnvelopeProtocols( + conversationId, + envelopes.map((e) => ({ + recipientDeviceId: e.recipientDeviceId, + protocol: e.protocol ?? 'sealed_box', + })), + ); + + if (!protocolCheck.ok) { + socket.emit('error', { + event: 'protocol_mismatch', + code: protocolCheck.code, + message: protocolCheck.error, + negotiatedProtocol: protocolCheck.negotiated, + offendingDeviceIds: protocolCheck.offendingDeviceIds, + }); + return; + } + } + let fileId: string | null = inputFileId || null; if (FILE_CONTENT_TYPES.has(resolvedContentType) && !fileId) { const [fileRow] = await db @@ -231,6 +258,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void recipientDeviceId: env.recipientDeviceId, recipientUserId: deviceToUser.get(env.recipientDeviceId)!, ciphertext: env.ciphertext, + protocol: env.protocol ?? ('sealed_box' as const), })); if (validEnvelopes.length > 0) { @@ -363,6 +391,7 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void recipientDeviceId: env.recipientDeviceId, recipientUserId: deviceToUser.get(env.recipientDeviceId)!, ciphertext: env.ciphertext, + protocol: (env as { protocol?: E2eeProtocol }).protocol ?? ('sealed_box' as const), })); if (validEnvelopes.length > 0) { diff --git a/docs/signal-integration.md b/docs/signal-integration.md index de9601af..5d63d492 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 — the `supports_signal` capability flag on `devices`, the +per-envelope `protocol` column that keeps Phase-1 history decryptable, the +negotiation endpoint, and the rollout order — is specified in +[apps/backend/docs/signal-migration.md](../apps/backend/docs/signal-migration.md). + +The short version: a conversation switches to Signal only once every active +device on both sides advertises support, old envelopes are never re-encrypted, +and the client keeps its Phase-1 decryption path forever. + ## Activation checklist ```bash From 5354994b6d68f12dd28189a4e6be82c0b0d92034 Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Sun, 2 Aug 2026 10:39:41 +0100 Subject: [PATCH 2/2] @ fix(backend): repair merge damage in routes broken on main These five files do not parse on main after earlier conflict resolutions dropped closing braces and spliced handlers together. Backend CI cannot type-check the package while they are in this state. - conversations.ts: the group-invite rate-limit guard lost its return and closing brace, swallowing the rest of POST /:id/members - devices.ts: duplicate db/schema import, an import statement whose opening line was lost, and the device_linked audit event hoisted above the signature check where row and existing are not yet in scope - files.ts: two GET /:fileId route openers, one unclosed, and the membership guard fused with the audit-event block - uploads.ts: the MLS group check lost its closing brace, swallowing the daily upload quota - users.ts: the key-bundle prekey-low signal fused with the audit block and referenced an undeclared variable; GET /:id/key-fingerprint had lost its implementation entirely and carried key-bundle code instead; PATCH /me was truncated mid-statement and fused with a second, bogus key-fingerprint route referencing undefined identifiers The fingerprint implementation is restored to the contract its existing test pins: sorted identity keys, SHA-256, two 30-digit segments from non-overlapping halves, returned raw and in groups of five. uploads.test.ts had an unterminated vi.mock factory from the same cause. Each repair keeps both sides of the original merge; no feature is dropped. --- apps/backend/src/__tests__/uploads.test.ts | 2 + apps/backend/src/routes/conversations.ts | 22 +- apps/backend/src/routes/devices.ts | 38 ++- apps/backend/src/routes/files.ts | 5 +- apps/backend/src/routes/uploads.ts | 2 + apps/backend/src/routes/users.ts | 278 +++++++++++---------- 6 files changed, 191 insertions(+), 156 deletions(-) diff --git a/apps/backend/src/__tests__/uploads.test.ts b/apps/backend/src/__tests__/uploads.test.ts index 886a08ec..61a5ae05 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/routes/conversations.ts b/apps/backend/src/routes/conversations.ts index 5fddfe15..a41a4866 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 848a2def..47e08dec 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 a20543db..ae6ade3d 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/uploads.ts b/apps/backend/src/routes/uploads.ts index 6407d7a0..ee5a80e4 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 5f94635e..177facd6 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' }); } });