diff --git a/src/controllers/chatController.ts b/src/controllers/chatController.ts index 701f5e2..fe4a5aa 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 ( @@ -178,6 +179,8 @@ export const getUserChats = async ( name: chatName, isGroup: chat.isGroup, type: chat.type, + securityMode: chat.securityMode, + protocolVersion: chat.protocolVersion, groupId: chat.groupId, // Include groupId for group chats avatar: chatAvatar, lastMessage: lastMessage ? { @@ -422,7 +425,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 +435,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 +490,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 +526,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 +997,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 +1097,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..1425211 --- /dev/null +++ b/src/controllers/e2ee.controller.ts @@ -0,0 +1,445 @@ +import { NextFunction, Response } from "express"; +import Models from "../database/models"; +import { AuthenticatedRequest } from "../types/requests"; +import { + getUserDeviceBundles, + listUserDevices, + upsertUserDeviceBundle, +} from "../services/e2eeDevice.service"; +import { + createOrGetSecureDMChat, + getSecureDMMessagePage, + markSecureChatMessagesAsRead, + sendSecureDMMessage, +} from "../services/e2eeMessage.service"; +import { notifyChatMessageReceived } from "../utils/notificationHelpers"; + +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 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, + }); + + 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, + 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 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/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..464a172 --- /dev/null +++ b/src/database/models/chatMessageRecipientPayload.model.ts @@ -0,0 +1,79 @@ +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: [ + { + fields: ["chatMessageId"], + }, + { + fields: ["recipientUserId", "recipientDeviceId"], + }, + { + 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 26d21a1..68f3ed6 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"; @@ -60,6 +64,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); @@ -338,6 +346,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" }); @@ -361,6 +385,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", @@ -374,6 +403,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, { @@ -491,6 +536,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..40c75ba --- /dev/null +++ b/src/routes/e2ee.routes.ts @@ -0,0 +1,25 @@ +import { RequestHandler, Router } from "express"; +import { authenticate } from "../middleware/auth.middleware"; +import { + createOrGetSecureDM, + getMySecureDevices, + getPublicDeviceBundlesForUser, + getSecureChatMessages, + markSecureChatAsRead, + registerSecureDevice, + sendSecureChatMessage, +} from "../controllers/e2ee.controller"; + +const router = Router(); + +router.use(authenticate as RequestHandler); + +router.get("/devices", getMySecureDevices as RequestHandler); +router.post("/devices/register", registerSecureDevice 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/read", markSecureChatAsRead as RequestHandler); + +export default router; diff --git a/src/routes/index.ts b/src/routes/index.ts index 97c4579..f5ad28f 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -23,6 +23,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"; @@ -57,9 +58,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..d87633d 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 diff --git a/src/services/e2eeDevice.service.ts b/src/services/e2eeDevice.service.ts new file mode 100644 index 0000000..69b38cf --- /dev/null +++ b/src/services/e2eeDevice.service.ts @@ -0,0 +1,217 @@ +import { Op } from "sequelize"; + +const SUPPORTED_E2EE_ALGORITHMS = new Set(["qc-e2ee-p256-v1"]); + +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 assertValidBundle = (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 ( + !Number.isInteger(input.bundle.signedPreKey.keyId) || + !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 }); + } +}; + +export const upsertUserDeviceBundle = async ( + models: any, + userId: string, + input: RegisterDeviceInput, +) => { + 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"]], + }); +}; + +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 }, + ); + } + + return 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"]], + }); +}; diff --git a/src/services/e2eeMessage.service.ts b/src/services/e2eeMessage.service.ts new file mode 100644 index 0000000..2b1b150 --- /dev/null +++ b/src/services/e2eeMessage.service.ts @@ -0,0 +1,636 @@ +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; +}; + +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, + }, + ], + }); + + 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, + }, + ], + }); + + return { + participantUserIds, + devices, + }; +}; + +export const sendSecureDMMessage = async ( + models: any, + { + chatId, + userId, + senderDeviceId, + messageType, + replyToMessageId, + recipientPayloads, + }: { + chatId: string; + userId: string; + senderDeviceId: string; + messageType: "text"; + replyToMessageId?: string | null; + recipientPayloads: SecureRecipientPayloadInput[]; + }, +) => { + if (messageType !== "text") { + throw Object.assign( + new Error("secure_dm_v1 currently supports text messages only"), + { 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(); + 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, + }); + } + + const pairKey = `${payload.recipientUserId}:${payload.recipientDeviceId}`; + if (!allowedDevicePairs.has(pairKey)) { + 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); + + 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 }, + ); + + 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); + + if (justDeliveredIds.length > 0) { + const now = new Date(); + 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", + }, + }, + ); + } + + return { + count: typeof result.count === "number" ? result.count : result.count.length, + rows: result.rows.map((message: any) => { + const payload = Array.isArray(message.recipientPayloads) + ? message.recipientPayloads[0] + : null; + + return { + id: message.id, + chatId: message.chatId, + messageType: message.messageType, + replyToMessageId: message.replyToMessageId, + status: payload?.readAt ? "read" : payload?.deliveredAt ? "delivered" : message.status, + deliveredAt: payload?.deliveredAt || message.deliveredAt || null, + readAt: payload?.readAt || message.readAt || null, + 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/types/model.ts b/src/types/model.ts index 143e6f1..1a8c435 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;