diff --git a/docs/secure-chat-v1.md b/docs/secure-chat-v1.md new file mode 100644 index 0000000..a7b6461 --- /dev/null +++ b/docs/secure-chat-v1.md @@ -0,0 +1,185 @@ +# Secure Chat v1 Runbook + +This document is the current implementation contract for QueCode secure direct +messages. It is intentionally scoped to `secure_dm_v1`; secure groups are not +part of this version. + +## Current Status + +`secure_dm_v1` is a serious proof of concept for private text DMs and encrypted +media. It is not yet the final fintech-grade messaging protocol. + +Implemented: + +- Direct-message E2EE mode with `securityMode = "secure_dm_v1"`. +- Per-device identity keys, signed prekeys, and one-time prekeys. +- Server-side signed prekey signature verification. +- Server-side one-time prekey reservation and replay protection. +- Device revocation and current device listing. +- Client-side identity pinning with a visible fingerprint verification dialog. +- Client-side encrypted media upload and decrypt-on-device preview/download. +- Secure push/foreground notification redaction. +- Plaintext reactions blocked in secure chats. +- Backend protocol smoke coverage. + +Still required before production: + +- Encrypted reactions instead of blocked plaintext reactions. +- Secure group messaging. +- Production migration rehearsal and rollback plan. +- Native mobile secure storage when the PWA becomes a mobile app. +- Independent security review of protocol and code. + +## Threat Model + +This version is designed to protect message and media content from the backend, +database operators, notification providers, Cloudinary storage, and passive +network observers. + +This version does not fully protect against: + +- A compromised client device. +- Malicious JavaScript served to the client. +- A backend that silently registers attacker-controlled devices before users + verify fingerprints. +- Metadata exposure such as participants, chat IDs, timestamps, and message + counts. +- Denial of service, message deletion, or delivery suppression by the server. + +## Protocol Summary + +Device registration: + +1. The client generates a long-lived P-256 ECDSA identity key. +2. The client generates a P-256 ECDH signed prekey. +3. The client signs the canonical JSON form of the signed prekey public JWK + with the identity private key. +4. The client uploads the public identity key, signed prekey, signature, and a + batch of one-time prekeys. +5. The backend validates P-256 public key shape, signature presence, duplicate + key IDs, and cryptographically verifies the signed prekey signature. + +Message send: + +1. The sender fetches active recipient device bundles. +2. The client verifies signed prekey signatures and checks the local identity + trust store. +3. The client prefers a recipient one-time prekey when available, otherwise it + falls back to the signed prekey. +4. The client encrypts one envelope per recipient device. +5. The backend validates sender identity metadata, recipient device membership, + one-time prekey availability, and reserves consumed one-time prekeys in a DB + transaction. + +Message receive: + +1. The client receives only the envelope addressed to the current device. +2. The client rejects envelopes with mismatched sender identity metadata. +3. If a one-time prekey is consumed locally, the client removes the private + prekey from local storage after decrypting. + +Media send: + +1. The client encrypts the file with a random media key and IV before upload. +2. The backend uploads only encrypted bytes as raw Cloudinary content. +3. The chat message stores only encrypted media metadata inside the E2EE message + content. +4. The recipient decrypts media locally for preview or download. + +## Key Management Rules + +- Private identity, signed prekey, and one-time prekey material must not be + stored in `localStorage`. +- Browser PWA storage currently uses IndexedDB. This is acceptable for the PWA + proof of concept, but native mobile must use platform secure storage later. +- Signed prekeys should rotate regularly. The current frontend refresh policy is + intentionally tolerant so older deployed backends do not break chat. +- One-time prekeys should be refilled before the server-side available count is + depleted. +- Device revocation prevents future sends to the revoked device but does not + revoke content that was already delivered to that device. + +## Notification Rules + +Secure chat notifications must never include: + +- Plaintext message content. +- Sender display name. +- Media URLs. +- Encrypted envelope payloads. +- Device key material. + +Allowed notification data is limited to routing metadata such as `chatId`, +`messageId`, `messageType`, and an app URL/action. + +## Reactions + +Plaintext reactions are currently blocked for secure chats on both frontend and +backend. + +The production path is to implement reactions as encrypted control messages, for +example: + +```json +{ + "kind": "reaction", + "targetMessageId": "message-id", + "emoji": "thumbs_up", + "action": "set" +} +``` + +Those control messages must be encrypted per recipient device like normal secure +messages and must not use the legacy plaintext reaction socket events. + +## Deployment And Rollout + +Before merging: + +1. Rebase/merge these local branches with the latest `origin/dev2` frontend and + `origin/dev-2` backend. +2. Run backend checks: + - `npm run build` + - `npm run test:e2ee` +3. Run frontend checks: + - `npm run type-check` + - `npm run test:e2ee-media` +4. Confirm the DB migration `20260518000000-add-chat-security-and-e2ee-devices` + has been reviewed against the real production/staging database. +5. Confirm both Netlify origins are temporarily allowed by backend CORS: + - `https://qiew-code-dev2.netlify.app` + - `https://qc-dev2.netlify.app` + +Rollout order: + +1. Deploy backend with old and new frontend origins allowed. +2. Apply the DB migration during a planned window. +3. Deploy the new frontend to `https://qc-dev2.netlify.app`. +4. Ask the team to switch from `qiew-code-dev2.netlify.app` to + `qc-dev2.netlify.app` only after the new deployment contains all merged work. +5. Keep the old origin allowed temporarily, then remove it after the team has + moved and no active workflow depends on it. + +Rollback: + +- Keep old frontend deployment available until the new one is validated. +- Do not drop E2EE tables during rollback. Disable secure chat creation at the + app level instead, so existing secure metadata is preserved. +- If the migration fails, stop rollout before asking the team to switch links. + +## Audit Checklist + +Before production fintech use, an external review should verify: + +- The canonical signature input for signed prekeys is stable across browsers and + Node. +- Envelope associated data binds sender, recipient, chat, device IDs, algorithm, + and message ID. +- One-time prekey reservation is race-safe under concurrent sends. +- Revoked devices cannot receive newly created secure envelopes. +- Media encryption rejects tampering and never renders encrypted URLs directly. +- Notifications cannot leak sensitive secure chat content. +- Secure chat downgrade from `secure_dm_v1` to legacy mode is impossible without + explicit administrative migration. +- Logs do not store plaintext messages, media keys, private keys, or full + encrypted payloads unnecessarily. diff --git a/package.json b/package.json index 854a3fe..84c2c2b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev:nodemon": "nodemon", "dev:clean": "pkill -f 'ts-node-dev' || true && pnpm run dev", "dev:verbose": "ts-node-dev --respawn --transpile-only --ignore-watch node_modules --require dotenv/config --poll --rs --clear src/index.ts", - "build": "tsc && mkdir -p dist/database/seeders && cp -f src/database/seeders/*.js dist/database/seeders/", + "build": "tsc && node -e \"const fs=require('fs'), path=require('path'); const src='src/database/seeders', dst='dist/database/seeders'; fs.mkdirSync(dst,{recursive:true}); for (const file of fs.readdirSync(src).filter((name)=>name.endsWith('.js'))) fs.copyFileSync(path.join(src,file), path.join(dst,file));\"", "start": "node --require dotenv/config dist/index.js", "heroku-postbuild": "pnpm run build", "drop-all-tables": "psql -U your_username -d your_database_name -c \"SELECT 'DROP TABLE IF EXISTS \"' || tablename || '\" CASCADE;' FROM pg_tables WHERE schemaname = 'public'\" | psql -U your_username -d your_database_name", @@ -25,6 +25,7 @@ "prepare": "husky install", "precommit": "prettier . --write && eslint --fix .", "run-tests": "cross-env NODE_ENV=test jest --detectOpenHandles --coverage --verbose --testTimeout=10000", + "test:e2ee": "ts-node --transpile-only scripts/e2ee-protocol-smoke.ts", "pretest": "cross-env NODE_ENV=test pnpm run migrate:reset", "run-after-tests": "cross-env NODE_ENV=test sequelize db:migrate:undo:all ", "test": "npm-run-all run-tests run-after-tests --continue-on-error" diff --git a/scripts/e2ee-protocol-smoke.ts b/scripts/e2ee-protocol-smoke.ts new file mode 100644 index 0000000..2bdab8c --- /dev/null +++ b/scripts/e2ee-protocol-smoke.ts @@ -0,0 +1,652 @@ +import assert from "node:assert/strict"; +import { webcrypto } from "node:crypto"; +import { + appendUserDeviceOneTimePreKeys, + getUserDeviceBundles, + revokeUserDevice, + rotateUserDeviceSignedPreKey, + upsertUserDeviceBundle, +} from "../src/services/e2eeDevice.service"; + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; +}; + +const p256PublicKey = (seed: string) => ({ + kty: "EC", + crv: "P-256", + x: Buffer.alloc(32, `${seed}x`).toString("base64url"), + y: Buffer.alloc(32, `${seed}y`).toString("base64url"), +}); + +let validBundle = { + deviceId: "device-secure-smoke-1", + deviceName: "Smoke Browser", + platform: "test", + appVersion: "test", + bundle: { + algorithm: "qc-e2ee-p256-v1", + identityPublicKey: p256PublicKey("i"), + signedPreKey: { + keyId: 11, + publicKey: p256PublicKey("s"), + signature: Buffer.alloc(64, "signature").toString("base64url"), + }, + registrationId: 42, + oneTimePreKeys: [ + { keyId: 101, publicKey: p256PublicKey("a") }, + { keyId: 102, publicKey: p256PublicKey("b") }, + ], + }, +}; + +const generateSigningKeyPair = () => + webcrypto.subtle.generateKey( + { + name: "ECDSA", + namedCurve: "P-256", + }, + true, + ["sign", "verify"], + ); + +const generateExchangeKeyPair = () => + webcrypto.subtle.generateKey( + { + name: "ECDH", + namedCurve: "P-256", + }, + true, + ["deriveBits"], + ); + +const signSignedPreKey = async (identityPrivateKey: CryptoKey, signedPreKeyPublic: JsonWebKey) => { + const signature = await webcrypto.subtle.sign( + { + name: "ECDSA", + hash: "SHA-256", + }, + identityPrivateKey, + new TextEncoder().encode(stableStringify(signedPreKeyPublic)), + ); + + return Buffer.from(signature).toString("base64url"); +}; + +const createValidBundle = async () => { + const identityKeys = await generateSigningKeyPair(); + const signedPreKey = await generateExchangeKeyPair(); + const identityPublicKey = (await webcrypto.subtle.exportKey( + "jwk", + identityKeys.publicKey, + )) as Record; + const signedPreKeyPublic = (await webcrypto.subtle.exportKey( + "jwk", + signedPreKey.publicKey, + )) as Record; + + return { + deviceId: "device-secure-smoke-1", + deviceName: "Smoke Browser", + platform: "test", + appVersion: "test", + bundle: { + algorithm: "qc-e2ee-p256-v1", + identityPublicKey, + signedPreKey: { + keyId: 11, + publicKey: signedPreKeyPublic, + signature: await signSignedPreKey(identityKeys.privateKey, signedPreKeyPublic), + }, + registrationId: 42, + oneTimePreKeys: [ + { keyId: 101, publicKey: p256PublicKey("a") }, + { keyId: 102, publicKey: p256PublicKey("b") }, + ], + }, + }; +}; + +const createSignedPreKeyForIdentity = async () => { + const identityKeys = await generateSigningKeyPair(); + const signedPreKey = await generateExchangeKeyPair(); + const identityPublicKey = (await webcrypto.subtle.exportKey( + "jwk", + identityKeys.publicKey, + )) as Record; + const signedPreKeyPublic = (await webcrypto.subtle.exportKey( + "jwk", + signedPreKey.publicKey, + )) as Record; + + return { + identityPublicKey, + signedPreKey: { + keyId: 777, + publicKey: signedPreKeyPublic, + signature: await signSignedPreKey(identityKeys.privateKey, signedPreKeyPublic), + }, + }; +}; + +const expectStatus = async ( + run: () => Promise, + statusCode: number, + messageIncludes: string, +) => { + await assert.rejects(run, (error: any) => { + assert.equal(error.statusCode, statusCode); + assert.match(error.message, new RegExp(messageIncludes)); + return true; + }); +}; + +const buildDevice = (overrides: Record = {}) => ({ + id: "user-device-row-id", + userId: "user-a", + deviceId: validBundle.deviceId, + deviceName: null, + platform: null, + appVersion: null, + isActive: true, + revokedAt: null, + lastSeenAt: null, + keyBundle: { + identityPublicKey: validBundle.bundle.identityPublicKey, + signedPreKeyPublic: validBundle.bundle.signedPreKey.publicKey, + signedPreKeySignature: validBundle.bundle.signedPreKey.signature, + }, + async update(values: Record) { + Object.assign(this, values); + return this; + }, + ...overrides, +}); + +const buildBundle = () => ({ + uploadedAt: null, + async update(values: Record) { + Object.assign(this, values); + return this; + }, +}); + +const buildEnvelope = ( + recipientUserId: string, + recipientDeviceId: string, + recipientOneTimePreKeyId?: number, +) => ({ + version: 1, + protocolVersion: "secure-dm-v1", + algorithm: "qc-e2ee-p256-v1", + senderUserId: "user-a", + senderDeviceId: "device-a-1", + recipientUserId, + recipientDeviceId, + recipientOneTimePreKeyId, + ephemeralPublicKey: p256PublicKey("e"), + wrappedMessageKey: "wrapped", + wrappedMessageKeyIv: "wrapped-iv", + ciphertext: "ciphertext", + ciphertextIv: "ciphertext-iv", + createdAt: new Date().toISOString(), + signature: "signature", +}); + +const buildRecipientPayload = ( + recipientUserId: string, + recipientDeviceId: string, + recipientOneTimePreKeyId?: number, +) => ({ + recipientUserId, + recipientDeviceId, + encryptedEnvelope: buildEnvelope(recipientUserId, recipientDeviceId, recipientOneTimePreKeyId), +}); + +const buildRecipientPayloadWithEnvelopeOverrides = ( + recipientUserId: string, + recipientDeviceId: string, + envelopeOverrides: Record, +) => ({ + recipientUserId, + recipientDeviceId, + encryptedEnvelope: { + ...buildEnvelope(recipientUserId, recipientDeviceId), + ...envelopeOverrides, + }, +}); + +const buildSendModels = ({ + consumeCount = 1, + includeRecipientDeviceB2 = true, +}: { + consumeCount?: number; + includeRecipientDeviceB2?: boolean; +} = {}) => { + const createdPayloadRows: unknown[] = []; + const consumedPreKeys: unknown[] = []; + const createdMessage = { + id: "message-1", + chatId: "chat-1", + senderId: "user-a", + messageType: "text", + replyToMessageId: null, + status: "sent", + createdAt: new Date(), + }; + + return { + createdPayloadRows, + consumedPreKeys, + models: { + ChatParticipant: { + findOne: async () => ({ chatId: "chat-1", userId: "user-a" }), + findAll: async () => [{ userId: "user-a" }, { userId: "user-b" }], + }, + Chat: { + findByPk: async () => ({ + id: "chat-1", + isGroup: false, + type: "dm", + securityMode: "secure_dm_v1", + }), + }, + UserDevice: { + findOne: async ({ where }: any) => + where.userId === "user-a" && where.deviceId === "device-a-1" + ? { + id: "device-row-a1", + userId: "user-a", + deviceId: "device-a-1", + keyBundle: {}, + } + : null, + findAll: async () => { + const rows = [ + { + id: "device-row-a1", + userId: "user-a", + deviceId: "device-a-1", + keyBundle: {}, + oneTimePreKeys: [{ preKeyId: 201 }], + }, + { + id: "device-row-b1", + userId: "user-b", + deviceId: "device-b-1", + keyBundle: {}, + oneTimePreKeys: [{ preKeyId: 301 }], + }, + ]; + + if (includeRecipientDeviceB2) { + rows.push({ + id: "device-row-b2", + userId: "user-b", + deviceId: "device-b-2", + keyBundle: {}, + oneTimePreKeys: [{ preKeyId: 302 }], + }); + } + + return rows; + }, + update: async () => [1], + }, + ChatMessage: { + create: async () => createdMessage, + findOne: async () => null, + findByPk: async () => createdMessage, + }, + ChatMessageRecipientPayload: { + bulkCreate: async (rows: unknown[]) => { + createdPayloadRows.push(...rows); + }, + }, + DeviceOneTimePreKey: { + update: async (_values: unknown, options: unknown) => { + consumedPreKeys.push(options); + return [consumeCount]; + }, + }, + }, + }; +}; + +const run = async () => { + validBundle = await createValidBundle(); + + await expectStatus( + () => + upsertUserDeviceBundle({} as any, "user-a", { + ...validBundle, + bundle: { + ...validBundle.bundle, + algorithm: "unsupported", + }, + }), + 400, + "Unsupported E2EE algorithm", + ); + + await expectStatus( + () => + upsertUserDeviceBundle({} as any, "user-a", { + ...validBundle, + bundle: { + ...validBundle.bundle, + signedPreKey: { + ...validBundle.bundle.signedPreKey, + signature: Buffer.alloc(64, "bad-signature").toString("base64url"), + }, + }, + }), + 400, + "Invalid signed pre-key signature", + ); + + await expectStatus( + () => + upsertUserDeviceBundle({} as any, "user-a", { + ...validBundle, + bundle: { + ...validBundle.bundle, + oneTimePreKeys: [ + { keyId: 101, publicKey: p256PublicKey("a") }, + { keyId: 101, publicKey: p256PublicKey("b") }, + ], + }, + }), + 400, + "Duplicate one-time pre-key id", + ); + + let destroyedPreKeysWhere: unknown = null; + let insertedPreKeys: unknown[] = []; + const upsertDevice = buildDevice(); + const upsertBundle = buildBundle(); + const upsertModels = { + UserDevice: { + findOne: async () => null, + findOrCreate: async () => [upsertDevice], + }, + DeviceKeyBundle: { + findOrCreate: async () => [upsertBundle], + }, + DeviceOneTimePreKey: { + destroy: async ({ where }: { where: unknown }) => { + destroyedPreKeysWhere = where; + }, + bulkCreate: async (rows: unknown[]) => { + insertedPreKeys = rows; + }, + }, + }; + + const registered = await upsertUserDeviceBundle(upsertModels, "user-a", validBundle); + assert.equal(registered.availableOneTimePreKeys, 2); + assert.equal(upsertDevice.userId, "user-a"); + assert.equal((upsertBundle as any).algorithm, "qc-e2ee-p256-v1"); + assert.deepEqual(destroyedPreKeysWhere, { + userDeviceId: "user-device-row-id", + usedAt: null, + }); + assert.equal(insertedPreKeys.length, 2); + + await expectStatus( + () => + getUserDeviceBundles( + { + Contact: { findOne: async () => null }, + } as any, + "user-a", + "user-b", + ), + 403, + "active contacts", + ); + + let revokeDestroyWhere: unknown = null; + const revokedDevice = buildDevice(); + const revokeModels = { + UserDevice: { + findOne: async () => revokedDevice, + }, + DeviceOneTimePreKey: { + destroy: async ({ where }: { where: unknown }) => { + revokeDestroyWhere = where; + }, + }, + }; + + const revoked = await revokeUserDevice(revokeModels, "user-a", validBundle.deviceId); + assert.equal(revoked.deviceId, validBundle.deviceId); + assert.equal(revokedDevice.isActive, false); + assert.ok(revokedDevice.revokedAt instanceof Date); + assert.deepEqual(revokeDestroyWhere, { + userDeviceId: "user-device-row-id", + usedAt: null, + }); + + const rotationKeys = await createSignedPreKeyForIdentity(); + const activeDevice = buildDevice({ + keyBundle: { + identityPublicKey: rotationKeys.identityPublicKey, + uploadedAt: null, + async update(values: Record) { + Object.assign(this, values); + return this; + }, + }, + }); + const rotateModels = { + UserDevice: { + findOne: async () => activeDevice, + }, + DeviceKeyBundle: {}, + }; + const rotated = await rotateUserDeviceSignedPreKey( + rotateModels, + "user-a", + validBundle.deviceId, + rotationKeys.signedPreKey, + ); + + assert.equal(rotated.signedPreKeyId, 777); + assert.equal((activeDevice.keyBundle as any).signedPreKeyId, 777); + + await expectStatus( + () => + rotateUserDeviceSignedPreKey(rotateModels, "user-a", validBundle.deviceId, { + ...rotationKeys.signedPreKey, + keyId: 778, + signature: Buffer.alloc(64, "bad-rotation").toString("base64url"), + }), + 400, + "Invalid signed pre-key signature", + ); + + const appendedRows: unknown[] = []; + const appendModels = { + UserDevice: { + findOne: async () => activeDevice, + }, + DeviceKeyBundle: {}, + DeviceOneTimePreKey: { + findAll: async () => [], + bulkCreate: async (rows: unknown[]) => { + appendedRows.push(...rows); + }, + }, + }; + const appended = await appendUserDeviceOneTimePreKeys( + appendModels, + "user-a", + validBundle.deviceId, + [{ keyId: 901, publicKey: p256PublicKey("n") }], + ); + + assert.equal(appended.addedOneTimePreKeys, 1); + assert.equal(appendedRows.length, 1); + + process.env.DB_DEV_URL ||= "postgres://user:pass@localhost:5432/qc_smoke"; + const { sequelizeConnection } = await import("../src/database/config/db.config"); + const { getSecureDMMessagePage, sendSecureDMMessage } = await import( + "../src/services/e2eeMessage.service" + ); + + (sequelizeConnection as any).transaction = async (callback: (transaction: unknown) => unknown) => + callback({ smoke: true }); + + const sendSuccess = buildSendModels(); + const sentMessage = await sendSecureDMMessage(sendSuccess.models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [ + buildRecipientPayload("user-a", "device-a-1", 201), + buildRecipientPayload("user-b", "device-b-1", 301), + buildRecipientPayload("user-b", "device-b-2", 302), + ], + }); + + assert.equal((sentMessage as any).id, "message-1"); + assert.equal(sendSuccess.createdPayloadRows.length, 3); + assert.equal(sendSuccess.consumedPreKeys.length, 3); + + const sendMediaSuccess = buildSendModels(); + await sendSecureDMMessage(sendMediaSuccess.models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "image", + recipientPayloads: [buildRecipientPayload("user-b", "device-b-1", 301)], + }); + + assert.equal(sendMediaSuccess.createdPayloadRows.length, 1); + assert.equal(sendMediaSuccess.consumedPreKeys.length, 1); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels().models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "money" as any, + recipientPayloads: [buildRecipientPayload("user-b", "device-b-1", 301)], + }), + 400, + "Unsupported secure message type", + ); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels().models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [ + buildRecipientPayloadWithEnvelopeOverrides("user-b", "device-b-1", { + senderUserId: "user-b", + }), + ], + }), + 400, + "sender identity", + ); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels().models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [ + buildRecipientPayloadWithEnvelopeOverrides("user-b", "device-b-1", { + recipientDeviceId: "device-b-2", + }), + ], + }), + 400, + "recipient identity", + ); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels().models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [buildRecipientPayload("user-c", "device-c-1", 401)], + }), + 400, + "outside this secure chat", + ); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels({ includeRecipientDeviceB2: false }).models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [ + buildRecipientPayload("user-b", "device-b-1", 301), + buildRecipientPayload("user-b", "device-b-2", 302), + ], + }), + 400, + "outside this secure chat", + ); + + await expectStatus( + () => + getSecureDMMessagePage(buildSendModels().models, { + chatId: "chat-1", + userId: "user-b", + deviceId: "device-b-2", + page: 1, + limit: 10, + }), + 400, + "registered secure device", + ); + + await expectStatus( + () => + sendSecureDMMessage(buildSendModels({ consumeCount: 0 }).models, { + chatId: "chat-1", + userId: "user-a", + senderDeviceId: "device-a-1", + messageType: "text", + recipientPayloads: [buildRecipientPayload("user-b", "device-b-1", 301)], + }), + 409, + "already consumed", + ); + + console.log("E2EE protocol smoke checks passed"); +}; + +run().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/src/app.ts b/src/app.ts index b10a1eb..3d41965 100644 --- a/src/app.ts +++ b/src/app.ts @@ -37,12 +37,21 @@ const allowedOrigins = [ "http://localhost:3000", "http://localhost:3001", "http://localhost:3003", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1:3003", + "http://[::1]:3000", + "http://[::1]:3001", + "http://[::1]:3003", process.env.ADMIN_FRONTEND_URL, process.env.FRONTEND_URL, + "https://qiew-code-dev2.netlify.app", "https://qc-dev2.netlify.app", ].filter(Boolean); -const netlifyPreviewOriginPattern = - /^https:\/\/deploy-preview-\d+--qiew-code-dev2\.netlify\.app$/; +const netlifyPreviewOriginPatterns = [ + /^https:\/\/deploy-preview-\d+--qiew-code-dev2\.netlify\.app$/, + /^https:\/\/deploy-preview-\d+--qc-dev2\.netlify\.app$/, +]; // CORS configuration - allow credentials with specific origin const corsOptions = { @@ -53,7 +62,7 @@ const corsOptions = { if (!origin) return callback(null, true); if ( allowedOrigins.includes(origin) || - netlifyPreviewOriginPattern.test(origin) + netlifyPreviewOriginPatterns.some((pattern) => pattern.test(origin)) ) { return callback(null, true); } else { @@ -67,6 +76,7 @@ const corsOptions = { "Authorization", "X-Requested-With", "Accept", + "x-qc-device-id", ], exposedHeaders: ["Content-Range", "X-Content-Range"], maxAge: 86400, // 24 hours diff --git a/src/auth/reset_password.ts b/src/auth/reset_password.ts index 5530caa..aa0ca4d 100644 --- a/src/auth/reset_password.ts +++ b/src/auth/reset_password.ts @@ -12,7 +12,7 @@ import bcrypt from "bcrypt"; const JWT_SECRET = process.env.JWT_SECRET || "your_jwt_secret"; const FRONTEND_URL = process.env.FRONTEND_URL || "http://localhost:3000"; const NON_ADMIN_FRONTEND_URL = - process.env.NON_ADMIN_FRONTEND_URL || "https://qiew-code-dev2.netlify.app"; + process.env.NON_ADMIN_FRONTEND_URL || "https://qc-dev2.netlify.app"; type PasswordResetAccountType = "user" | "organization"; type AuthTokenPurpose = "password_reset" | "account_setup"; diff --git a/src/controllers/chatController.ts b/src/controllers/chatController.ts index 701f5e2..11cec74 100644 --- a/src/controllers/chatController.ts +++ b/src/controllers/chatController.ts @@ -7,6 +7,7 @@ import ChatService from "../services/chatService"; import multer from "multer"; import path from "path"; import fs from "fs"; +import { SECURE_DM_PROTOCOL_VERSION, supportsSecureDmBetweenUsers } from "../services/e2eeMessage.service"; // Get user's chats (both DMs and group chats) export const getUserChats = async ( @@ -18,55 +19,75 @@ export const getUserChats = async ( const userId = req.user.id; const models = req.app.get("models") as ReturnType; - const chats = await models.ChatParticipant.findAll({ + const chatIncludes = (chatAttributes: string[]) => [ + { + model: models.Chat, + as: "chat", + attributes: chatAttributes, + include: [ + { + model: models.ChatParticipant, + as: "participants", + include: [ + { + model: models.User, + as: "user", + attributes: ["id", "firstName", "lastName", "email", "isOnline", "lastSeen"], + include: [ + { + model: models.Profile, + as: "profile", + attributes: ["profileImage"] + } + ] + } + ] + }, + { + model: models.ChatMessage, + as: "messages", + limit: 1, + order: [["createdAt", "DESC"]], + include: [ + { + model: models.User, + as: "sender", + attributes: ["firstName", "lastName"] + } + ] + }, + { + model: models.Group, + as: "group", + attributes: ["id", "name", "description", "profilePictureUrl", "memberCount"] + } + ] + } + ]; + + const loadChats = (chatAttributes: string[]) => models.ChatParticipant.findAll({ where: { userId }, - include: [ - { - model: models.Chat, - as: "chat", - attributes: ['id', 'isGroup', 'groupId', 'type', 'createdAt', 'updatedAt'], // Explicitly include groupId + chat type - include: [ - { - model: models.ChatParticipant, - as: "participants", - include: [ - { - model: models.User, - as: "user", - attributes: ["id", "firstName", "lastName", "email", "isOnline", "lastSeen"], - include: [ - { - model: models.Profile, - as: "profile", - attributes: ["profileImage"] - } - ] - } - ] - }, - { - model: models.ChatMessage, - as: "messages", - limit: 1, - order: [["createdAt", "DESC"]], - include: [ - { - model: models.User, - as: "sender", - attributes: ["firstName", "lastName"] - } - ] - }, - { - model: models.Group, - as: "group", - attributes: ["id", "name", "description", "profilePictureUrl", "memberCount"] - } - ] - } - ] + include: chatIncludes(chatAttributes) as any }); + let chats; + try { + chats = await loadChats(['id', 'isGroup', 'groupId', 'type', 'securityMode', 'protocolVersion', 'createdAt', 'updatedAt']); + } catch (error: any) { + const missingSecureChatColumns = + error?.parent?.code === "42703" && + ["chat.securityMode", "chat.protocolVersion"].some((column) => + String(error?.parent?.message || error?.message || "").includes(column) + ); + + if (!missingSecureChatColumns) { + throw error; + } + + console.warn("Chats table is missing E2EE metadata columns; loading chats in legacy compatibility mode."); + chats = await loadChats(['id', 'isGroup', 'groupId', 'type', 'createdAt', 'updatedAt']); + } + // Deduplicate by chatId — a user may have multiple ChatParticipant rows for // the same chat (e.g. they joined both as a regular user and as admin in a // support chat). Keep only the first occurrence per chatId. @@ -178,6 +199,8 @@ export const getUserChats = async ( name: chatName, isGroup: chat.isGroup, type: chat.type, + securityMode: chat.securityMode || "legacy", + protocolVersion: chat.protocolVersion || null, groupId: chat.groupId, // Include groupId for group chats avatar: chatAvatar, lastMessage: lastMessage ? { @@ -198,9 +221,61 @@ export const getUserChats = async ( }; }); + const preferredDirectChats = new Map(); + + for (const chat of formattedChats) { + if (chat.isGroup || chat.type === "support") { + continue; + } + + const otherParticipant = chat.participants.find((participant: any) => participant.userId !== userId); + if (!otherParticipant) { + continue; + } + + const mapKey = `dm:${otherParticipant.userId}`; + const existing = preferredDirectChats.get(mapKey); + + if (!existing) { + preferredDirectChats.set(mapKey, chat); + continue; + } + + const existingIsSecure = existing.securityMode === "secure_dm_v1"; + const currentIsSecure = chat.securityMode === "secure_dm_v1"; + + if (currentIsSecure && !existingIsSecure) { + preferredDirectChats.set(mapKey, chat); + continue; + } + + if (currentIsSecure === existingIsSecure) { + const existingTimestamp = new Date(existing.lastMessage?.createdAt || 0).getTime(); + const currentTimestamp = new Date(chat.lastMessage?.createdAt || 0).getTime(); + + if (currentTimestamp > existingTimestamp) { + preferredDirectChats.set(mapKey, chat); + } + } + } + + const dedupedFormattedChats = formattedChats.filter((chat) => { + if (chat.isGroup || chat.type === "support") { + return true; + } + + const otherParticipant = chat.participants.find((participant: any) => participant.userId !== userId); + if (!otherParticipant) { + return true; + } + + const preferredChat = preferredDirectChats.get(`dm:${otherParticipant.userId}`); + return preferredChat?.id === chat.id; + }); + res.json({ success: true, - data: formattedChats + data: dedupedFormattedChats }); } catch (error) { @@ -422,7 +497,8 @@ export const createOrGetDMChat = async ( const participantChatIds = participantChats.map(cp => cp.chatId); const commonChatIds = userChatIds.filter(id => participantChatIds.includes(id)); - let existingChat = null; + let existingSecureChat = null; + let existingLegacyChat = null; if (commonChatIds.length > 0) { // Verify it's exactly a 2-person chat for (const chatId of commonChatIds) { @@ -431,16 +507,27 @@ export const createOrGetDMChat = async ( }); if (participantCount === 2) { - existingChat = await models.Chat.findByPk(chatId); - break; + const candidateChat = await models.Chat.findByPk(chatId); + if (candidateChat?.securityMode === "secure_dm_v1") { + existingSecureChat = candidateChat; + break; + } + if (!existingLegacyChat) { + existingLegacyChat = candidateChat; + } } } } - if (existingChat) { + const resolvedChat = existingSecureChat || existingLegacyChat; + if (resolvedChat) { res.json({ success: true, - data: { chatId: existingChat.id }, + data: { + chatId: resolvedChat.id, + securityMode: resolvedChat.securityMode || "legacy", + protocolVersion: resolvedChat.protocolVersion || null, + }, message: "Existing chat found" }); return; @@ -475,13 +562,20 @@ export const createOrGetDMChat = async ( return; } + const shouldCreateSecureChat = await supportsSecureDmBetweenUsers(models, [ + userId, + participantId, + ]); + // Create new DM chat const transaction = await sequelizeConnection.transaction(); try { const newChat = await models.Chat.create( { - isGroup: false + isGroup: false, + securityMode: shouldCreateSecureChat ? "secure_dm_v1" : "legacy", + protocolVersion: shouldCreateSecureChat ? SECURE_DM_PROTOCOL_VERSION : null, }, { transaction } ); @@ -504,7 +598,11 @@ export const createOrGetDMChat = async ( res.status(201).json({ success: true, - data: { chatId: newChat.id }, + data: { + chatId: newChat.id, + securityMode: newChat.securityMode, + protocolVersion: newChat.protocolVersion, + }, message: "Chat created successfully" }); @@ -971,7 +1069,9 @@ export const joinGroupChat = async ( // Create the chat chat = await models.Chat.create({ isGroup: true, - groupId + groupId, + securityMode: "legacy", + protocolVersion: null, }, { transaction }); // Get all active group members @@ -1069,4 +1169,4 @@ export const joinGroupChat = async ( console.error("Error joining group chat:", error); next(error); } -}; \ No newline at end of file +}; diff --git a/src/controllers/e2ee.controller.ts b/src/controllers/e2ee.controller.ts new file mode 100644 index 0000000..46e0608 --- /dev/null +++ b/src/controllers/e2ee.controller.ts @@ -0,0 +1,614 @@ +import { NextFunction, Response } from "express"; +import Models from "../database/models"; +import { AuthenticatedRequest } from "../types/requests"; +import { + appendUserDeviceOneTimePreKeys, + getUserDeviceBundles, + listUserDevices, + revokeUserDevice, + rotateUserDeviceSignedPreKey, + upsertUserDeviceBundle, +} from "../services/e2eeDevice.service"; +import { + createOrGetSecureDMChat, + getSecureDMMessagePage, + markSecureChatMessagesAsRead, + sendSecureDMMessage, +} from "../services/e2eeMessage.service"; +import { notifyChatMessageReceived } from "../utils/notificationHelpers"; +import { uploadEncryptedChatMedia } from "../services/mediaUploadService"; +import fs from "fs"; + +const ensureAuthenticatedUser = async ( + req: AuthenticatedRequest, + models: ReturnType, +) => { + const userId = req.user?.id; + if (!userId) { + throw Object.assign(new Error("Unauthorized"), { statusCode: 401 }); + } + + const user = await models.User.findByPk(userId); + if (!user) { + throw Object.assign( + new Error("Secure chat device registration is supported for user accounts only"), + { statusCode: 403 }, + ); + } + + return userId; +}; + +export const registerSecureDevice = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const result = await upsertUserDeviceBundle(models, userId, req.body); + + return res.status(200).json({ + success: true, + message: "Secure device bundle registered", + data: { + id: result.device.id, + deviceId: result.device.deviceId, + deviceName: result.device.deviceName, + platform: result.device.platform, + appVersion: result.device.appVersion, + availableOneTimePreKeys: result.availableOneTimePreKeys, + uploadedAt: result.bundle.uploadedAt, + }, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const getMySecureDevices = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const devices = await listUserDevices(models, userId); + + return res.status(200).json({ + success: true, + data: devices.map((device: any) => ({ + id: device.id, + deviceId: device.deviceId, + deviceName: device.deviceName, + platform: device.platform, + appVersion: device.appVersion, + isActive: device.isActive, + lastSeenAt: device.lastSeenAt, + revokedAt: device.revokedAt, + bundle: device.keyBundle + ? { + algorithm: device.keyBundle.algorithm, + registrationId: device.keyBundle.registrationId, + signedPreKeyId: device.keyBundle.signedPreKeyId, + uploadedAt: device.keyBundle.uploadedAt, + } + : null, + availableOneTimePreKeys: Array.isArray(device.oneTimePreKeys) + ? device.oneTimePreKeys.length + : 0, + })), + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const revokeMySecureDevice = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const result = await revokeUserDevice(models, userId, req.params.deviceId); + + return res.status(200).json({ + success: true, + message: "Secure device revoked", + data: result, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const rotateMySecureDeviceSignedPreKey = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const result = await rotateUserDeviceSignedPreKey( + models, + userId, + req.params.deviceId, + req.body?.signedPreKey, + ); + + return res.status(200).json({ + success: true, + message: "Secure signed pre-key rotated", + data: result, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const addMySecureDeviceOneTimePreKeys = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const result = await appendUserDeviceOneTimePreKeys( + models, + userId, + req.params.deviceId, + req.body?.oneTimePreKeys, + ); + + return res.status(201).json({ + success: true, + message: "Secure one-time pre-keys added", + data: result, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const getPublicDeviceBundlesForUser = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const requesterId = await ensureAuthenticatedUser(req, models); + const targetUserId = req.params.userId; + + if (!targetUserId) { + return res.status(400).json({ + success: false, + message: "Target userId is required", + }); + } + + const devices = await getUserDeviceBundles(models, requesterId, targetUserId); + + return res.status(200).json({ + success: true, + data: devices.map((device: any) => ({ + deviceId: device.deviceId, + deviceName: device.deviceName, + platform: device.platform, + bundle: device.keyBundle + ? { + algorithm: device.keyBundle.algorithm, + identityPublicKey: device.keyBundle.identityPublicKey, + signedPreKeyId: device.keyBundle.signedPreKeyId, + signedPreKeyPublic: device.keyBundle.signedPreKeyPublic, + signedPreKeySignature: device.keyBundle.signedPreKeySignature, + registrationId: device.keyBundle.registrationId, + } + : null, + oneTimePreKeys: Array.isArray(device.oneTimePreKeys) + ? device.oneTimePreKeys.map((preKey: any) => ({ + keyId: preKey.preKeyId, + publicKey: preKey.publicKey, + })) + : [], + })), + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const createOrGetSecureDM = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const { participantId } = req.body || {}; + + const result = await createOrGetSecureDMChat(models, { + userId, + participantId, + }); + + return res.status(result.created ? 201 : 200).json({ + success: true, + message: result.created + ? "Secure chat created successfully" + : "Existing secure chat found", + data: { + chatId: result.chat.id, + securityMode: result.chat.securityMode, + protocolVersion: result.chat.protocolVersion, + }, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +const resolveSecureDeviceId = (req: AuthenticatedRequest) => + req.header("x-qc-device-id")?.trim() || req.body?.senderDeviceId?.trim() || ""; + +export const getSecureChatMessages = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const { chatId } = req.params; + const deviceId = resolveSecureDeviceId(req); + const page = Number(req.query.page || 1); + const limit = Number(req.query.limit || 50); + + if (!chatId) { + return res.status(400).json({ + success: false, + message: "chatId is required", + }); + } + + if (!deviceId) { + return res.status(400).json({ + success: false, + message: "x-qc-device-id is required for secure chat access", + }); + } + + const result = await getSecureDMMessagePage(models, { + chatId, + userId, + deviceId, + page, + limit, + }); + const io = req.app.get("io"); + for (const event of result.deliveredEvents || []) { + io.to(`user_${event.senderId}`).emit("message_delivered", { + chatId: event.chatId, + messageId: event.messageId, + deliveredAt: event.deliveredAt, + }); + } + + return res.status(200).json({ + success: true, + data: { + messages: result.rows.map((message: any) => ({ + id: message.id, + chatId: message.chatId, + content: "", + encryptedEnvelope: message.encryptedEnvelope, + messageType: message.messageType, + replyToMessageId: message.replyToMessageId, + status: message.status, + deliveredAt: message.deliveredAt, + readAt: message.readAt, + readBy: message.readBy || [], + createdAt: message.createdAt, + sender: { + id: message.senderId, + name: message.sender + ? `${message.sender.firstName || ""} ${message.sender.lastName || ""}`.trim() || + "Unknown" + : "Unknown", + firstName: message.sender?.firstName, + lastName: message.sender?.lastName, + avatar: message.sender?.profile?.profileImage, + }, + })), + pagination: { + page, + limit, + total: result.count, + totalPages: Math.ceil(result.count / limit), + }, + }, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const sendSecureChatMessage = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const { chatId } = req.params; + const deviceId = resolveSecureDeviceId(req); + const { messageType = "text", replyToMessageId, recipientPayloads } = req.body; + + if (!chatId) { + return res.status(400).json({ + success: false, + message: "chatId is required", + }); + } + + if (!deviceId) { + return res.status(400).json({ + success: false, + message: "x-qc-device-id is required for secure message sending", + }); + } + + const message = await sendSecureDMMessage(models, { + chatId, + userId, + senderDeviceId: deviceId, + messageType, + replyToMessageId, + recipientPayloads, + }); + + const io = req.app.get("io"); + const app = req.app; + const senderPayload = { + id: message.senderId, + name: message.get("sender") + ? `${(message.get("sender") as any).firstName || ""} ${ + (message.get("sender") as any).lastName || "" + }`.trim() || "Unknown" + : "Unknown", + firstName: (message.get("sender") as any)?.firstName, + lastName: (message.get("sender") as any)?.lastName, + avatar: (message.get("sender") as any)?.profile?.profileImage, + }; + + const participants = await models.ChatParticipant.findAll({ + where: { chatId }, + attributes: ["userId"], + }); + + for (const participant of participants) { + io.to(`user_${participant.userId}`).emit("secure_message_available", { + chatId, + messageId: message.id, + senderId: message.senderId, + sender: senderPayload, + messageType: message.messageType, + replyToMessageId: message.replyToMessageId || null, + securityMode: "secure_dm_v1", + createdAt: message.createdAt, + }); + + if (participant.userId !== userId) { + await notifyChatMessageReceived( + app, + participant.userId, + chatId, + message.id, + senderPayload.id, + senderPayload.name, + "Secure message", + "secure", + false, + ); + } + } + + return res.status(201).json({ + success: true, + data: { + id: message.id, + chatId: message.chatId, + messageType: message.messageType, + replyToMessageId: message.replyToMessageId, + status: message.status, + createdAt: message.createdAt, + sender: senderPayload, + }, + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const uploadSecureChatMedia = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const { chatId } = req.params; + const deviceId = resolveSecureDeviceId(req); + const file = req.file; + + if (!chatId) { + return res.status(400).json({ success: false, message: "chatId is required" }); + } + + if (!deviceId) { + return res.status(400).json({ + success: false, + message: "x-qc-device-id is required for secure media upload", + }); + } + + if (!file) { + return res.status(400).json({ success: false, message: "Encrypted file is required" }); + } + + await getSecureDMMessagePage(models, { + chatId, + userId, + deviceId, + page: 1, + limit: 1, + }); + + const uploadResult = await uploadEncryptedChatMedia(file); + + if (fs.existsSync(file.path)) { + fs.unlinkSync(file.path); + } + + if (!uploadResult.success || !uploadResult.data) { + return res.status(400).json({ + success: false, + message: uploadResult.error || "Failed to upload encrypted media", + }); + } + + return res.status(201).json({ + success: true, + data: uploadResult.data, + }); + } catch (error: any) { + if (req.file?.path && fs.existsSync(req.file.path)) { + fs.unlinkSync(req.file.path); + } + + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; + +export const markSecureChatAsRead = async ( + req: AuthenticatedRequest, + res: Response, + next: NextFunction, +) => { + try { + const models = req.app.get("models") as ReturnType; + const userId = await ensureAuthenticatedUser(req, models); + const { chatId } = req.params; + const deviceId = resolveSecureDeviceId(req); + + if (!chatId) { + return res.status(400).json({ + success: false, + message: "chatId is required", + }); + } + + if (!deviceId) { + return res.status(400).json({ + success: false, + message: "x-qc-device-id is required for secure read sync", + }); + } + + const readAt = await markSecureChatMessagesAsRead(models, { + chatId, + userId, + deviceId, + }); + + const io = req.app.get("io"); + io.to(`chat_${chatId}`).emit("messages_read", { + chatId, + readBy: userId, + readAt, + }); + + return res.status(200).json({ + success: true, + data: { readAt }, + message: "Secure messages marked as read", + }); + } catch (error: any) { + if (error?.statusCode) { + return res.status(error.statusCode).json({ + success: false, + message: error.message, + }); + } + return next(error); + } +}; diff --git a/src/controllers/supportChat.controller.ts b/src/controllers/supportChat.controller.ts index 11ff2d7..0adac90 100644 --- a/src/controllers/supportChat.controller.ts +++ b/src/controllers/supportChat.controller.ts @@ -42,7 +42,12 @@ export const createOrGetSupportChat: RequestHandler = async (req, res, next) => } const chat = await models.Chat.create( - { isGroup: false, type: "support" } as any, + { + isGroup: false, + type: "support", + securityMode: "support_plain", + protocolVersion: null, + } as any, { transaction: t } ); await models.ChatParticipant.create( diff --git a/src/database/config/db.config.ts b/src/database/config/db.config.ts index caef797..69c022b 100644 --- a/src/database/config/db.config.ts +++ b/src/database/config/db.config.ts @@ -94,13 +94,19 @@ export const connectionToDatabase = async () => { $$; `); - // Sync models with force: true in development to recreate tables - const syncOptions = APP_MODE === 'development' - ? { force: false, alter: false } - : { alter: false }; - - await sequelizeConnection.sync(syncOptions); - console.log("Database sync completed successfully."); + const shouldSkipSync = process.env.SKIP_DB_SYNC === "true"; + + if (shouldSkipSync) { + console.log("Skipping Sequelize sync because SKIP_DB_SYNC=true"); + } else { + // Sync models with force: true in development to recreate tables + const syncOptions = APP_MODE === 'development' + ? { force: false, alter: false } + : { alter: false }; + + await sequelizeConnection.sync(syncOptions); + console.log("Database sync completed successfully."); + } console.log(`Connected to: ${db_uri.split('@')[1]?.split('?')[0]}`); } catch (error) { console.error("Unable to connect to the database:"); @@ -120,4 +126,4 @@ Object.keys(db_models).forEach((key) => { }); const database_models = { ...db_models }; -export default database_models; \ No newline at end of file +export default database_models; diff --git a/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js b/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js new file mode 100644 index 0000000..354c9ba --- /dev/null +++ b/src/database/migrations/20260518000000-add-chat-security-and-e2ee-devices.js @@ -0,0 +1,216 @@ +"use strict"; + +module.exports = { + up: async (queryInterface, Sequelize) => { + await queryInterface.addColumn("Chats", "securityMode", { + type: Sequelize.STRING(32), + allowNull: false, + defaultValue: "legacy", + }); + + await queryInterface.addColumn("Chats", "protocolVersion", { + type: Sequelize.STRING(32), + allowNull: true, + defaultValue: null, + }); + + await queryInterface.sequelize.query( + `UPDATE "Chats" SET "securityMode" = 'support_plain' WHERE "type" = 'support';`, + ); + + await queryInterface.createTable("UserDevices", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + userId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + deviceId: { + type: Sequelize.STRING(128), + allowNull: false, + unique: true, + }, + deviceName: { + type: Sequelize.STRING(128), + allowNull: true, + }, + platform: { + type: Sequelize.STRING(64), + allowNull: true, + }, + appVersion: { + type: Sequelize.STRING(32), + allowNull: true, + }, + isActive: { + type: Sequelize.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + lastSeenAt: { + type: Sequelize.DATE, + allowNull: true, + }, + revokedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.createTable("DeviceKeyBundles", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + userDeviceId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "UserDevices", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + algorithm: { + type: Sequelize.STRING(64), + allowNull: false, + }, + identityPublicKey: { + type: Sequelize.JSONB, + allowNull: false, + }, + signedPreKeyId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + signedPreKeyPublic: { + type: Sequelize.JSONB, + allowNull: false, + }, + signedPreKeySignature: { + type: Sequelize.TEXT, + allowNull: false, + }, + registrationId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + uploadedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.createTable("DeviceOneTimePreKeys", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + userDeviceId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "UserDevices", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + preKeyId: { + type: Sequelize.INTEGER, + allowNull: false, + }, + publicKey: { + type: Sequelize.JSONB, + allowNull: false, + }, + usedAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.NOW, + }, + }); + + await queryInterface.addIndex("Chats", ["securityMode"], { + name: "idx_chats_security_mode", + }); + await queryInterface.addIndex("UserDevices", ["userId", "isActive"], { + name: "idx_user_devices_user_active", + }); + await queryInterface.addIndex("UserDevices", ["deviceId"], { + name: "idx_user_devices_device_id", + unique: true, + }); + await queryInterface.addIndex("DeviceKeyBundles", ["userDeviceId"], { + name: "idx_device_key_bundles_user_device_id", + unique: true, + }); + await queryInterface.addIndex("DeviceOneTimePreKeys", ["userDeviceId", "usedAt"], { + name: "idx_device_one_time_pre_keys_device_used_at", + }); + await queryInterface.addIndex("DeviceOneTimePreKeys", ["userDeviceId", "preKeyId"], { + name: "idx_device_one_time_pre_keys_device_prekey", + unique: true, + }); + }, + + down: async (queryInterface) => { + await queryInterface.removeIndex("DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_prekey"); + await queryInterface.removeIndex("DeviceOneTimePreKeys", "idx_device_one_time_pre_keys_device_used_at"); + await queryInterface.removeIndex("DeviceKeyBundles", "idx_device_key_bundles_user_device_id"); + await queryInterface.removeIndex("UserDevices", "idx_user_devices_device_id"); + await queryInterface.removeIndex("UserDevices", "idx_user_devices_user_active"); + await queryInterface.removeIndex("Chats", "idx_chats_security_mode"); + + await queryInterface.dropTable("DeviceOneTimePreKeys"); + await queryInterface.dropTable("DeviceKeyBundles"); + await queryInterface.dropTable("UserDevices"); + + await queryInterface.removeColumn("Chats", "protocolVersion"); + await queryInterface.removeColumn("Chats", "securityMode"); + }, +}; diff --git a/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js b/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js new file mode 100644 index 0000000..abdbf86 --- /dev/null +++ b/src/database/migrations/20260518010000-create-chat-message-recipient-payloads.js @@ -0,0 +1,105 @@ +"use strict"; + +module.exports = { + async up(queryInterface, Sequelize) { + await queryInterface.createTable("ChatMessageRecipientPayloads", { + id: { + type: Sequelize.UUID, + defaultValue: Sequelize.UUIDV4, + primaryKey: true, + allowNull: false, + }, + chatMessageId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "ChatMessages", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + recipientUserId: { + type: Sequelize.UUID, + allowNull: false, + references: { + model: "Users", + key: "id", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + recipientDeviceId: { + type: Sequelize.STRING(128), + allowNull: false, + references: { + model: "UserDevices", + key: "deviceId", + }, + onUpdate: "CASCADE", + onDelete: "CASCADE", + }, + senderDeviceId: { + type: Sequelize.STRING(128), + allowNull: false, + }, + encryptedEnvelope: { + type: Sequelize.JSONB, + allowNull: false, + }, + deliveredAt: { + type: Sequelize.DATE, + allowNull: true, + }, + readAt: { + type: Sequelize.DATE, + allowNull: true, + }, + createdAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn("NOW"), + }, + updatedAt: { + type: Sequelize.DATE, + allowNull: false, + defaultValue: Sequelize.fn("NOW"), + }, + }); + + await queryInterface.addIndex("ChatMessageRecipientPayloads", ["chatMessageId"], { + name: "chat_message_recipient_payloads_message_idx", + }); + await queryInterface.addIndex( + "ChatMessageRecipientPayloads", + ["recipientUserId", "recipientDeviceId"], + { + name: "chat_message_recipient_payloads_recipient_idx", + }, + ); + await queryInterface.addIndex( + "ChatMessageRecipientPayloads", + ["chatMessageId", "recipientDeviceId"], + { + unique: true, + name: "chat_message_recipient_payloads_message_device_unique", + }, + ); + }, + + async down(queryInterface) { + await queryInterface.removeIndex( + "ChatMessageRecipientPayloads", + "chat_message_recipient_payloads_message_device_unique", + ); + await queryInterface.removeIndex( + "ChatMessageRecipientPayloads", + "chat_message_recipient_payloads_recipient_idx", + ); + await queryInterface.removeIndex( + "ChatMessageRecipientPayloads", + "chat_message_recipient_payloads_message_idx", + ); + await queryInterface.dropTable("ChatMessageRecipientPayloads"); + }, +}; diff --git a/src/database/models/chat.model.ts b/src/database/models/chat.model.ts index 7120cb0..79e1b80 100644 --- a/src/database/models/chat.model.ts +++ b/src/database/models/chat.model.ts @@ -7,6 +7,8 @@ class Chat extends Model { public isGroup!: boolean; public groupId?: string; public type!: string; + public securityMode!: string; + public protocolVersion!: string | null; } const Chat_model = (sequelize: Sequelize) => { @@ -16,6 +18,16 @@ const Chat_model = (sequelize: Sequelize) => { isGroup: { type: DataTypes.BOOLEAN, defaultValue: false }, groupId: DataTypes.UUID, type: { type: DataTypes.STRING(20), defaultValue: 'dm' }, + securityMode: { + type: DataTypes.STRING(32), + allowNull: false, + defaultValue: "legacy", + }, + protocolVersion: { + type: DataTypes.STRING(32), + allowNull: true, + defaultValue: null, + }, }, { sequelize, tableName: "Chats" } ); diff --git a/src/database/models/chatMessageRecipientPayload.model.ts b/src/database/models/chatMessageRecipientPayload.model.ts new file mode 100644 index 0000000..000a6af --- /dev/null +++ b/src/database/models/chatMessageRecipientPayload.model.ts @@ -0,0 +1,82 @@ +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + ChatMessageRecipientPayloadAttributes, + ChatMessageRecipientPayloadCreationAttributes, +} from "../../types/model"; + +class ChatMessageRecipientPayload extends Model< + ChatMessageRecipientPayloadAttributes, + ChatMessageRecipientPayloadCreationAttributes +> { + public id!: string; + public chatMessageId!: string; + public recipientUserId!: string; + public recipientDeviceId!: string; + public senderDeviceId!: string; + public encryptedEnvelope!: Record; + public deliveredAt!: Date | null; + public readAt!: Date | null; +} + +const chatMessageRecipientPayload_model = (sequelize: Sequelize) => { + ChatMessageRecipientPayload.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + chatMessageId: { + type: DataTypes.UUID, + allowNull: false, + }, + recipientUserId: { + type: DataTypes.UUID, + allowNull: false, + }, + recipientDeviceId: { + type: DataTypes.STRING(128), + allowNull: false, + }, + senderDeviceId: { + type: DataTypes.STRING(128), + allowNull: false, + }, + encryptedEnvelope: { + type: DataTypes.JSONB, + allowNull: false, + }, + deliveredAt: { + type: DataTypes.DATE, + allowNull: true, + }, + readAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: "ChatMessageRecipientPayloads", + indexes: [ + { + name: "chat_message_recipient_payloads_message_idx", + fields: ["chatMessageId"], + }, + { + name: "chat_message_recipient_payloads_recipient_idx", + fields: ["recipientUserId", "recipientDeviceId"], + }, + { + name: "chat_message_recipient_payloads_message_device_unique", + unique: true, + fields: ["chatMessageId", "recipientDeviceId"], + }, + ], + }, + ); + + return ChatMessageRecipientPayload; +}; + +export default chatMessageRecipientPayload_model; diff --git a/src/database/models/deviceKeyBundle.model.ts b/src/database/models/deviceKeyBundle.model.ts new file mode 100644 index 0000000..0aa76bd --- /dev/null +++ b/src/database/models/deviceKeyBundle.model.ts @@ -0,0 +1,72 @@ +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + DeviceKeyBundleAttributes, + DeviceKeyBundleCreationAttributes, +} from "../../types/model"; + +class DeviceKeyBundle extends Model< + DeviceKeyBundleAttributes, + DeviceKeyBundleCreationAttributes +> { + public id!: string; + public userDeviceId!: string; + public algorithm!: string; + public identityPublicKey!: Record; + public signedPreKeyId!: number; + public signedPreKeyPublic!: Record; + public signedPreKeySignature!: string; + public registrationId!: number; + public uploadedAt!: Date | null; +} + +const deviceKeyBundle_model = (sequelize: Sequelize) => { + DeviceKeyBundle.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + userDeviceId: { + type: DataTypes.UUID, + allowNull: false, + }, + algorithm: { + type: DataTypes.STRING(64), + allowNull: false, + }, + identityPublicKey: { + type: DataTypes.JSONB, + allowNull: false, + }, + signedPreKeyId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + signedPreKeyPublic: { + type: DataTypes.JSONB, + allowNull: false, + }, + signedPreKeySignature: { + type: DataTypes.TEXT, + allowNull: false, + }, + registrationId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + uploadedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: "DeviceKeyBundles", + }, + ); + + return DeviceKeyBundle; +}; + +export default deviceKeyBundle_model; diff --git a/src/database/models/deviceOneTimePreKey.model.ts b/src/database/models/deviceOneTimePreKey.model.ts new file mode 100644 index 0000000..9bcaac0 --- /dev/null +++ b/src/database/models/deviceOneTimePreKey.model.ts @@ -0,0 +1,52 @@ +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + DeviceOneTimePreKeyAttributes, + DeviceOneTimePreKeyCreationAttributes, +} from "../../types/model"; + +class DeviceOneTimePreKey extends Model< + DeviceOneTimePreKeyAttributes, + DeviceOneTimePreKeyCreationAttributes +> { + public id!: string; + public userDeviceId!: string; + public preKeyId!: number; + public publicKey!: Record; + public usedAt!: Date | null; +} + +const deviceOneTimePreKey_model = (sequelize: Sequelize) => { + DeviceOneTimePreKey.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + userDeviceId: { + type: DataTypes.UUID, + allowNull: false, + }, + preKeyId: { + type: DataTypes.INTEGER, + allowNull: false, + }, + publicKey: { + type: DataTypes.JSONB, + allowNull: false, + }, + usedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: "DeviceOneTimePreKeys", + }, + ); + + return DeviceOneTimePreKey; +}; + +export default deviceOneTimePreKey_model; diff --git a/src/database/models/index.ts b/src/database/models/index.ts index c42ebd3..2e80387 100644 --- a/src/database/models/index.ts +++ b/src/database/models/index.ts @@ -20,6 +20,10 @@ import ContactInvitation_model from "./contactInvitations.model"; import notification_model from "./notification.model"; import pushSubscription_model from "./pushSubscription.model"; import deviceSession_model from "./deviceSession.model"; +import userDevice_model from "./userDevice.model"; +import deviceKeyBundle_model from "./deviceKeyBundle.model"; +import deviceOneTimePreKey_model from "./deviceOneTimePreKey.model"; +import chatMessageRecipientPayload_model from "./chatMessageRecipientPayload.model"; import role_model from "./role.model"; import permission_model from "./permission.model"; import rolePermission_model from "./rolePermission.model"; @@ -64,6 +68,10 @@ const Models = (sequelize: Sequelize) => { const Notification = notification_model(sequelize); const PushSubscription = pushSubscription_model(sequelize); const DeviceSession = deviceSession_model(sequelize); + const UserDevice = userDevice_model(sequelize); + const DeviceKeyBundle = deviceKeyBundle_model(sequelize); + const DeviceOneTimePreKey = deviceOneTimePreKey_model(sequelize); + const ChatMessageRecipientPayload = chatMessageRecipientPayload_model(sequelize); const Role = role_model(sequelize); const Permission = permission_model(sequelize); @@ -354,6 +362,22 @@ const Models = (sequelize: Sequelize) => { // Message Reactions ChatMessage.hasMany(MessageReaction, { foreignKey: "messageId", as: "reactions" }); MessageReaction.belongsTo(ChatMessage, { foreignKey: "messageId", as: "message" }); + ChatMessage.hasMany(ChatMessageRecipientPayload, { + foreignKey: "chatMessageId", + as: "recipientPayloads", + }); + ChatMessageRecipientPayload.belongsTo(ChatMessage, { + foreignKey: "chatMessageId", + as: "message", + }); + User.hasMany(ChatMessageRecipientPayload, { + foreignKey: "recipientUserId", + as: "secureMessagePayloads", + }); + ChatMessageRecipientPayload.belongsTo(User, { + foreignKey: "recipientUserId", + as: "recipientUser", + }); User.hasMany(MessageReaction, { foreignKey: "userId", as: "messageReactions" }); MessageReaction.belongsTo(User, { foreignKey: "userId", as: "user" }); @@ -377,6 +401,11 @@ const Models = (sequelize: Sequelize) => { as: "pushSubscriptions", }); PushSubscription.belongsTo(User, { foreignKey: "userId", as: "user" }); + User.hasMany(UserDevice, { + foreignKey: "userId", + as: "secureDevices", + }); + UserDevice.belongsTo(User, { foreignKey: "userId", as: "user" }); User.hasMany(DeviceSession, { foreignKey: "userId", as: "deviceSessions", @@ -390,6 +419,22 @@ const Models = (sequelize: Sequelize) => { foreignKey: "organizationId", as: "organization", }); + UserDevice.hasOne(DeviceKeyBundle, { + foreignKey: "userDeviceId", + as: "keyBundle", + }); + DeviceKeyBundle.belongsTo(UserDevice, { + foreignKey: "userDeviceId", + as: "device", + }); + UserDevice.hasMany(DeviceOneTimePreKey, { + foreignKey: "userDeviceId", + as: "oneTimePreKeys", + }); + DeviceOneTimePreKey.belongsTo(UserDevice, { + foreignKey: "userDeviceId", + as: "device", + }); // External Accounts User.hasMany(ExternalAccount, { @@ -561,6 +606,10 @@ const Models = (sequelize: Sequelize) => { Notification, PushSubscription, DeviceSession, + UserDevice, + DeviceKeyBundle, + DeviceOneTimePreKey, + ChatMessageRecipientPayload, Role, Permission, RolePermission, diff --git a/src/database/models/userDevice.model.ts b/src/database/models/userDevice.model.ts new file mode 100644 index 0000000..78c32e0 --- /dev/null +++ b/src/database/models/userDevice.model.ts @@ -0,0 +1,71 @@ +import { DataTypes, Model, Sequelize, UUIDV4 } from "sequelize"; +import { + UserDeviceAttributes, + UserDeviceCreationAttributes, +} from "../../types/model"; + +class UserDevice extends Model { + public id!: string; + public userId!: string; + public deviceId!: string; + public deviceName!: string | null; + public platform!: string | null; + public appVersion!: string | null; + public isActive!: boolean; + public lastSeenAt!: Date | null; + public revokedAt!: Date | null; +} + +const userDevice_model = (sequelize: Sequelize) => { + UserDevice.init( + { + id: { + type: DataTypes.UUID, + defaultValue: UUIDV4, + primaryKey: true, + }, + userId: { + type: DataTypes.UUID, + allowNull: false, + }, + deviceId: { + type: DataTypes.STRING(128), + allowNull: false, + unique: true, + }, + deviceName: { + type: DataTypes.STRING(128), + allowNull: true, + }, + platform: { + type: DataTypes.STRING(64), + allowNull: true, + }, + appVersion: { + type: DataTypes.STRING(32), + allowNull: true, + }, + isActive: { + type: DataTypes.BOOLEAN, + allowNull: false, + defaultValue: true, + }, + lastSeenAt: { + type: DataTypes.DATE, + allowNull: true, + }, + revokedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + }, + { + sequelize, + tableName: "UserDevices", + }, + ); + + return UserDevice; +}; + +export default userDevice_model; diff --git a/src/routes/e2ee.routes.ts b/src/routes/e2ee.routes.ts new file mode 100644 index 0000000..cf168fe --- /dev/null +++ b/src/routes/e2ee.routes.ts @@ -0,0 +1,51 @@ +import { RequestHandler, Router } from "express"; +import { authenticate } from "../middleware/auth.middleware"; +import multer from "multer"; +import { + addMySecureDeviceOneTimePreKeys, + createOrGetSecureDM, + getMySecureDevices, + getPublicDeviceBundlesForUser, + getSecureChatMessages, + markSecureChatAsRead, + registerSecureDevice, + rotateMySecureDeviceSignedPreKey, + revokeMySecureDevice, + sendSecureChatMessage, + uploadSecureChatMedia, +} from "../controllers/e2ee.controller"; + +const router = Router(); +const secureMediaUpload = multer({ + storage: multer.diskStorage({}), + limits: { + fileSize: 110 * 1024 * 1024, + files: 1, + }, +}); + +router.use(authenticate as RequestHandler); + +router.get("/devices", getMySecureDevices as RequestHandler); +router.post("/devices/register", registerSecureDevice as RequestHandler); +router.patch( + "/devices/:deviceId/signed-prekey", + rotateMySecureDeviceSignedPreKey as RequestHandler, +); +router.post( + "/devices/:deviceId/one-time-prekeys", + addMySecureDeviceOneTimePreKeys as RequestHandler, +); +router.delete("/devices/:deviceId", revokeMySecureDevice as RequestHandler); +router.post("/dms", createOrGetSecureDM as RequestHandler); +router.get("/users/:userId/device-bundles", getPublicDeviceBundlesForUser as RequestHandler); +router.get("/chats/:chatId/messages", getSecureChatMessages as RequestHandler); +router.post("/chats/:chatId/messages", sendSecureChatMessage as RequestHandler); +router.post( + "/chats/:chatId/media", + secureMediaUpload.single("file"), + uploadSecureChatMedia as RequestHandler, +); +router.post("/chats/:chatId/read", markSecureChatAsRead as RequestHandler); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index dc607bf..a81e927 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -26,6 +26,7 @@ import fcmRouter from "./fcm.routes"; import pushSubscriptionRouter from "./pushSubscription.routes"; import supportRouter from "./support.routes"; import linkPreviewRouter from "./link-preview.routes"; +import e2eeRouter from "./e2ee.routes"; // Admin routes (unified authentication with role/permission middleware) import adminAuthRouter from "./admin.auth.routes"; @@ -63,9 +64,10 @@ router.use("/chats", chatRouter); router.use("/support", supportRouter); router.use("/outside-messages", outsideMessageRouter); router.use("/fcm", fcmRouter); -router.use("/push-subscriptions", pushSubscriptionRouter); -router.use("/link-preview", linkPreviewRouter); -router.use("", actionRouter); + router.use("/push-subscriptions", pushSubscriptionRouter); + router.use("/link-preview", linkPreviewRouter); + router.use("/e2ee", e2eeRouter); + router.use("", actionRouter); // Role and Permission management (admin-only with middleware) router.use("/roles", roleRouter); diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 8261968..d74df28 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -91,7 +91,9 @@ export class ChatService { // Create new chat const chat = await models.Chat.create({ isGroup, - createdBy: participantIds[0] + createdBy: participantIds[0], + securityMode: "legacy", + protocolVersion: null, }); // Add participants @@ -180,6 +182,14 @@ export class ChatService { replyToMessageId?: string ) { try { + const chatRecord = await models.Chat.findByPk(chatId, { + attributes: ["id", "securityMode"], + }); + + if (chatRecord?.securityMode === "secure_dm_v1") { + throw new Error("Legacy message sending is disabled for secure conversations"); + } + // Get chat key for encryption let chatKeyRecord = await models.ChatKey.findOne({ where: { chatId, userId: senderId } diff --git a/src/services/e2eeDevice.service.ts b/src/services/e2eeDevice.service.ts new file mode 100644 index 0000000..da76729 --- /dev/null +++ b/src/services/e2eeDevice.service.ts @@ -0,0 +1,546 @@ +import { Op } from "sequelize"; +import { webcrypto } from "crypto"; + +const SUPPORTED_E2EE_ALGORITHMS = new Set(["qc-e2ee-p256-v1"]); +const MAX_DB_SAFE_PREKEY_ID = 2_147_483_646; + +const decodeBase64Url = (value: string) => { + const normalized = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = normalized.padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return Buffer.from(padded, "base64"); +}; + +const stableStringify = (value: unknown): string => { + if (value === null || typeof value !== "object") { + return JSON.stringify(value); + } + + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(",")}]`; + } + + const entries = Object.entries(value as Record).sort(([a], [b]) => + a.localeCompare(b), + ); + + return `{${entries + .map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`) + .join(",")}}`; +}; + +const isValidP256Coordinate = (value: unknown) => { + if (typeof value !== "string" || value.length < 40) { + return false; + } + + try { + return decodeBase64Url(value).length === 32; + } catch { + return false; + } +}; + +const isValidP256PublicJwk = (value: unknown) => { + if (!value || typeof value !== "object") { + return false; + } + + const jwk = value as Record; + return ( + jwk.kty === "EC" && + jwk.crv === "P-256" && + isValidP256Coordinate(jwk.x) && + isValidP256Coordinate(jwk.y) + ); +}; + +const hasValidSecureBundleShape = (device: any) => + isValidP256PublicJwk(device?.keyBundle?.identityPublicKey) && + isValidP256PublicJwk(device?.keyBundle?.signedPreKeyPublic) && + typeof device?.keyBundle?.signedPreKeySignature === "string" && + device.keyBundle.signedPreKeySignature.length > 20; + +type RegisterDeviceInput = { + deviceId: string; + deviceName?: string | null; + platform?: string | null; + appVersion?: string | null; + bundle: { + algorithm: string; + identityPublicKey: Record; + signedPreKey: { + keyId: number; + publicKey: Record; + signature: string; + }; + registrationId: number; + oneTimePreKeys: Array<{ + keyId: number; + publicKey: Record; + }>; + }; +}; + +const verifySignedPreKeySignature = async ({ + identityPublicKey, + signedPreKeyPublic, + signature, +}: { + identityPublicKey: Record; + signedPreKeyPublic: Record; + signature: string; +}) => { + try { + const key = await webcrypto.subtle.importKey( + "jwk", + identityPublicKey as JsonWebKey, + { + name: "ECDSA", + namedCurve: "P-256", + }, + false, + ["verify"], + ); + + return webcrypto.subtle.verify( + { + name: "ECDSA", + hash: "SHA-256", + }, + key, + decodeBase64Url(signature), + new TextEncoder().encode(stableStringify(signedPreKeyPublic)), + ); + } catch { + return false; + } +}; + +const assertValidBundle = async (input: RegisterDeviceInput) => { + if (!input.deviceId || input.deviceId.length < 12 || input.deviceId.length > 128) { + throw Object.assign(new Error("A valid deviceId is required"), { statusCode: 400 }); + } + + if (!SUPPORTED_E2EE_ALGORITHMS.has(input.bundle?.algorithm)) { + throw Object.assign(new Error("Unsupported E2EE algorithm"), { statusCode: 400 }); + } + + if (!input.bundle?.identityPublicKey || !input.bundle?.signedPreKey?.publicKey) { + throw Object.assign(new Error("A valid device key bundle is required"), { statusCode: 400 }); + } + + if ( + !isValidP256PublicJwk(input.bundle.identityPublicKey) || + !isValidP256PublicJwk(input.bundle.signedPreKey.publicKey) + ) { + throw Object.assign(new Error("Device bundle contains an invalid P-256 public key"), { + statusCode: 400, + }); + } + + if ( + !Number.isInteger(input.bundle.signedPreKey.keyId) || + input.bundle.signedPreKey.keyId <= 0 || + input.bundle.signedPreKey.keyId > MAX_DB_SAFE_PREKEY_ID || + !Number.isInteger(input.bundle.registrationId) + ) { + throw Object.assign(new Error("Invalid signed pre-key metadata"), { statusCode: 400 }); + } + + if (!Array.isArray(input.bundle.oneTimePreKeys) || input.bundle.oneTimePreKeys.length === 0) { + throw Object.assign(new Error("At least one one-time pre-key is required"), { statusCode: 400 }); + } + + if (input.bundle.oneTimePreKeys.length > 100) { + throw Object.assign(new Error("Too many one-time pre-keys submitted"), { statusCode: 400 }); + } + + assertValidOneTimePreKeys(input.bundle.oneTimePreKeys); + + const validSignature = await verifySignedPreKeySignature({ + identityPublicKey: input.bundle.identityPublicKey, + signedPreKeyPublic: input.bundle.signedPreKey.publicKey, + signature: input.bundle.signedPreKey.signature, + }); + + if (!validSignature) { + throw Object.assign(new Error("Invalid signed pre-key signature"), { statusCode: 400 }); + } +}; + +const assertValidOneTimePreKeys = ( + oneTimePreKeys: Array<{ keyId: number; publicKey: Record }>, +) => { + const seenIds = new Set(); + + for (const preKey of oneTimePreKeys) { + if ( + !Number.isInteger(preKey?.keyId) || + preKey.keyId <= 0 || + preKey.keyId > MAX_DB_SAFE_PREKEY_ID + ) { + throw Object.assign(new Error("Invalid one-time pre-key metadata"), { statusCode: 400 }); + } + + if (seenIds.has(preKey.keyId)) { + throw Object.assign(new Error("Duplicate one-time pre-key id"), { statusCode: 400 }); + } + + if (!isValidP256PublicJwk(preKey.publicKey)) { + throw Object.assign(new Error("One-time pre-key contains an invalid P-256 public key"), { + statusCode: 400, + }); + } + + seenIds.add(preKey.keyId); + } +}; + +const assertValidSignedPreKey = (signedPreKey: { + keyId: number; + publicKey: Record; + signature: string; +}) => { + if ( + !Number.isInteger(signedPreKey?.keyId) || + signedPreKey.keyId <= 0 || + signedPreKey.keyId > MAX_DB_SAFE_PREKEY_ID + ) { + throw Object.assign(new Error("Invalid signed pre-key metadata"), { statusCode: 400 }); + } + + if (!isValidP256PublicJwk(signedPreKey.publicKey)) { + throw Object.assign(new Error("Signed pre-key contains an invalid P-256 public key"), { + statusCode: 400, + }); + } + + if (typeof signedPreKey.signature !== "string" || signedPreKey.signature.length <= 20) { + throw Object.assign(new Error("Invalid signed pre-key signature"), { statusCode: 400 }); + } +}; + +export const upsertUserDeviceBundle = async ( + models: any, + userId: string, + input: RegisterDeviceInput, +) => { + await assertValidBundle(input); + + const existingForDeviceId = await models.UserDevice.findOne({ + where: { deviceId: input.deviceId }, + }); + + if (existingForDeviceId && existingForDeviceId.userId !== userId) { + throw Object.assign( + new Error("This device identity is already bound to another account"), + { statusCode: 409 }, + ); + } + + const [device] = await models.UserDevice.findOrCreate({ + where: { deviceId: input.deviceId }, + defaults: { + userId, + deviceId: input.deviceId, + deviceName: input.deviceName || null, + platform: input.platform || null, + appVersion: input.appVersion || null, + isActive: true, + lastSeenAt: new Date(), + revokedAt: null, + }, + }); + + await device.update({ + userId, + deviceName: input.deviceName || device.deviceName || null, + platform: input.platform || device.platform || null, + appVersion: input.appVersion || device.appVersion || null, + isActive: true, + lastSeenAt: new Date(), + revokedAt: null, + }); + + const [bundle] = await models.DeviceKeyBundle.findOrCreate({ + where: { userDeviceId: device.id }, + defaults: { + userDeviceId: device.id, + algorithm: input.bundle.algorithm, + identityPublicKey: input.bundle.identityPublicKey, + signedPreKeyId: input.bundle.signedPreKey.keyId, + signedPreKeyPublic: input.bundle.signedPreKey.publicKey, + signedPreKeySignature: input.bundle.signedPreKey.signature, + registrationId: input.bundle.registrationId, + uploadedAt: new Date(), + }, + }); + + await bundle.update({ + algorithm: input.bundle.algorithm, + identityPublicKey: input.bundle.identityPublicKey, + signedPreKeyId: input.bundle.signedPreKey.keyId, + signedPreKeyPublic: input.bundle.signedPreKey.publicKey, + signedPreKeySignature: input.bundle.signedPreKey.signature, + registrationId: input.bundle.registrationId, + uploadedAt: new Date(), + }); + + await models.DeviceOneTimePreKey.destroy({ + where: { + userDeviceId: device.id, + usedAt: null, + }, + }); + + await models.DeviceOneTimePreKey.bulkCreate( + input.bundle.oneTimePreKeys.map((preKey) => ({ + userDeviceId: device.id, + preKeyId: preKey.keyId, + publicKey: preKey.publicKey, + usedAt: null, + })), + ); + + return { + device, + bundle, + availableOneTimePreKeys: input.bundle.oneTimePreKeys.length, + }; +}; + +export const listUserDevices = async (models: any, userId: string) => { + return models.UserDevice.findAll({ + where: { userId }, + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: false, + }, + { + model: models.DeviceOneTimePreKey, + as: "oneTimePreKeys", + required: false, + where: { + usedAt: null, + }, + }, + ], + order: [["createdAt", "ASC"]], + }); +}; + +export const revokeUserDevice = async ( + models: any, + userId: string, + deviceId: string, +) => { + if (!deviceId) { + throw Object.assign(new Error("deviceId is required"), { statusCode: 400 }); + } + + const device = await models.UserDevice.findOne({ + where: { + userId, + deviceId, + isActive: true, + revokedAt: null, + }, + }); + + if (!device) { + throw Object.assign(new Error("Active secure device not found"), { statusCode: 404 }); + } + + const revokedAt = new Date(); + await device.update({ + isActive: false, + revokedAt, + lastSeenAt: revokedAt, + }); + + await models.DeviceOneTimePreKey.destroy({ + where: { + userDeviceId: device.id, + usedAt: null, + }, + }); + + return { + deviceId: device.deviceId, + revokedAt, + }; +}; + +const getOwnedActiveDevice = async (models: any, userId: string, deviceId: string) => { + if (!deviceId) { + throw Object.assign(new Error("deviceId is required"), { statusCode: 400 }); + } + + const device = await models.UserDevice.findOne({ + where: { + userId, + deviceId, + isActive: true, + revokedAt: null, + }, + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: true, + }, + ], + }); + + if (!device) { + throw Object.assign(new Error("Active secure device not found"), { statusCode: 404 }); + } + + return device; +}; + +export const rotateUserDeviceSignedPreKey = async ( + models: any, + userId: string, + deviceId: string, + signedPreKey: { + keyId: number; + publicKey: Record; + signature: string; + }, +) => { + assertValidSignedPreKey(signedPreKey); + const device = await getOwnedActiveDevice(models, userId, deviceId); + const validSignature = await verifySignedPreKeySignature({ + identityPublicKey: device.keyBundle.identityPublicKey, + signedPreKeyPublic: signedPreKey.publicKey, + signature: signedPreKey.signature, + }); + + if (!validSignature) { + throw Object.assign(new Error("Invalid signed pre-key signature"), { statusCode: 400 }); + } + + await device.keyBundle.update({ + signedPreKeyId: signedPreKey.keyId, + signedPreKeyPublic: signedPreKey.publicKey, + signedPreKeySignature: signedPreKey.signature, + uploadedAt: new Date(), + }); + + await device.update({ + lastSeenAt: new Date(), + }); + + return { + deviceId: device.deviceId, + signedPreKeyId: signedPreKey.keyId, + uploadedAt: device.keyBundle.uploadedAt, + }; +}; + +export const appendUserDeviceOneTimePreKeys = async ( + models: any, + userId: string, + deviceId: string, + oneTimePreKeys: Array<{ keyId: number; publicKey: Record }>, +) => { + if (!Array.isArray(oneTimePreKeys) || oneTimePreKeys.length === 0) { + throw Object.assign(new Error("At least one one-time pre-key is required"), { statusCode: 400 }); + } + + if (oneTimePreKeys.length > 100) { + throw Object.assign(new Error("Too many one-time pre-keys submitted"), { statusCode: 400 }); + } + + assertValidOneTimePreKeys(oneTimePreKeys); + const device = await getOwnedActiveDevice(models, userId, deviceId); + const preKeyIds = oneTimePreKeys.map((preKey) => preKey.keyId); + const existing = await models.DeviceOneTimePreKey.findAll({ + where: { + userDeviceId: device.id, + preKeyId: { [Op.in]: preKeyIds }, + }, + }); + + if (existing.length > 0) { + throw Object.assign(new Error("One-time pre-key id already exists for this device"), { + statusCode: 409, + }); + } + + await models.DeviceOneTimePreKey.bulkCreate( + oneTimePreKeys.map((preKey) => ({ + userDeviceId: device.id, + preKeyId: preKey.keyId, + publicKey: preKey.publicKey, + usedAt: null, + })), + ); + + await device.update({ + lastSeenAt: new Date(), + }); + + return { + deviceId: device.deviceId, + addedOneTimePreKeys: oneTimePreKeys.length, + }; +}; + +const hasActiveContactBetween = async (models: any, requesterId: string, targetUserId: string) => { + const contact = await models.Contact.findOne({ + where: { + status: "active", + [Op.or]: [ + { userAId: requesterId, userBId: targetUserId }, + { userAId: targetUserId, userBId: requesterId }, + ], + }, + }); + + return Boolean(contact); +}; + +export const getUserDeviceBundles = async ( + models: any, + requesterId: string, + targetUserId: string, +) => { + const allowed = + requesterId === targetUserId || + (await hasActiveContactBetween(models, requesterId, targetUserId)); + + if (!allowed) { + throw Object.assign( + new Error("You can only fetch secure device bundles for yourself or active contacts"), + { statusCode: 403 }, + ); + } + + const devices = await models.UserDevice.findAll({ + where: { + userId: targetUserId, + isActive: true, + revokedAt: null, + }, + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: true, + }, + { + model: models.DeviceOneTimePreKey, + as: "oneTimePreKeys", + required: false, + where: { usedAt: null }, + }, + ], + order: [["createdAt", "ASC"]], + }); + + return devices.filter((device: any) => hasValidSecureBundleShape(device)); +}; diff --git a/src/services/e2eeMessage.service.ts b/src/services/e2eeMessage.service.ts new file mode 100644 index 0000000..b3d2856 --- /dev/null +++ b/src/services/e2eeMessage.service.ts @@ -0,0 +1,833 @@ +import { Op } from "sequelize"; +import { sequelizeConnection } from "../database/config/db.config"; + +const SECURE_DM_PROTOCOL_VERSION = "secure-dm-v1"; +const SECURE_MESSAGE_PLACEHOLDER = "Secure message"; + +type SecureRecipientPayloadInput = { + recipientUserId: string; + recipientDeviceId: string; + encryptedEnvelope: Record; +}; + +type SecureMessageType = "text" | "image" | "file" | "audio" | "video" | "document"; +type SecureEnvelopeInput = Record; + +const assertSecureUserDevice = async ( + models: any, + userId: string, + deviceId: string, +) => { + const device = await models.UserDevice.findOne({ + where: { + userId, + deviceId, + isActive: true, + revokedAt: null, + }, + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: true, + }, + { + model: models.DeviceOneTimePreKey, + as: "oneTimePreKeys", + required: false, + where: { usedAt: null }, + }, + ], + }); + + if (!device) { + throw Object.assign(new Error("A registered secure device is required"), { + statusCode: 400, + }); + } + + return device; +}; + +const assertSecureChatParticipant = async ( + models: any, + chatId: string, + userId: string, +) => { + const participant = await models.ChatParticipant.findOne({ + where: { chatId, userId }, + }); + + if (!participant) { + throw Object.assign(new Error("You are not authorized for this chat"), { + statusCode: 403, + }); + } + + const chat = await models.Chat.findByPk(chatId); + if (!chat) { + throw Object.assign(new Error("Chat not found"), { statusCode: 404 }); + } + + if (chat.securityMode !== "secure_dm_v1") { + throw Object.assign( + new Error("This chat is not using secure_dm_v1"), + { statusCode: 400 }, + ); + } + + if (chat.isGroup || chat.type === "support") { + throw Object.assign( + new Error("secure_dm_v1 currently supports direct messages only"), + { statusCode: 400 }, + ); + } + + return chat; +}; + +export const supportsSecureDmBetweenUsers = async ( + models: any, + userIds: string[], +) => { + const rows = await models.UserDevice.findAll({ + where: { + userId: { [Op.in]: userIds }, + isActive: true, + revokedAt: null, + }, + attributes: ["userId"], + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: true, + attributes: ["id"], + }, + ], + }); + + const capableUserIds = new Set(rows.map((row: any) => row.userId)); + return userIds.every((userId) => capableUserIds.has(userId)); +}; + +export const createOrGetSecureDMChat = async ( + models: any, + { + userId, + participantId, + }: { + userId: string; + participantId: string; + }, +) => { + if (!participantId) { + throw Object.assign(new Error("Participant ID is required"), { + statusCode: 400, + }); + } + + if (participantId === userId) { + throw Object.assign(new Error("Cannot create a secure chat with yourself"), { + statusCode: 400, + }); + } + + const otherUser = await models.User.findByPk(participantId, { + attributes: ["id"], + }); + + if (!otherUser) { + throw Object.assign(new Error("User not found"), { + statusCode: 404, + }); + } + + const contactRelation = await models.Contact.findOne({ + where: { + [Op.or]: [ + { userAId: userId, userBId: participantId }, + { userAId: participantId, userBId: userId }, + ], + status: "active", + }, + }); + + if (!contactRelation) { + throw Object.assign( + new Error("You can only start secure chats with your contacts"), + { + statusCode: 403, + }, + ); + } + + const canUseSecureDm = await supportsSecureDmBetweenUsers(models, [ + userId, + participantId, + ]); + + if (!canUseSecureDm) { + throw Object.assign( + new Error("Both users need at least one registered secure device"), + { + statusCode: 400, + }, + ); + } + + const userChats = await models.ChatParticipant.findAll({ + where: { userId }, + attributes: ["chatId"], + include: [ + { + model: models.Chat, + as: "chat", + where: { + isGroup: false, + securityMode: "secure_dm_v1", + }, + }, + ], + }); + + const participantChats = await models.ChatParticipant.findAll({ + where: { userId: participantId }, + attributes: ["chatId"], + include: [ + { + model: models.Chat, + as: "chat", + where: { + isGroup: false, + securityMode: "secure_dm_v1", + }, + }, + ], + }); + + const userChatIds = new Set(userChats.map((row: any) => row.chatId)); + const commonChatIds = participantChats + .map((row: any) => row.chatId) + .filter((chatId: string) => userChatIds.has(chatId)); + + for (const chatId of commonChatIds) { + const participantCount = await models.ChatParticipant.count({ + where: { chatId }, + }); + + if (participantCount !== 2) { + continue; + } + + const existingChat = await models.Chat.findByPk(chatId); + if (existingChat?.securityMode === "secure_dm_v1") { + return { + chat: existingChat, + created: false, + }; + } + } + + const newChat = await sequelizeConnection.transaction(async (transaction) => { + const chat = await models.Chat.create( + { + isGroup: false, + securityMode: "secure_dm_v1", + protocolVersion: SECURE_DM_PROTOCOL_VERSION, + }, + { transaction }, + ); + + await models.ChatParticipant.bulkCreate( + [ + { + chatId: chat.id, + userId, + joinedAt: new Date(), + }, + { + chatId: chat.id, + userId: participantId, + joinedAt: new Date(), + }, + ], + { transaction }, + ); + + return chat; + }); + + return { + chat: newChat, + created: true, + }; +}; + +const listSecureDevicesForChat = async (models: any, chatId: string) => { + const participants = await models.ChatParticipant.findAll({ + where: { chatId }, + attributes: ["userId"], + }); + + const participantUserIds = participants.map((participant: any) => participant.userId); + const devices = await models.UserDevice.findAll({ + where: { + userId: { [Op.in]: participantUserIds }, + isActive: true, + revokedAt: null, + }, + include: [ + { + model: models.DeviceKeyBundle, + as: "keyBundle", + required: true, + }, + { + model: models.DeviceOneTimePreKey, + as: "oneTimePreKeys", + required: false, + where: { usedAt: null }, + }, + ], + }); + + return { + participantUserIds, + devices, + }; +}; + +const assertValidSecureEnvelope = ({ + envelope, + senderUserId, + senderDeviceId, + recipientUserId, + recipientDeviceId, +}: { + envelope: SecureEnvelopeInput; + senderUserId: string; + senderDeviceId: string; + recipientUserId: string; + recipientDeviceId: string; +}) => { + const requiredStringFields = [ + "protocolVersion", + "algorithm", + "senderUserId", + "senderDeviceId", + "recipientUserId", + "recipientDeviceId", + "wrappedMessageKey", + "wrappedMessageKeyIv", + "ciphertext", + "ciphertextIv", + "createdAt", + "signature", + ]; + + if (envelope.version !== 1) { + throw Object.assign(new Error("Unsupported secure envelope version"), { statusCode: 400 }); + } + + for (const field of requiredStringFields) { + if (typeof envelope[field] !== "string" || envelope[field].length === 0) { + throw Object.assign(new Error("Malformed secure envelope submitted"), { statusCode: 400 }); + } + } + + if ( + envelope.protocolVersion !== SECURE_DM_PROTOCOL_VERSION || + envelope.algorithm !== "qc-e2ee-p256-v1" + ) { + throw Object.assign(new Error("Unsupported secure envelope protocol"), { statusCode: 400 }); + } + + if (envelope.senderUserId !== senderUserId || envelope.senderDeviceId !== senderDeviceId) { + throw Object.assign(new Error("Secure envelope sender identity does not match request"), { + statusCode: 400, + }); + } + + if ( + envelope.recipientUserId !== recipientUserId || + envelope.recipientDeviceId !== recipientDeviceId + ) { + throw Object.assign(new Error("Secure envelope recipient identity does not match payload"), { + statusCode: 400, + }); + } + + if (!envelope.ephemeralPublicKey || typeof envelope.ephemeralPublicKey !== "object") { + throw Object.assign(new Error("Malformed secure envelope submitted"), { statusCode: 400 }); + } + + if ( + envelope.recipientOneTimePreKeyId !== undefined && + envelope.recipientOneTimePreKeyId !== null && + !Number.isInteger(envelope.recipientOneTimePreKeyId) + ) { + throw Object.assign(new Error("Invalid one-time pre-key metadata"), { statusCode: 400 }); + } +}; + +export const sendSecureDMMessage = async ( + models: any, + { + chatId, + userId, + senderDeviceId, + messageType, + replyToMessageId, + recipientPayloads, + }: { + chatId: string; + userId: string; + senderDeviceId: string; + messageType: SecureMessageType; + replyToMessageId?: string | null; + recipientPayloads: SecureRecipientPayloadInput[]; + }, +) => { + if (!["text", "image", "file", "audio", "video", "document"].includes(messageType)) { + throw Object.assign( + new Error("Unsupported secure message type"), + { statusCode: 400 }, + ); + } + + if (!Array.isArray(recipientPayloads) || recipientPayloads.length === 0) { + throw Object.assign(new Error("At least one encrypted payload is required"), { + statusCode: 400, + }); + } + + await assertSecureChatParticipant(models, chatId, userId); + await assertSecureUserDevice(models, userId, senderDeviceId); + + const { participantUserIds, devices } = await listSecureDevicesForChat(models, chatId); + const allowedDevicePairs = new Map(); + + for (const device of devices) { + allowedDevicePairs.set(`${device.userId}:${device.deviceId}`, device); + } + + const uniquePairs = new Set(); + const oneTimePreKeyUsages: Array<{ userDeviceId: string; preKeyId: number }> = []; + let containsOtherParticipantPayload = false; + + for (const payload of recipientPayloads) { + if ( + !payload?.recipientUserId || + !payload?.recipientDeviceId || + !payload?.encryptedEnvelope || + typeof payload.encryptedEnvelope !== "object" + ) { + throw Object.assign(new Error("Malformed encrypted payload submitted"), { + statusCode: 400, + }); + } + + assertValidSecureEnvelope({ + envelope: payload.encryptedEnvelope, + senderUserId: userId, + senderDeviceId, + recipientUserId: payload.recipientUserId, + recipientDeviceId: payload.recipientDeviceId, + }); + + const pairKey = `${payload.recipientUserId}:${payload.recipientDeviceId}`; + const targetDevice = allowedDevicePairs.get(pairKey); + if (!targetDevice) { + throw Object.assign( + new Error("Encrypted payload targets a device outside this secure chat"), + { statusCode: 400 }, + ); + } + + if (uniquePairs.has(pairKey)) { + throw Object.assign( + new Error("Duplicate encrypted payload submitted for the same device"), + { statusCode: 400 }, + ); + } + + uniquePairs.add(pairKey); + + const recipientOneTimePreKeyId = payload.encryptedEnvelope.recipientOneTimePreKeyId; + if (recipientOneTimePreKeyId !== undefined && recipientOneTimePreKeyId !== null) { + if (!Number.isInteger(recipientOneTimePreKeyId)) { + throw Object.assign(new Error("Invalid one-time pre-key metadata"), { + statusCode: 400, + }); + } + + const availableOneTimePreKeys = Array.isArray(targetDevice.oneTimePreKeys) + ? targetDevice.oneTimePreKeys + : []; + const matchingPreKey = availableOneTimePreKeys.find( + (preKey: any) => preKey.preKeyId === recipientOneTimePreKeyId, + ); + + if (!matchingPreKey) { + throw Object.assign( + new Error("Encrypted payload references an unavailable one-time pre-key"), + { statusCode: 400 }, + ); + } + + oneTimePreKeyUsages.push({ + userDeviceId: targetDevice.id, + preKeyId: recipientOneTimePreKeyId, + }); + } + + if (payload.recipientUserId !== userId) { + containsOtherParticipantPayload = true; + } + } + + if (!containsOtherParticipantPayload) { + throw Object.assign( + new Error("At least one recipient payload for the other participant is required"), + { statusCode: 400 }, + ); + } + + if (replyToMessageId) { + const replyTarget = await models.ChatMessage.findOne({ + where: { + id: replyToMessageId, + chatId, + }, + attributes: ["id"], + }); + + if (!replyTarget) { + throw Object.assign(new Error("Reply target not found in this chat"), { + statusCode: 400, + }); + } + } + + const createdMessage = await sequelizeConnection.transaction(async (transaction) => { + const message = await models.ChatMessage.create( + { + chatId, + senderId: userId, + content: SECURE_MESSAGE_PLACEHOLDER, + messageType, + replyToMessageId: replyToMessageId || null, + isEncrypted: true, + encryptionIv: null, + status: "sent", + }, + { transaction }, + ); + + await models.ChatMessageRecipientPayload.bulkCreate( + recipientPayloads.map((payload) => ({ + chatMessageId: message.id, + recipientUserId: payload.recipientUserId, + recipientDeviceId: payload.recipientDeviceId, + senderDeviceId, + encryptedEnvelope: payload.encryptedEnvelope, + deliveredAt: payload.recipientUserId === userId ? new Date() : null, + readAt: payload.recipientUserId === userId ? new Date() : null, + })), + { transaction }, + ); + + if (oneTimePreKeyUsages.length > 0) { + const usedAt = new Date(); + const usageResults = await Promise.all( + oneTimePreKeyUsages.map((usage) => + models.DeviceOneTimePreKey.update( + { usedAt }, + { + where: { + userDeviceId: usage.userDeviceId, + preKeyId: usage.preKeyId, + usedAt: null, + }, + transaction, + }, + ), + ), + ); + + const consumedCount = usageResults.reduce((total: number, result: any) => { + const count = Array.isArray(result) ? Number(result[0]) : Number(result); + return total + (Number.isFinite(count) ? count : 0); + }, 0); + + if (consumedCount !== oneTimePreKeyUsages.length) { + throw Object.assign( + new Error("One-time pre-key was already consumed. Refresh recipient devices and retry."), + { statusCode: 409 }, + ); + } + } + + await models.UserDevice.update( + { lastSeenAt: new Date() }, + { + where: { + userId, + deviceId: senderDeviceId, + }, + transaction, + }, + ); + + return message; + }); + + return models.ChatMessage.findByPk(createdMessage.id, { + include: [ + { + model: models.User, + as: "sender", + attributes: ["id", "firstName", "lastName"], + include: [ + { + model: models.Profile, + as: "profile", + attributes: ["profileImage"], + }, + ], + }, + ], + }); +}; + +export const getSecureDMMessagePage = async ( + models: any, + { + chatId, + userId, + deviceId, + page, + limit, + }: { + chatId: string; + userId: string; + deviceId: string; + page: number; + limit: number; + }, +) => { + await assertSecureChatParticipant(models, chatId, userId); + await assertSecureUserDevice(models, userId, deviceId); + + const result = await models.ChatMessage.findAndCountAll({ + where: { chatId }, + include: [ + { + model: models.User, + as: "sender", + attributes: ["id", "firstName", "lastName"], + include: [ + { + model: models.Profile, + as: "profile", + attributes: ["profileImage"], + }, + ], + }, + { + model: models.ChatMessageRecipientPayload, + as: "recipientPayloads", + required: true, + where: { + recipientUserId: userId, + recipientDeviceId: deviceId, + }, + }, + ], + order: [["createdAt", "DESC"]], + limit, + offset: (page - 1) * limit, + }); + + const justDeliveredIds = result.rows + .filter((message: any) => { + const payload = Array.isArray(message.recipientPayloads) + ? message.recipientPayloads[0] + : null; + return payload && message.senderId !== userId && !payload.deliveredAt; + }) + .map((message: any) => message.id); + + const now = new Date(); + const deliveredEvents = result.rows + .filter((message: any) => justDeliveredIds.includes(message.id)) + .map((message: any) => ({ + chatId: message.chatId, + messageId: message.id, + senderId: message.senderId, + deliveredAt: now, + })); + + if (justDeliveredIds.length > 0) { + await models.ChatMessageRecipientPayload.update( + { deliveredAt: now }, + { + where: { + chatMessageId: { [Op.in]: justDeliveredIds }, + recipientUserId: userId, + recipientDeviceId: deviceId, + deliveredAt: null, + }, + }, + ); + await models.ChatMessage.update( + { status: "delivered", deliveredAt: now }, + { + where: { + id: { [Op.in]: justDeliveredIds }, + senderId: { [Op.ne]: userId }, + status: "sent", + }, + }, + ); + } + + const messageIds = result.rows.map((message: any) => message.id); + const receiptPayloads = messageIds.length > 0 + ? await models.ChatMessageRecipientPayload.findAll({ + where: { + chatMessageId: { [Op.in]: messageIds }, + }, + }) + : []; + const receiptsByMessageId = new Map(); + for (const receipt of receiptPayloads) { + const existing = receiptsByMessageId.get(receipt.chatMessageId) || []; + existing.push(receipt); + receiptsByMessageId.set(receipt.chatMessageId, existing); + } + + return { + count: typeof result.count === "number" ? result.count : result.count.length, + deliveredEvents, + rows: result.rows.map((message: any) => { + const payload = Array.isArray(message.recipientPayloads) + ? message.recipientPayloads[0] + : null; + const messageReceipts = receiptsByMessageId.get(message.id) || []; + const otherUserReceipts = messageReceipts.filter( + (receipt) => receipt.recipientUserId !== message.senderId, + ); + const deliveredAt = + otherUserReceipts.find((receipt) => receipt.deliveredAt)?.deliveredAt || + null; + const readReceipts = otherUserReceipts + .filter((receipt) => receipt.readAt) + .map((receipt) => ({ + userId: receipt.recipientUserId, + readAt: receipt.readAt, + })); + const readAt = readReceipts[0]?.readAt || null; + + return { + id: message.id, + chatId: message.chatId, + messageType: message.messageType, + replyToMessageId: message.replyToMessageId, + status: readAt ? "read" : deliveredAt ? "delivered" : message.status, + deliveredAt, + readAt, + readBy: readReceipts, + createdAt: message.createdAt, + senderId: message.senderId, + sender: message.sender, + encryptedEnvelope: payload?.encryptedEnvelope || null, + }; + }), + }; +}; + +export const markSecureChatMessagesAsRead = async ( + models: any, + { + chatId, + userId, + deviceId, + }: { + chatId: string; + userId: string; + deviceId: string; + }, +) => { + await assertSecureChatParticipant(models, chatId, userId); + await assertSecureUserDevice(models, userId, deviceId); + + const now = new Date(); + const unreadMessages = await models.ChatMessage.findAll({ + where: { + chatId, + senderId: { [Op.ne]: userId }, + }, + attributes: ["id"], + }); + + const unreadMessageIds = unreadMessages.map((message: any) => message.id); + + await sequelizeConnection.transaction(async (transaction) => { + await models.ChatParticipant.update( + { lastReadAt: now }, + { + where: { chatId, userId }, + transaction, + }, + ); + + if (unreadMessageIds.length > 0) { + await models.ChatMessageRecipientPayload.update( + { + deliveredAt: now, + readAt: now, + }, + { + where: { + chatMessageId: { [Op.in]: unreadMessageIds }, + recipientUserId: userId, + recipientDeviceId: deviceId, + }, + transaction, + }, + ); + + await models.ChatMessage.update( + { + status: "read", + deliveredAt: now, + readAt: now, + }, + { + where: { + id: { [Op.in]: unreadMessageIds }, + senderId: { [Op.ne]: userId }, + }, + transaction, + }, + ); + } + + await models.UserDevice.update( + { lastSeenAt: now }, + { + where: { + userId, + deviceId, + }, + transaction, + }, + ); + }); + + return now; +}; + +export { SECURE_DM_PROTOCOL_VERSION, SECURE_MESSAGE_PLACEHOLDER }; diff --git a/src/services/mediaUploadService.ts b/src/services/mediaUploadService.ts index 4fdcaf3..06ef884 100644 --- a/src/services/mediaUploadService.ts +++ b/src/services/mediaUploadService.ts @@ -1,5 +1,10 @@ import cloudinary from "../helpers/cloudinary"; -import { CLOUDINARY_FOLDER_NAME } from "../utils/keys"; +import { + CLOUDINARY_API_KEY, + CLOUDINARY_API_SECRET, + CLOUDINARY_CLOUD_NAME, + CLOUDINARY_FOLDER_NAME, +} from "../utils/keys"; const folder = CLOUDINARY_FOLDER_NAME; @@ -248,6 +253,65 @@ export const uploadChatMedia = async ( } }; +export const uploadEncryptedChatMedia = async ( + file: Express.Multer.File, + retries: number = 2 +): Promise<{ + success: boolean; + data?: { + url: string; + fileSize: number; + fileName: string; + mimeType: string; + }; + error?: string; +}> => { + try { + if (file.size > 110 * 1024 * 1024) { + return { success: false, error: 'Encrypted file size exceeds maximum allowed size of 110MB' }; + } + + if (!CLOUDINARY_CLOUD_NAME || !CLOUDINARY_API_KEY || !CLOUDINARY_API_SECRET) { + return { success: false, error: 'Secure media storage is not configured' }; + } + + const uploadFolder = `${folder || 'qiew'}/chat/secure`; + const uploadOptions = { + folder: uploadFolder, + resource_type: 'raw' as const, + timeout: 120000, + chunk_size: 6000000, + }; + + let result; + try { + result = await cloudinary.uploader.upload(file.path, uploadOptions); + } catch (uploadError: any) { + if (retries > 0 && (uploadError.http_code === 499 || uploadError.name === 'TimeoutError')) { + await new Promise(resolve => setTimeout(resolve, 2000)); + return uploadEncryptedChatMedia(file, retries - 1); + } + + throw uploadError; + } + + return { + success: true, + data: { + url: result.secure_url, + fileSize: file.size, + fileName: file.originalname, + mimeType: file.mimetype, + } + }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to upload encrypted file' + }; + } +}; + // Delete media from Cloudinary export const deleteChatMedia = async (url: string): Promise => { try { diff --git a/src/services/webPush.service.ts b/src/services/webPush.service.ts index 8a25e3f..6596674 100644 --- a/src/services/webPush.service.ts +++ b/src/services/webPush.service.ts @@ -80,10 +80,18 @@ class WebPushService { } private buildTitle(payload: NotificationPayload) { + if (payload.data.messageType === "secure") { + return "New secure message"; + } + return payload.data.title || this.humanizeType(payload.type); } private buildBody(payload: NotificationPayload) { + if (payload.data.messageType === "secure") { + return "Open QueCode to view this encrypted message."; + } + return ( payload.data.message || payload.data.description || @@ -149,7 +157,14 @@ class WebPushService { data: { notificationId, type: payload.type, - ...payload.data, + ...(payload.data.messageType === "secure" + ? { + chatId: payload.data.chatId, + messageId: payload.data.messageId, + messageType: "secure", + url: payload.data.url, + } + : payload.data), }, }; } diff --git a/src/socket/socketManager.ts b/src/socket/socketManager.ts index d00b731..46ca038 100644 --- a/src/socket/socketManager.ts +++ b/src/socket/socketManager.ts @@ -212,9 +212,16 @@ class SocketManager { } const chat = await models.Chat.findByPk(data.chatId, { - attributes: ["id", "isGroup"] + attributes: ["id", "isGroup", "securityMode"] }); + if (chat?.securityMode === "secure_dm_v1") { + socket.emit("error", { + message: "This conversation requires secure messaging. Use the secure message flow.", + }); + return; + } + // Sanitize mentions: deduplicate, remove self-mentions, cap at 20 const hasAllMentionInPayload = !!data.mentions?.some( (m) => m.username?.toLowerCase() === ALL_MENTION_USERNAME || m.userId === ALL_MENTION_USER_ID @@ -697,6 +704,12 @@ class SocketManager { return; } + const chat = await models.Chat.findByPk(data.chatId); + if (chat?.securityMode === "secure_dm_v1") { + socket.emit("error", { message: "Plaintext reactions are disabled for secure chats" }); + return; + } + const emoji = (data.emoji || "").trim().slice(0, 16); if (!emoji) { socket.emit("error", { message: "Invalid emoji" }); @@ -751,6 +764,12 @@ class SocketManager { return; } + const chat = await models.Chat.findByPk(data.chatId); + if (chat?.securityMode === "secure_dm_v1") { + socket.emit("error", { message: "Plaintext reactions are disabled for secure chats" }); + return; + } + await models.MessageReaction.destroy({ where: { messageId: data.messageId, userId: socket.userId }, }); @@ -958,4 +977,4 @@ class SocketManager { } } -export default SocketManager; \ No newline at end of file +export default SocketManager; diff --git a/src/types/model.ts b/src/types/model.ts index b1a9e10..b519684 100644 --- a/src/types/model.ts +++ b/src/types/model.ts @@ -210,6 +210,8 @@ export interface ChatAttributes { isGroup: boolean; groupId?: string; type?: string; + securityMode?: "legacy" | "secure_dm_v1" | "secure_group_v1" | "support_plain"; + protocolVersion?: string | null; createdAt?: Date; updatedAt?: Date; } @@ -469,6 +471,85 @@ export type DeviceSessionCreationAttributes = Optional< | "updatedAt" >; +export interface UserDeviceAttributes { + id: string; + userId: string; + deviceId: string; + deviceName?: string | null; + platform?: string | null; + appVersion?: string | null; + isActive: boolean; + lastSeenAt?: Date | null; + revokedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type UserDeviceCreationAttributes = Optional< + UserDeviceAttributes, + | "id" + | "deviceName" + | "platform" + | "appVersion" + | "isActive" + | "lastSeenAt" + | "revokedAt" + | "createdAt" + | "updatedAt" +>; + +export interface DeviceKeyBundleAttributes { + id: string; + userDeviceId: string; + algorithm: string; + identityPublicKey: Record; + signedPreKeyId: number; + signedPreKeyPublic: Record; + signedPreKeySignature: string; + registrationId: number; + uploadedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type DeviceKeyBundleCreationAttributes = Optional< + DeviceKeyBundleAttributes, + "id" | "uploadedAt" | "createdAt" | "updatedAt" +>; + +export interface DeviceOneTimePreKeyAttributes { + id: string; + userDeviceId: string; + preKeyId: number; + publicKey: Record; + usedAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type DeviceOneTimePreKeyCreationAttributes = Optional< + DeviceOneTimePreKeyAttributes, + "id" | "usedAt" | "createdAt" | "updatedAt" +>; + +export interface ChatMessageRecipientPayloadAttributes { + id: string; + chatMessageId: string; + recipientUserId: string; + recipientDeviceId: string; + senderDeviceId: string; + encryptedEnvelope: Record; + deliveredAt?: Date | null; + readAt?: Date | null; + createdAt?: Date; + updatedAt?: Date; +} + +export type ChatMessageRecipientPayloadCreationAttributes = Optional< + ChatMessageRecipientPayloadAttributes, + "id" | "deliveredAt" | "readAt" | "createdAt" | "updatedAt" +>; + export interface OrganizationCategoryAttributes { id: string; name: string; diff --git a/src/utils/notificationHelpers.ts b/src/utils/notificationHelpers.ts index 8efe8f1..252df4d 100644 --- a/src/utils/notificationHelpers.ts +++ b/src/utils/notificationHelpers.ts @@ -432,6 +432,34 @@ export const notifyChatMessageReceived = async ( mediaUrl?: string, thumbnailUrl?: string ) => { + const isSecureChat = messageType === "secure"; + const secureTitle = "New secure message"; + const secureMessage = "Open QueCode to view this encrypted message."; + + if (isSecureChat) { + return createAndSendNotification(app, { + type: NotificationType.CHAT_MESSAGE_RECEIVED, + recipientId, + data: { + chatId, + messageId, + senderId, + messageType: "secure", + isGroupChat: false, + title: secureTitle, + message: secureMessage, + url: `/chat?chatId=${chatId}`, + actions: [ + { + type: "view", + label: "Open chat", + url: `/chat?chatId=${chatId}`, + }, + ], + }, + }); + } + // Determine notification type based on message type const notificationTypeMap: Record = { text: NotificationType.CHAT_MESSAGE_TEXT, diff --git a/tsconfig.json b/tsconfig.json index 7677ce4..fc866dd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -14,6 +14,7 @@ }, "exclude": [ "node_modules", + "scripts", "**/*.test.ts", "**/*.spec.ts", "src/routes/contact.routes.ts", @@ -25,4 +26,4 @@ "src/swagger/**" ] } - \ No newline at end of file +