From f75a2cb4bbea0f94358d0d84056d2309d2a996f2 Mon Sep 17 00:00:00 2001 From: Yash Rajpal <58601732+yash-rajpal@users.noreply.github.com> Date: Sat, 22 Jun 2024 23:38:44 +0530 Subject: [PATCH] feat(E2EE): Async E2EE keys exchange (#32197) --- .changeset/orange-clocks-raise.md | 8 + apps/meteor/app/api/server/v1/e2e.ts | 51 ++++ .../app/e2e/client/rocketchat.e2e.room.js | 44 +++- apps/meteor/app/e2e/client/rocketchat.e2e.ts | 218 +++++++++++++++++- .../functions/handleSuggestedGroupKey.ts | 7 +- .../provideUsersSuggestedGroupKeys.ts | 33 +++ .../methods/setUserPublicAndPrivateKeys.ts | 5 +- .../app/e2e/server/methods/updateGroupKey.ts | 8 +- .../app/lib/server/functions/addUserToRoom.ts | 5 + .../server/functions/removeUserFromRoom.ts | 5 + apps/meteor/client/startup/e2e.ts | 81 +------ .../channels/hooks/useTeamsChannelList.ts | 3 +- apps/meteor/lib/publishFields.ts | 1 + apps/meteor/server/lib/resetUserE2EKey.ts | 3 +- .../server/methods/removeUserFromRoom.ts | 7 +- apps/meteor/server/models/raw/Rooms.ts | 112 +++++++++ .../meteor/server/models/raw/Subscriptions.ts | 61 ++++- packages/core-typings/src/IRoom.ts | 2 + packages/i18n/src/locales/en.i18n.json | 6 +- .../model-typings/src/models/IRoomsModel.ts | 4 + .../src/models/ISubscriptionsModel.ts | 8 +- packages/rest-typings/src/index.ts | 1 + packages/rest-typings/src/v1/e2e.ts | 59 ++++- 23 files changed, 636 insertions(+), 96 deletions(-) create mode 100644 .changeset/orange-clocks-raise.md create mode 100644 apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts diff --git a/.changeset/orange-clocks-raise.md b/.changeset/orange-clocks-raise.md new file mode 100644 index 00000000000..81eac16e2a9 --- /dev/null +++ b/.changeset/orange-clocks-raise.md @@ -0,0 +1,8 @@ +--- +'@rocket.chat/model-typings': minor +'@rocket.chat/core-typings': minor +'@rocket.chat/rest-typings': minor +'@rocket.chat/meteor': minor +--- + +Async End-to-End Encrypted rooms key distribution process. Users now don't need to be online to get the keys of their subscribed encrypted rooms, the key distribution process is now async and users can recieve keys even when they are not online. diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 4c67e5f4f8b..74bd85dded6 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -1,13 +1,18 @@ import type { IUser } from '@rocket.chat/core-typings'; +import { Subscriptions } from '@rocket.chat/models'; import { ise2eGetUsersOfRoomWithoutKeyParamsGET, ise2eSetRoomKeyIDParamsPOST, ise2eSetUserPublicAndPrivateKeysParamsPOST, ise2eUpdateGroupKeyParamsPOST, + isE2EProvideUsersGroupKeyProps, + isE2EFetchUsersWaitingForGroupKeyProps, } from '@rocket.chat/rest-typings'; import { Meteor } from 'meteor/meteor'; import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSuggestedGroupKey'; +import { provideUsersSuggestedGroupKeys } from '../../../e2e/server/functions/provideUsersSuggestedGroupKeys'; +import { settings } from '../../../settings/server'; import { API } from '../api'; API.v1.addRoute( @@ -188,6 +193,9 @@ API.v1.addRoute( { authRequired: true, validateParams: ise2eUpdateGroupKeyParamsPOST, + deprecation: { + version: '8.0.0', + }, }, { async post() { @@ -233,3 +241,46 @@ API.v1.addRoute( }, }, ); + +API.v1.addRoute( + 'e2e.fetchUsersWaitingForGroupKey', + { + authRequired: true, + validateParams: isE2EFetchUsersWaitingForGroupKeyProps, + }, + { + async get() { + if (!settings.get('E2E_Enable')) { + return API.v1.success({ usersWaitingForE2EKeys: {} }); + } + + const { roomIds = [] } = this.queryParams; + const usersWaitingForE2EKeys = (await Subscriptions.findUsersWithPublicE2EKeyByRids(roomIds, this.userId).toArray()).reduce< + Record + >((acc, { rid, users }) => ({ [rid]: users, ...acc }), {}); + + return API.v1.success({ + usersWaitingForE2EKeys, + }); + }, + }, +); + +API.v1.addRoute( + 'e2e.provideUsersSuggestedGroupKeys', + { + authRequired: true, + validateParams: isE2EProvideUsersGroupKeyProps, + }, + { + async post() { + if (!settings.get('E2E_Enable')) { + return API.v1.success(); + } + + await provideUsersSuggestedGroupKeys(this.userId, this.bodyParams.usersSuggestedGroupKeys); + + return API.v1.success(); + }, + }, +); diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js index 4903dc01266..ac7cedb3fd9 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js @@ -94,6 +94,10 @@ export class E2ERoom extends Emitter { logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg); } + hasSessionKey() { + return !!this.groupSessionKey; + } + getState() { return this.state; } @@ -317,17 +321,29 @@ export class E2ERoom extends Emitter { async encryptKeyForOtherParticipants() { // Encrypt generated session key for every user in room and publish to subscription model. try { - const { users } = await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId); - users.forEach((user) => this.encryptForParticipant(user)); + const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); + + if (!users.length) { + return; + } + + const usersSuggestedGroupKeys = { [this.roomId]: [] }; + for await (const user of users) { + const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e.public_key); + + usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey }); + } + + await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); } catch (error) { return this.error('Error getting room users: ', error); } } - async encryptForParticipant(user) { + async encryptGroupKeyForParticipant(public_key) { let userKey; try { - userKey = await importRSAKey(JSON.parse(user.e2e.public_key), ['encrypt']); + userKey = await importRSAKey(JSON.parse(public_key), ['encrypt']); } catch (error) { return this.error('Error importing user key: ', error); } @@ -336,8 +352,8 @@ export class E2ERoom extends Emitter { // Encrypt session key for this user with his/her public key try { const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString)); - // Key has been encrypted. Publish to that user's subscription model for this room. - await sdk.call('e2e.updateGroupKey', this.roomId, user._id, this.keyID + Base64.encode(new Uint8Array(encryptedUserKey))); + const encryptedUserKeyToString = this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)); + return encryptedUserKeyToString; } catch (error) { return this.error('Error encrypting user key: ', error); } @@ -510,4 +526,20 @@ export class E2ERoom extends Emitter { this.on('STATE_CHANGED', cb); return () => this.off('STATE_CHANGED', cb); } + + async encryptGroupKeyForParticipantsWaitingForTheKeys(users) { + if (!this.isReady()) { + return; + } + + const usersWithKeys = await Promise.all( + users.map(async (user) => { + const { _id, public_key } = user; + const key = await this.encryptGroupKeyForParticipant(public_key); + return { _id, key }; + }), + ); + + return usersWithKeys; + } } diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index ba905e945f2..0cc344ff515 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -1,10 +1,11 @@ import QueryString from 'querystring'; import URL from 'url'; -import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; +import type { IE2EEMessage, IMessage, IRoom, ISubscription, IUser, IUploadWithUser, MessageAttachment } from '@rocket.chat/core-typings'; import { isE2EEMessage } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import EJSON from 'ejson'; +import _ from 'lodash'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; @@ -18,6 +19,7 @@ import EnterE2EPasswordModal from '../../../client/views/e2e/EnterE2EPasswordMod import SaveE2EPasswordModal from '../../../client/views/e2e/SaveE2EPasswordModal'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; +import { isTruthy } from '../../../lib/isTruthy'; import { ChatRoom, Subscriptions, Messages } from '../../models/client'; import { settings } from '../../settings/client'; import { getUserAvatarURL } from '../../utils/client'; @@ -40,6 +42,7 @@ import { } from './helper'; import { log, logError } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; + import './events.js'; let failedToDecodeKey = false; @@ -49,6 +52,7 @@ type KeyPair = { private_key: string | null; }; +const ROOM_KEY_EXCHANGE_SIZE = 10; const E2EEStateDependency = new Tracker.Dependency(); class E2E extends Emitter { @@ -62,12 +66,18 @@ class E2E extends Emitter { public privateKey: CryptoKey | undefined; + private keyDistributionInterval: ReturnType | null; + private state: E2EEState; + private observable: Meteor.LiveQueryHandle | undefined; + constructor() { super(); this.started = false; this.instancesByRoomId = {}; + this.keyDistributionInterval = null; + this.observable = undefined; this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => { this.log(`${prevState} -> ${nextState}`); @@ -81,6 +91,18 @@ class E2E extends Emitter { await this.onE2EEReady(); }); + this.on(E2EEState.DISABLED, () => { + this.observable?.stop(); + }); + + this.on(E2EEState.NOT_STARTED, () => { + this.observable?.stop(); + }); + + this.on(E2EEState.ERROR, () => { + this.observable?.stop(); + }); + this.setState(E2EEState.NOT_STARTED); } @@ -109,12 +131,80 @@ class E2E extends Emitter { async onE2EEReady() { this.log('startClient -> Done'); - this.log('decryptSubscriptions'); this.initiateHandshake(); + await this.handleAsyncE2EESuggestedKey(); + this.log('decryptSubscriptions'); await this.decryptSubscriptions(); this.log('decryptSubscriptions -> Done'); await this.initiateDecryptingPendingMessages(); this.log('DecryptingPendingMessages -> Done'); + await this.initiateKeyDistribution(); + this.log('initiateKeyDistribution -> Done'); + this.observeSubscriptions(); + this.log('observing subscriptions'); + } + + observeSubscriptions() { + this.observable?.stop(); + + this.observable = Subscriptions.find().observe({ + changed: (sub: ISubscription) => { + setTimeout(async () => { + this.log('Subscription changed', sub); + if (!sub.encrypted && !sub.E2EKey) { + this.removeInstanceByRoomId(sub.rid); + return; + } + + const e2eRoom = await this.getInstanceByRoomId(sub.rid); + if (!e2eRoom) { + return; + } + + if (sub.E2ESuggestedKey) { + if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { + await this.acceptSuggestedKey(sub.rid); + e2eRoom.keyReceived(); + } else { + console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + await this.rejectSuggestedKey(sub.rid); + } + } + + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + + // Cover private groups and direct messages + if (!e2eRoom.isSupportedRoomType(sub.t)) { + e2eRoom.disable(); + return; + } + + if (sub.E2EKey && e2eRoom.isWaitingKeys()) { + e2eRoom.keyReceived(); + return; + } + + if (!e2eRoom.isReady()) { + return; + } + + await e2eRoom.decryptSubscription(); + }, 0); + }, + added: (sub: ISubscription) => { + setTimeout(async () => { + this.log('Subscription added', sub); + if (!sub.encrypted && !sub.E2EKey) { + return; + } + return this.getInstanceByRoomId(sub.rid); + }, 0); + }, + removed: (sub: ISubscription) => { + this.log('Subscription removed', sub); + this.removeInstanceByRoomId(sub.rid); + }, + }); } shouldAskForE2EEPassword() { @@ -134,6 +224,32 @@ class E2E extends Emitter { this.emit(nextState); } + async handleAsyncE2EESuggestedKey() { + const subs = Subscriptions.find({ E2ESuggestedKey: { $exists: true } }).fetch(); + await Promise.all( + subs + .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) + .map(async (sub) => { + const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); + + if (!e2eRoom) { + return; + } + + if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { + this.log('Imported valid E2E suggested key'); + await e2e.acceptSuggestedKey(sub.rid); + e2eRoom.keyReceived(); + } else { + this.error('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); + await e2e.rejectSuggestedKey(sub.rid); + } + + sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); + }), + ); + } + async getInstanceByRoomId(rid: IRoom['_id']): Promise { const room = await waitUntilFind(() => ChatRoom.findOne({ _id: rid })); @@ -301,6 +417,8 @@ class E2E extends Emitter { this.instancesByRoomId = {}; this.privateKey = undefined; this.started = false; + this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); + this.keyDistributionInterval = null; this.setState(E2EEState.DISABLED); } @@ -602,6 +720,9 @@ class E2E extends Emitter { } async parseQuoteAttachment(message: IE2EEMessage): Promise { + if (!message?.msg) { + return message; + } const urls = message.msg.match(getMessageUrlRegex()) || []; await Promise.all( @@ -647,6 +768,99 @@ class E2E extends Emitter { return message; } + + async getSuggestedE2EEKeys(usersWaitingForE2EKeys: Record) { + const roomIds = Object.keys(usersWaitingForE2EKeys); + return Object.fromEntries( + ( + await Promise.all( + roomIds.map(async (room) => { + const e2eRoom = await this.getInstanceByRoomId(room); + + if (!e2eRoom) { + return; + } + const usersWithKeys = await e2eRoom.encryptGroupKeyForParticipantsWaitingForTheKeys(usersWaitingForE2EKeys[room]); + + if (!usersWithKeys) { + return; + } + + return [room, usersWithKeys]; + }), + ) + ).filter(isTruthy), + ); + } + + async getSample(roomIds: string[], limit = 3): Promise { + if (limit === 0) { + return []; + } + + const randomRoomIds = _.sampleSize(roomIds, ROOM_KEY_EXCHANGE_SIZE); + + const sampleIds: string[] = []; + for await (const roomId of randomRoomIds) { + const e2eroom = await this.getInstanceByRoomId(roomId); + if (!e2eroom?.hasSessionKey()) { + continue; + } + + sampleIds.push(roomId); + } + + if (!sampleIds.length) { + return this.getSample(roomIds, limit - 1); + } + + return sampleIds; + } + + async initiateKeyDistribution() { + if (this.keyDistributionInterval) { + return; + } + + const keyDistribution = async () => { + const roomIds = ChatRoom.find({ + 'usersWaitingForE2EKeys': { $exists: true }, + 'usersWaitingForE2EKeys.userId': { $ne: Meteor.userId() }, + }).map((room) => room._id); + if (!roomIds.length) { + return; + } + + // Prevent function from running and doing nothing when theres something to do + const sampleIds = await this.getSample(roomIds); + + if (!sampleIds.length) { + return; + } + + const { usersWaitingForE2EKeys = {} } = await sdk.rest.get('/v1/e2e.fetchUsersWaitingForGroupKey', { roomIds: sampleIds }); + + if (!Object.keys(usersWaitingForE2EKeys).length) { + return; + } + + const userKeysWithRooms = await this.getSuggestedE2EEKeys(usersWaitingForE2EKeys); + + if (!Object.keys(userKeysWithRooms).length) { + return; + } + + try { + await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys: userKeysWithRooms }); + } catch (error) { + return this.error('Error providing group key to users: ', error); + } + }; + + // Run first call right away, then schedule for 10s in the future + await keyDistribution(); + this.keyDistributionInterval = setInterval(keyDistribution, 10000); + } } export const e2e = new E2E(); diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index dcd1f82edbc..860051c04d4 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -1,4 +1,4 @@ -import { Subscriptions } from '@rocket.chat/models'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; export async function handleSuggestedGroupKey( @@ -23,6 +23,11 @@ export async function handleSuggestedGroupKey( if (handle === 'accept') { await Subscriptions.setGroupE2EKey(sub._id, suggestedKey); + await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); + } + + if (handle === 'reject') { + await Rooms.addUserIdToE2EEQueueByRoomIds([sub.rid], userId); } await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); diff --git a/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts new file mode 100644 index 00000000000..42408f398ec --- /dev/null +++ b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts @@ -0,0 +1,33 @@ +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions } from '@rocket.chat/models'; + +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; + +export const provideUsersSuggestedGroupKeys = async ( + userId: IUser['_id'], + usersSuggestedGroupKeys: Record, +) => { + const roomIds = Object.keys(usersSuggestedGroupKeys); + + if (!roomIds.length) { + return; + } + + // Process should try to process all rooms i have access instead of dying if one is not + for await (const roomId of roomIds) { + if (!(await canAccessRoomIdAsync(roomId, userId))) { + continue; + } + + const usersWithSuggestedKeys = []; + for await (const user of usersSuggestedGroupKeys[roomId]) { + const { modifiedCount } = await Subscriptions.setGroupE2ESuggestedKey(user._id, roomId, user.key); + if (!modifiedCount) { + continue; + } + usersWithSuggestedKeys.push(user._id); + } + + await Rooms.removeUsersFromE2EEQueueByRoomId(roomId, usersWithSuggestedKeys); + } +}; diff --git a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts index cd96f77a239..94d252601bc 100644 --- a/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts +++ b/apps/meteor/app/e2e/server/methods/setUserPublicAndPrivateKeys.ts @@ -1,4 +1,4 @@ -import { Users } from '@rocket.chat/models'; +import { Rooms, Users } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; @@ -33,5 +33,8 @@ Meteor.methods({ private_key: keyPair.private_key, public_key: keyPair.public_key, }); + + const subscribedRoomIds = await Rooms.getSubscribedRoomIdsWithoutE2EKeys(userId); + await Rooms.addUserIdToE2EEQueueByRoomIds(subscribedRoomIds, userId); }, }); diff --git a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts index 30053cc7164..c856f8cf708 100644 --- a/apps/meteor/app/e2e/server/methods/updateGroupKey.ts +++ b/apps/meteor/app/e2e/server/methods/updateGroupKey.ts @@ -2,6 +2,8 @@ import { Subscriptions } from '@rocket.chat/models'; import type { ServerMethods } from '@rocket.chat/ui-contexts'; import { Meteor } from 'meteor/meteor'; +import { methodDeprecationLogger } from '../../../lib/server/lib/deprecationWarningLogger'; + declare module '@rocket.chat/ui-contexts' { // eslint-disable-next-line @typescript-eslint/naming-convention interface ServerMethods { @@ -11,6 +13,7 @@ declare module '@rocket.chat/ui-contexts' { Meteor.methods({ async 'e2e.updateGroupKey'(rid, uid, key) { + methodDeprecationLogger.method('e2e.updateGroupKey', '8.0.0'); const userId = Meteor.userId(); if (!userId) { throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.acceptSuggestedGroupKey' }); @@ -27,10 +30,7 @@ Meteor.methods({ } // uid also has subscription to this room - const userSub = await Subscriptions.findOneByRoomIdAndUserId(rid, uid); - if (userSub) { - await Subscriptions.setGroupE2ESuggestedKey(userSub._id, key); - } + await Subscriptions.setGroupE2ESuggestedKey(uid, rid, key); } }, }); diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index e377ba3c460..57ea20f00cb 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -9,6 +9,7 @@ import { RoomMemberActions } from '../../../../definition/IRoomTypeConfig'; import { callbacks } from '../../../../lib/callbacks'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; +import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; @@ -132,5 +133,9 @@ export const addUserToRoom = async function ( await Team.addMember(inviter || userToBeAdded, userToBeAdded._id, room.teamId); } + if (room.encrypted && settings.get('E2E_Enable') && userToBeAdded.e2e?.public_key) { + await Rooms.addUserIdToE2EEQueueByRoomIds([room._id], userToBeAdded._id); + } + return true; }; diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index e593b350805..3b065c68f15 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -7,6 +7,7 @@ import { Meteor } from 'meteor/meteor'; import { afterLeaveRoomCallback } from '../../../../lib/callbacks/afterLeaveRoomCallback'; import { beforeLeaveRoomCallback } from '../../../../lib/callbacks/beforeLeaveRoomCallback'; +import { settings } from '../../../settings/server'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; export const removeUserFromRoom = async function ( @@ -65,6 +66,10 @@ export const removeUserFromRoom = async function ( await Team.removeMember(room.teamId, user._id); } + if (room.encrypted && settings.get('E2E_Enable')) { + await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); + } + // TODO: CACHE: maybe a queue? await afterLeaveRoomCallback.run(user, room); diff --git a/apps/meteor/client/startup/e2e.ts b/apps/meteor/client/startup/e2e.ts index 22ebc345c51..de615e8f45d 100644 --- a/apps/meteor/client/startup/e2e.ts +++ b/apps/meteor/client/startup/e2e.ts @@ -1,13 +1,12 @@ -import type { IMessage, ISubscription } from '@rocket.chat/core-typings'; +import type { IMessage } from '@rocket.chat/core-typings'; import { isE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { Meteor } from 'meteor/meteor'; import { Tracker } from 'meteor/tracker'; import { E2EEState } from '../../app/e2e/client/E2EEState'; import { e2e } from '../../app/e2e/client/rocketchat.e2e'; -import { Subscriptions, ChatRoom } from '../../app/models/client'; +import { ChatRoom } from '../../app/models/client'; import { settings } from '../../app/settings/client'; -import { sdk } from '../../app/utils/client/lib/SDKClient'; import { onClientBeforeSendMessage } from '../lib/onClientBeforeSendMessage'; import { onClientMessageReceived } from '../lib/onClientMessageReceived'; import { isLayoutEmbedded } from '../lib/utils/isLayoutEmbedded'; @@ -17,10 +16,12 @@ import { router } from '../providers/RouterProvider'; Meteor.startup(() => { Tracker.autorun(() => { if (!Meteor.userId()) { + e2e.log('Not logged in'); return; } if (!window.crypto) { + e2e.error('No crypto support'); return; } @@ -29,98 +30,33 @@ Meteor.startup(() => { const adminEmbedded = isLayoutEmbedded() && router.getLocationPathname().startsWith('/admin'); if (enabled && !adminEmbedded) { + e2e.log('E2E enabled starting client'); e2e.startClient(); } else { + e2e.log('E2E disabled'); e2e.setState(E2EEState.DISABLED); e2e.closeAlert(); } }); - let observable: Meteor.LiveQueryHandle | null = null; let offClientMessageReceived: undefined | (() => void); let offClientBeforeSendMessage: undefined | (() => void); - let unsubNotifyUser: undefined | (() => void); let listenersAttached = false; Tracker.autorun(() => { if (!e2e.isReady()) { + e2e.log('Not ready'); offClientMessageReceived?.(); - unsubNotifyUser?.(); - unsubNotifyUser = undefined; - observable?.stop(); offClientBeforeSendMessage?.(); listenersAttached = false; return; } if (listenersAttached) { + e2e.log('Listeners already attached'); return; } - unsubNotifyUser = sdk.stream('notify-user', [`${Meteor.userId()}/e2ekeyRequest`], async (roomId, keyId): Promise => { - const e2eRoom = await e2e.getInstanceByRoomId(roomId); - if (!e2eRoom) { - return; - } - - e2eRoom.provideKeyToUser(keyId); - }).stop; - - observable = Subscriptions.find().observe({ - changed: async (sub: ISubscription) => { - setTimeout(async () => { - if (!sub.encrypted && !sub.E2EKey) { - e2e.removeInstanceByRoomId(sub.rid); - return; - } - - const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); - if (!e2eRoom) { - return; - } - - if (sub.E2ESuggestedKey) { - if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { - e2e.acceptSuggestedKey(sub.rid); - } else { - console.warn('Invalid E2ESuggestedKey, rejecting', sub.E2ESuggestedKey); - e2e.rejectSuggestedKey(sub.rid); - } - } - - sub.encrypted ? e2eRoom.resume() : e2eRoom.pause(); - - // Cover private groups and direct messages - if (!e2eRoom.isSupportedRoomType(sub.t)) { - e2eRoom.disable(); - return; - } - - if (sub.E2EKey && e2eRoom.isWaitingKeys()) { - e2eRoom.keyReceived(); - return; - } - - if (!e2eRoom.isReady()) { - return; - } - - e2eRoom.decryptSubscription(); - }, 0); - }, - added: async (sub: ISubscription) => { - setTimeout(async () => { - if (!sub.encrypted && !sub.E2EKey) { - return; - } - return e2e.getInstanceByRoomId(sub.rid); - }, 0); - }, - removed: (sub: ISubscription) => { - e2e.removeInstanceByRoomId(sub.rid); - }, - }); - offClientMessageReceived = onClientMessageReceived.use(async (msg: IMessage) => { const e2eRoom = await e2e.getInstanceByRoomId(msg.rid); if (!e2eRoom?.shouldConvertReceivedMessages()) { @@ -157,5 +93,6 @@ Meteor.startup(() => { }); listenersAttached = true; + e2e.log('Listeners attached', listenersAttached); }); }); diff --git a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts index 80dd2998c94..43fc7c2db1a 100644 --- a/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts +++ b/apps/meteor/client/views/teams/contextualBar/channels/hooks/useTeamsChannelList.ts @@ -41,12 +41,13 @@ export const useTeamsChannelList = ( }); return { - items: rooms.map(({ _updatedAt, lastMessage, lm, ts, webRtcCallStartTime, ...room }) => ({ + items: rooms.map(({ _updatedAt, lastMessage, lm, ts, webRtcCallStartTime, usersWaitingForE2EKeys, ...room }) => ({ ...(lm && { lm: new Date(lm) }), ...(ts && { ts: new Date(ts) }), _updatedAt: new Date(_updatedAt), ...(lastMessage && { lastMessage: mapMessageFromApi(lastMessage) }), ...(webRtcCallStartTime && { webRtcCallStartTime: new Date(webRtcCallStartTime) }), + ...(usersWaitingForE2EKeys && usersWaitingForE2EKeys.map(({ userId, ts }) => ({ userId, ts: new Date(ts) }))), ...room, })), itemCount: total, diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index b23c8901aca..1e9274526ef 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -120,4 +120,5 @@ export const roomFields = { callDuration: 1, callTotalHoldTime: 1, callWaitingTime: 1, + usersWaitingForE2EKeys: 1, } as const; diff --git a/apps/meteor/server/lib/resetUserE2EKey.ts b/apps/meteor/server/lib/resetUserE2EKey.ts index b6c2d2c886e..3f30251dbb1 100644 --- a/apps/meteor/server/lib/resetUserE2EKey.ts +++ b/apps/meteor/server/lib/resetUserE2EKey.ts @@ -1,5 +1,5 @@ import { api } from '@rocket.chat/core-services'; -import { Subscriptions, Users } from '@rocket.chat/models'; +import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; import * as Mailer from '../../app/mailer/server/api'; @@ -71,6 +71,7 @@ export async function resetUserE2EEncriptionKey(uid: string, notifyUser: boolean await Users.resetE2EKey(uid); await Subscriptions.resetUserE2EKey(uid); + await Rooms.removeUserFromE2EEQueue(uid); // Force the user to logout, so that the keys can be generated again await Users.unsetLoginTokens(uid); diff --git a/apps/meteor/server/methods/removeUserFromRoom.ts b/apps/meteor/server/methods/removeUserFromRoom.ts index ee1ba25e76f..08cd6c5b25e 100644 --- a/apps/meteor/server/methods/removeUserFromRoom.ts +++ b/apps/meteor/server/methods/removeUserFromRoom.ts @@ -8,6 +8,7 @@ import { canAccessRoomAsync, getUsersInRole } from '../../app/authorization/serv import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission'; import { hasRoleAsync } from '../../app/authorization/server/functions/hasRole'; import { notifyOnRoomChanged } from '../../app/lib/server/lib/notifyListener'; +import { settings } from '../../app/settings/server'; import { RoomMemberActions } from '../../definition/IRoomTypeConfig'; import { callbacks } from '../../lib/callbacks'; import { afterRemoveFromRoomCallback } from '../../lib/callbacks/afterRemoveFromRoomCallback'; @@ -89,7 +90,11 @@ export const removeUserFromRoomMethod = async (fromId: string, data: { rid: stri await Team.removeMember(room.teamId, removedUser._id); } - setImmediate(async () => { + if (room.encrypted && settings.get('E2E_Enable')) { + await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [removedUser._id]); + } + + setImmediate(() => { void afterRemoveFromRoomCallback.run({ removedUser, userWhoRemoved: fromUser }, room); void notifyOnRoomChanged(room); }); diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 78efa845ba3..1859f6c18b3 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -93,6 +93,16 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { sparse: true, }, { key: { t: 1, ts: 1 } }, + { + key: { + 'usersWaitingForE2EKeys.userId': 1, + }, + partialFilterExpression: { + 'usersWaitingForE2EKeys.userId': { + $exists: true, + }, + }, + }, ]; } @@ -1977,4 +1987,106 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } + + async getSubscribedRoomIdsWithoutE2EKeys(uid: IUser['_id']): Promise { + return ( + await this.col + .aggregate([ + { $match: { encrypted: true } }, + { + $lookup: { + from: 'rocketchat_subscription', + localField: '_id', + foreignField: 'rid', + as: 'subs', + }, + }, + { + $unwind: '$subs', + }, + { + $match: { + 'subs.u._id': uid, + 'subs.E2EKey': { + $exists: false, + }, + }, + }, + { + $project: { + _id: 1, + }, + }, + ]) + .toArray() + ).map(({ _id }) => _id); + } + + addUserIdToE2EEQueueByRoomIds(roomIds: IRoom['_id'][], uid: IUser['_id']): Promise { + const query: Filter = { + '_id': { + $in: roomIds, + }, + 'usersWaitingForE2EKeys.userId': { $ne: uid }, + 'encrypted': true, + }; + + const update: UpdateFilter = { + $push: { + usersWaitingForE2EKeys: { + $each: [ + { + userId: uid, + ts: new Date(), + }, + ], + $slice: -50, + }, + }, + }; + + return this.updateMany(query, update); + } + + async removeUsersFromE2EEQueueByRoomId(roomId: IRoom['_id'], uids: IUser['_id'][]): Promise { + const query: Filter = { + '_id': roomId, + 'usersWaitingForE2EKeys.userId': { + $in: uids, + }, + 'encrypted': true, + }; + + const update: UpdateFilter = { + $pull: { + usersWaitingForE2EKeys: { userId: { $in: uids } }, + }, + }; + + await this.updateMany(query, update); + + return this.updateMany( + { + '_id': roomId, + 'usersWaitingForE2EKeys.0': { $exists: false }, + 'encrypted': true, + }, + { $unset: { usersWaitingForE2EKeys: 1 } }, + ); + } + + async removeUserFromE2EEQueue(uid: IUser['_id']): Promise { + const query: Filter = { + 'usersWaitingForE2EKeys.userId': uid, + 'encrypted': true, + }; + + const update: UpdateFilter = { + $pull: { + usersWaitingForE2EKeys: { userId: uid }, + }, + }; + + return this.updateMany(query, update); + } } diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index e2570f60e4a..01440a179c7 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -27,6 +27,7 @@ import type { UpdateFilter, InsertOneResult, InsertManyResult, + AggregationCursor, } from 'mongodb'; import { getDefaultSubscriptionPref } from '../../../app/utils/lib/getDefaultSubscriptionPref'; @@ -538,8 +539,8 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.findOneById(_id); } - setGroupE2ESuggestedKey(_id: string, key: string): Promise { - const query = { _id }; + setGroupE2ESuggestedKey(uid: string, rid: string, key: string): Promise { + const query = { rid, 'u._id': uid }; const update = { $set: { E2ESuggestedKey: key } }; return this.updateOne(query, update); } @@ -674,6 +675,61 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.find(query, options); } + findUsersWithPublicE2EKeyByRids( + rids: IRoom['_id'][], + excludeUserId: IUser['_id'], + usersLimit = 50, + ): AggregationCursor<{ rid: IRoom['_id']; users: { _id: IUser['_id']; public_key: string }[] }> { + return this.col.aggregate([ + { + $match: { + 'rid': { + $in: rids, + }, + 'E2EKey': { + $exists: false, + }, + 'u._id': { + $ne: excludeUserId, + }, + }, + }, + { + $lookup: { + from: 'users', + localField: 'u._id', + foreignField: '_id', + as: 'user', + }, + }, + { + $unwind: '$user', + }, + { + $match: { + 'user.e2e.public_key': { + $exists: 1, + }, + }, + }, + { + $group: { + _id: { + rid: '$rid', + }, + users: { $push: { _id: '$user._id', public_key: '$user.e2e.public_key' } }, + }, + }, + { + $project: { + rid: '$_id.rid', + users: { $slice: ['$users', usersLimit] }, + _id: 0, + }, + }, + ]); + } + updateAudioNotificationValueById(_id: string, audioNotificationValue: string): Promise { const query = { _id, @@ -871,6 +927,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri { $unset: { E2EKey: '', + E2ESuggestedKey: 1, }, }, ); diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 32bf438cd70..74d4ca2ef48 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -95,6 +95,8 @@ export interface IRoom extends IRocketChatRecord { customFields?: Record; channel?: { _id: string }; + + usersWaitingForE2EKeys?: { userId: IUser['_id']; ts: Date }[]; } export const isRoomWithJoinCode = (room: Partial): room is IRoomWithJoinCode => diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 9f11703f274..46b91babb6f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1803,9 +1803,9 @@ "E2E_password_request_text": "To access your encrypted channels and direct messages, enter your encryption password. This is not stored on the server, so you’ll need to use it on every device.", "E2E_password_reveal_text": "Create secure private rooms and direct messages with end-to-end encryption. This password won’t be stored on the server. You can use it on all your devices.", "E2E_password_save_text": "This will only be displayed once, please save it now.", - "E2E_Reset_Email_Content": "You've been automatically logged out. When you login again, Rocket.Chat will generate a new key and restore your access to any encrypted room that has one or more members online. Due to the nature of the E2E encryption, Rocket.Chat will not be able to restore access to any encrypted room that has no member online.", - "E2E_Reset_Key_Explanation": "This option will remove your current E2E key and log you out.
When you login again, Rocket.Chat will generate you a new key and restore your access to any encrypted room that has one or more members online.
Due to the nature of the E2E encryption, Rocket.Chat will not be able to restore access to any encrypted room that has no member online.", - "E2E_Reset_Other_Key_Warning": "Reset the current E2E key will log out the user. When the user login again, Rocket.Chat will generate a new key and restore the user access to any encrypted room that has one or more members online. Due to the nature of the E2E encryption, Rocket.Chat will not be able to restore access to any encrypted room that has no member online.", + "E2E_Reset_Email_Content": "You've been automatically logged out. When you log in again, a new key will be generated and access will be restored to any encrypted room with at least one member online. If no members are online, access will be restored as soon as a member comes online.", + "E2E_Reset_Key_Explanation": "This will remove your current E2EE key and log you out.
When you log in again, a new key will be generated and access will be restored to any encrypted room with at least one member online.
If no members are online, access will be restored as soon as a member comes online.", + "E2E_Reset_Other_Key_Warning": "Resetting the E2EE key will log out the user. When the user logs in again, a new key will be generated and access will be restored to any encrypted room with at least one member online. If no members are online, access will be restored as soon as a member comes online.", "E2E_unavailable_for_federation": "E2E is unavailable for federated rooms", "ECDH_Enabled": "Enable second layer encryption for data transport", "Edit": "Edit", diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 9bc4fc35a9d..887f51987ae 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -278,4 +278,8 @@ export interface IRoomsModel extends IBaseModel { removeDirectRoomContainingUsername(username: string): Promise; countDiscussions(): Promise; setOTRForDMByRoomID(rid: string): Promise; + addUserIdToE2EEQueueByRoomIds(roomIds: IRoom['_id'][], uid: IUser['_id']): Promise; + getSubscribedRoomIdsWithoutE2EKeys(uid: IUser['_id']): Promise; + removeUsersFromE2EEQueueByRoomId(roomId: IRoom['_id'], uids: IUser['_id'][]): Promise; + removeUserFromE2EEQueue(uid: IUser['_id']): Promise; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index aa972a84618..91398e77ebe 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -9,6 +9,7 @@ import type { Filter, InsertOneResult, InsertManyResult, + AggregationCursor, } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; @@ -96,7 +97,7 @@ export interface ISubscriptionsModel extends IBaseModel { setGroupE2EKey(_id: string, key: string): Promise; - setGroupE2ESuggestedKey(_id: string, key: string): Promise; + setGroupE2ESuggestedKey(uid: string, rid: string, key: string): Promise; unsetGroupE2ESuggestedKey(_id: string): Promise; @@ -128,6 +129,11 @@ export interface ISubscriptionsModel extends IBaseModel { getAutoTranslateLanguagesByRoomAndNotUser(rid: string, userId: string): Promise<(string | undefined)[]>; findByRidWithoutE2EKey(rid: string, options: FindOptions): FindCursor; + findUsersWithPublicE2EKeyByRids( + rids: IRoom['_id'][], + excludeUserId: IUser['_id'], + usersLimit?: number, + ): AggregationCursor<{ rid: IRoom['_id']; users: { _id: IUser['_id']; public_key: string }[] }>; findByUserId(userId: string, options?: FindOptions): FindCursor; cachedFindByUserId(userId: string, options?: FindOptions): FindCursor; updateAutoTranslateById(_id: string, autoTranslate: boolean): Promise; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index 044282784cd..1850fc5e2b5 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -258,6 +258,7 @@ export * from './v1/e2e/e2eGetUsersOfRoomWithoutKeyParamsGET'; export * from './v1/e2e/e2eSetRoomKeyIDParamsPOST'; export * from './v1/e2e/e2eSetUserPublicAndPrivateKeysParamsPOST'; export * from './v1/e2e/e2eUpdateGroupKeyParamsPOST'; +export * from './v1/e2e'; export * from './v1/import'; export * from './v1/voip'; export * from './v1/email-inbox'; diff --git a/packages/rest-typings/src/v1/e2e.ts b/packages/rest-typings/src/v1/e2e.ts index 07e9d0379d6..1974445eb34 100644 --- a/packages/rest-typings/src/v1/e2e.ts +++ b/packages/rest-typings/src/v1/e2e.ts @@ -1,4 +1,4 @@ -import type { IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; const ajv = new Ajv({ @@ -88,6 +88,55 @@ const E2eSetRoomKeyIdSchema = { export const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); +type E2EProvideUsersGroupKeyProps = { + usersSuggestedGroupKeys: Record; +}; + +const E2EProvideUsersGroupKeySchema = { + type: 'object', + properties: { + usersSuggestedGroupKeys: { + type: 'object', + additionalProperties: { + type: 'array', + items: { + type: 'object', + properties: { + _id: { type: 'string' }, + key: { type: 'string' }, + }, + required: ['_id', 'key'], + additionalProperties: false, + }, + }, + }, + }, + required: ['usersSuggestedGroupKeys'], + additionalProperties: false, +}; + +export const isE2EProvideUsersGroupKeyProps = ajv.compile(E2EProvideUsersGroupKeySchema); + +type E2EFetchUsersWaitingForGroupKeyProps = { roomIds: string[] }; + +const E2EFetchUsersWaitingForGroupKeySchema = { + type: 'object', + properties: { + roomIds: { + type: 'array', + items: { + type: 'string', + }, + }, + }, + required: ['roomIds'], + additionalProperties: false, +}; + +export const isE2EFetchUsersWaitingForGroupKeyProps = ajv.compile( + E2EFetchUsersWaitingForGroupKeySchema, +); + export type E2eEndpoints = { '/v1/e2e.setUserPublicAndPrivateKeys': { POST: (params: E2eSetUserPublicAndPrivateKeysProps) => void; @@ -112,4 +161,12 @@ export type E2eEndpoints = { '/v1/e2e.fetchMyKeys': { GET: () => { public_key: string; private_key: string }; }; + '/v1/e2e.fetchUsersWaitingForGroupKey': { + GET: (params: E2EFetchUsersWaitingForGroupKeyProps) => { + usersWaitingForE2EKeys: Record; + }; + }; + '/v1/e2e.provideUsersSuggestedGroupKeys': { + POST: (params: E2EProvideUsersGroupKeyProps) => void; + }; };