From 021b488a41e8949dbc27b8bc719bfedc7b4c1786 Mon Sep 17 00:00:00 2001 From: Olorunfemi20 Date: Thu, 30 Jul 2026 08:52:05 +0100 Subject: [PATCH] @ 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