diff --git a/.changeset/smooth-horses-draw.md b/.changeset/smooth-horses-draw.md new file mode 100644 index 000000000000..cf3a7059e264 --- /dev/null +++ b/.changeset/smooth-horses-draw.md @@ -0,0 +1,11 @@ +--- +"@rocket.chat/meteor": major +"@rocket.chat/core-services": patch +"@rocket.chat/core-typings": patch +"@rocket.chat/ddp-client": patch +"@rocket.chat/i18n": patch +"@rocket.chat/model-typings": patch +"@rocket.chat/rest-typings": patch +--- + +Allows authorized users to reset the encryption key for end-to-end encrypted rooms. This aims to prevent situations where all users of a room have lost the encryption key, and as such, the access to the room. diff --git a/apps/meteor/app/api/server/v1/e2e.ts b/apps/meteor/app/api/server/v1/e2e.ts index 74bd85dded6a..b5f0c2ded06d 100644 --- a/apps/meteor/app/api/server/v1/e2e.ts +++ b/apps/meteor/app/api/server/v1/e2e.ts @@ -7,14 +7,23 @@ import { ise2eUpdateGroupKeyParamsPOST, isE2EProvideUsersGroupKeyProps, isE2EFetchUsersWaitingForGroupKeyProps, + isE2EResetRoomKeyProps, } from '@rocket.chat/rest-typings'; +import ExpiryMap from 'expiry-map'; import { Meteor } from 'meteor/meteor'; +import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; +import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { handleSuggestedGroupKey } from '../../../e2e/server/functions/handleSuggestedGroupKey'; import { provideUsersSuggestedGroupKeys } from '../../../e2e/server/functions/provideUsersSuggestedGroupKeys'; +import { resetRoomKey } from '../../../e2e/server/functions/resetRoomKey'; import { settings } from '../../../settings/server'; import { API } from '../api'; +// After 10s the room lock will expire, meaning that if for some reason the process never completed +// The next reset will be available 10s after +const LockMap = new ExpiryMap(10000); + API.v1.addRoute( 'e2e.fetchMyKeys', { @@ -284,3 +293,36 @@ API.v1.addRoute( }, }, ); + +// This should have permissions +API.v1.addRoute( + 'e2e.resetRoomKey', + { authRequired: true, validateParams: isE2EResetRoomKeyProps }, + { + async post() { + const { rid, e2eKey, e2eKeyId } = this.bodyParams; + if (!(await hasPermissionAsync(this.userId, 'toggle-room-e2e-encryption', rid))) { + return API.v1.unauthorized(); + } + if (LockMap.has(rid)) { + throw new Error('error-e2e-key-reset-in-progress'); + } + + LockMap.set(rid, true); + + if (!(await canAccessRoomIdAsync(rid, this.userId))) { + throw new Error('error-not-allowed'); + } + + try { + await resetRoomKey(rid, this.userId, e2eKey, e2eKeyId); + return API.v1.success(); + } catch (e) { + console.error(e); + return API.v1.failure('error-e2e-key-reset-failed'); + } finally { + LockMap.delete(rid); + } + }, + }, +); diff --git a/apps/meteor/app/e2e/client/helper.js b/apps/meteor/app/e2e/client/helper.js index ddf49b262b91..25d9e9407801 100644 --- a/apps/meteor/app/e2e/client/helper.js +++ b/apps/meteor/app/e2e/client/helper.js @@ -147,9 +147,14 @@ export async function generateMnemonicPhrase(n, sep = ' ') { return result.join(sep); } -export async function createSha256Hash(data) { +export async function createSha256HashFromText(data) { const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(data)); return Array.from(new Uint8Array(hash)) .map((b) => b.toString(16).padStart(2, '0')) .join(''); } + +export async function sha256HashFromArrayBuffer(arrayBuffer) { + const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer))); + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js index b360e635243d..d08719d95506 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.room.js +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.room.js @@ -7,6 +7,7 @@ import { roomCoordinator } from '../../../client/lib/rooms/roomCoordinator'; import { RoomSettingsEnum } from '../../../definition/IRoomTypeConfig'; import { ChatRoom, Subscriptions, Messages } from '../../models/client'; import { sdk } from '../../utils/client/lib/SDKClient'; +import { t } from '../../utils/lib/i18n'; import { E2ERoomState } from './E2ERoomState'; import { toString, @@ -24,7 +25,8 @@ import { readFileAsArrayBuffer, encryptAESCTR, generateAESCTRKey, - createSha256Hash, + sha256HashFromArrayBuffer, + createSha256HashFromText, } from './helper'; import { log, logError } from './logger'; import { e2e } from './rocketchat.e2e'; @@ -34,7 +36,7 @@ const PAUSED = Symbol('PAUSED'); const permitedMutations = { [E2ERoomState.NOT_STARTED]: [E2ERoomState.ESTABLISHING, E2ERoomState.DISABLED, E2ERoomState.KEYS_RECEIVED], - [E2ERoomState.READY]: [E2ERoomState.DISABLED], + [E2ERoomState.READY]: [E2ERoomState.DISABLED, E2ERoomState.CREATING_KEYS, E2ERoomState.WAITING_KEYS], [E2ERoomState.ERROR]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.NOT_STARTED], [E2ERoomState.WAITING_KEYS]: [E2ERoomState.KEYS_RECEIVED, E2ERoomState.ERROR, E2ERoomState.DISABLED], [E2ERoomState.ESTABLISHING]: [ @@ -76,7 +78,10 @@ export class E2ERoom extends Emitter { this.typeOfRoom = room.t; this.roomKeyId = room.e2eKeyId; - this.once(E2ERoomState.READY, () => this.decryptPendingMessages()); + this.once(E2ERoomState.READY, async () => { + await this.decryptOldRoomKeys(); + return this.decryptPendingMessages(); + }); this.once(E2ERoomState.READY, () => this.decryptSubscription()); this.on('STATE_CHANGED', (prev) => { if (this.roomId === RoomManager.opened) { @@ -212,6 +217,64 @@ export class E2ERoom extends Emitter { this.log('decryptSubscriptions Done'); } + async decryptOldRoomKeys() { + const sub = Subscriptions.findOne({ rid: this.roomId }); + + if (!sub?.oldRoomKeys || sub?.oldRoomKeys.length === 0) { + this.log('decryptOldRoomKeys nothing to do'); + return; + } + + const keys = []; + for await (const key of sub.oldRoomKeys) { + try { + const k = await this.decryptSessionKey(key.E2EKey); + keys.push({ + ...key, + E2EKey: k, + }); + } catch (e) { + this.error( + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + ); + keys.push({ ...key, E2EKey: null }); + } + } + + this.oldKeys = keys; + this.log('decryptOldRoomKeys Done'); + } + + async exportOldRoomKeys(oldKeys) { + this.log('exportOldRoomKeys starting'); + if (!oldKeys || oldKeys.length === 0) { + this.log('exportOldRoomKeys nothing to do'); + return; + } + + const keys = []; + for await (const key of oldKeys) { + try { + if (!key.E2EKey) { + continue; + } + + const k = await this.exportSessionKey(key.E2EKey); + keys.push({ + ...key, + E2EKey: k, + }); + } catch (e) { + this.error( + `Cannot decrypt old room key with id ${key.e2eKeyId}. This is likely because user private key changed or is missing. Skipping`, + ); + } + } + + this.log(`exportOldRoomKeys Done: ${keys.length} keys exported`); + return keys; + } + async decryptPendingMessages() { return Messages.find({ rid: this.roomId, t: 'e2e', e2e: 'pending' }).forEach(async ({ _id, ...msg }) => { Messages.update({ _id }, await this.decryptMessage(msg)); @@ -266,6 +329,18 @@ export class E2ERoom extends Emitter { return roomCoordinator.getRoomDirectives(type).allowRoomSettingChange({}, RoomSettingsEnum.E2E); } + async decryptSessionKey(key) { + return importAESKey(JSON.parse(await this.exportSessionKey(key))); + } + + async exportSessionKey(key) { + key = key.slice(12); + key = Base64.decode(key); + + const decryptedKey = await decryptRSA(e2e.privateKey, key); + return toString(decryptedKey); + } + async importGroupKey(groupKey) { this.log('Importing room key ->', this.roomId); // Get existing group key @@ -285,7 +360,7 @@ export class E2ERoom extends Emitter { // When a new e2e room is created, it will be initialized without an e2e key id // This will prevent new rooms from storing `undefined` as the keyid if (!this.keyID) { - this.keyID = this.roomKeyId || (await createSha256Hash(this.sessionKeyExportedString)).slice(0, 12); + this.keyID = this.roomKeyId || (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); } // Import session key for use. @@ -301,32 +376,70 @@ export class E2ERoom extends Emitter { return true; } + async createNewGroupKey() { + this.groupSessionKey = await generateAESKey(); + + const sessionKeyExported = await exportJWKKey(this.groupSessionKey); + this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); + this.keyID = (await createSha256HashFromText(this.sessionKeyExportedString)).slice(0, 12); + } + async createGroupKey() { this.log('Creating room key'); - // Create group key try { - this.groupSessionKey = await generateAESKey(); + await this.createNewGroupKey(); + + await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); + await sdk.rest.post('/v1/e2e.updateGroupKey', { + rid: this.roomId, + uid: this.userId, + key: await this.encryptGroupKeyForParticipant(e2e.publicKey), + }); + await this.encryptKeyForOtherParticipants(); } catch (error) { - console.error('Error generating group key: ', error); + this.error('Error exporting group key: ', error); throw error; } + } + + async resetRoomKey() { + this.log('Resetting room key'); + if (!e2e.publicKey) { + this.error('Cannot reset room key. No public key found.'); + return; + } + this.setState(E2ERoomState.CREATING_KEYS); try { - const sessionKeyExported = await exportJWKKey(this.groupSessionKey); - this.sessionKeyExportedString = JSON.stringify(sessionKeyExported); - this.keyID = (await createSha256Hash(this.sessionKeyExportedString)).slice(0, 12); + await this.createNewGroupKey(); - await sdk.call('e2e.setRoomKeyID', this.roomId, this.keyID); - await this.encryptKeyForOtherParticipants(); + const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptGroupKeyForParticipant(e2e.publicKey) }; + + this.setState(E2ERoomState.READY); + this.log(`Room key reset done for room ${this.roomId}`); + + return e2eNewKeys; } catch (error) { - this.error('Error exporting group key: ', error); + this.error('Error resetting group key: ', error); throw error; } } + onRoomKeyReset(keyID) { + this.log(`Room keyID was reset. New keyID: ${keyID} Previous keyID: ${this.keyID}`); + this.setState(E2ERoomState.WAITING_KEYS); + this.keyID = keyID; + this.groupSessionKey = undefined; + this.sessionKeyExportedString = undefined; + this.sessionKeyExported = undefined; + this.oldKeys = undefined; + } + async encryptKeyForOtherParticipants() { // Encrypt generated session key for every user in room and publish to subscription model. try { + const mySub = Subscriptions.findOne({ rid: this.roomId }); + const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); const users = (await sdk.call('e2e.getUsersOfRoomWithoutKey', this.roomId)).users.filter((user) => user?.e2e?.public_key); if (!users.length) { @@ -336,8 +449,9 @@ export class E2ERoom extends Emitter { const usersSuggestedGroupKeys = { [this.roomId]: [] }; for await (const user of users) { const encryptedGroupKey = await this.encryptGroupKeyForParticipant(user.e2e.public_key); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e.public_key, decryptedOldGroupKeys); - usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey }); + usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key: encryptedGroupKey, ...(oldKeys && { oldKeys }) }); } await sdk.rest.post('/v1/e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); @@ -346,6 +460,36 @@ export class E2ERoom extends Emitter { } } + async encryptOldKeysForParticipant(public_key, oldRoomKeys) { + if (!oldRoomKeys || oldRoomKeys.length === 0) { + return; + } + + let userKey; + + try { + userKey = await importRSAKey(JSON.parse(public_key), ['encrypt']); + } catch (error) { + return this.error('Error importing user key: ', error); + } + + try { + const keys = []; + for await (const oldRoomKey of oldRoomKeys) { + if (!oldRoomKey.E2EKey) { + continue; + } + const encryptedKey = await encryptRSA(userKey, toArrayBuffer(oldRoomKey.E2EKey)); + const encryptedKeyToString = oldRoomKey.e2eKeyId + Base64.encode(new Uint8Array(encryptedKey)); + + keys.push({ ...oldRoomKey, E2EKey: encryptedKeyToString }); + } + return keys; + } catch (error) { + return this.error('Error encrypting user key: ', error); + } + } + async encryptGroupKeyForParticipant(public_key) { let userKey; try { @@ -365,16 +509,6 @@ export class E2ERoom extends Emitter { } } - async sha256Hash(arrayBuffer) { - const hashArray = Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', arrayBuffer))); - return hashArray.map((b) => b.toString(16).padStart(2, '0')).join(''); - } - - async sha256HashText(text) { - const encoder = new TextEncoder(); - return this.sha256Hash(encoder.encode(text)); - } - // Encrypts files before upload. I/O is in arraybuffers. async encryptFile(file) { // if (!this.isSupportedRoomType(this.typeOfRoom)) { @@ -383,7 +517,7 @@ export class E2ERoom extends Emitter { const fileArrayBuffer = await readFileAsArrayBuffer(file); - const hash = await this.sha256Hash(new Uint8Array(fileArrayBuffer)); + const hash = await sha256HashFromArrayBuffer(new Uint8Array(fileArrayBuffer)); const vector = crypto.getRandomValues(new Uint8Array(16)); const key = await generateAESCTRKey(); @@ -397,7 +531,7 @@ export class E2ERoom extends Emitter { const exportedKey = await window.crypto.subtle.exportKey('jwk', key); - const fileName = await this.sha256HashText(file.name); + const fileName = await createSha256HashFromText(file.name); const encryptedFile = new File([toArrayBuffer(result)], fileName); @@ -461,6 +595,10 @@ export class E2ERoom extends Emitter { return; } + if (!this.groupSessionKey) { + throw new Error(t('E2E_Invalid_Key')); + } + const ts = new Date(); const data = new TextEncoder('UTF-8').encode( @@ -509,8 +647,17 @@ export class E2ERoom extends Emitter { async decrypt(message) { const keyID = message.slice(0, 12); + let oldKey = ''; if (keyID !== this.keyID) { - return message; + const oldRoomKey = this.oldKeys?.find((key) => key.e2eKeyId === keyID); + // Messages already contain a keyID stored with them + // That means that if we cannot find a keyID for the key the message has preppended to + // The message is indecipherable. + if (!oldRoomKey) { + this.error(`Message is indecipherable. Message KeyID ${keyID} not found in old room keys`); + return { msg: t('E2E_indecipherable') }; + } + oldKey = oldRoomKey.E2EKey; } message = message.slice(12); @@ -518,10 +665,11 @@ export class E2ERoom extends Emitter { const [vector, cipherText] = splitVectorAndEcryptedData(Base64.decode(message)); try { - const result = await decryptAES(vector, this.groupSessionKey, cipherText); + const result = await decryptAES(vector, oldKey || this.groupSessionKey, cipherText); return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result))); } catch (error) { - return this.error('Error decrypting message: ', error, message); + this.error('Error decrypting message: ', error, message); + return { msg: t('E2E_Key_Error') }; } } @@ -544,11 +692,14 @@ export class E2ERoom extends Emitter { return; } + const mySub = Subscriptions.findOne({ rid: this.roomId }); + const decryptedOldGroupKeys = await this.exportOldRoomKeys(mySub?.oldRoomKeys); const usersWithKeys = await Promise.all( users.map(async (user) => { const { _id, public_key } = user; const key = await this.encryptGroupKeyForParticipant(public_key); - return { _id, key }; + const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys); + return { _id, key, ...(oldKeys && { oldKeys }) }; }), ); diff --git a/apps/meteor/app/e2e/client/rocketchat.e2e.ts b/apps/meteor/app/e2e/client/rocketchat.e2e.ts index 5485fe31fc15..fa1cda643902 100644 --- a/apps/meteor/app/e2e/client/rocketchat.e2e.ts +++ b/apps/meteor/app/e2e/client/rocketchat.e2e.ts @@ -67,6 +67,8 @@ class E2E extends Emitter { public privateKey: CryptoKey | undefined; + public publicKey: string | undefined; + private keyDistributionInterval: ReturnType | null; private state: E2EEState; @@ -268,6 +270,16 @@ class E2E extends Emitter { this.instancesByRoomId[rid] = new E2ERoom(Meteor.userId(), room); } + // When the key was already set and is changed via an update, we update the room instance + if ( + this.instancesByRoomId[rid].keyID !== undefined && + room.e2eKeyId !== undefined && + this.instancesByRoomId[rid].keyID !== room.e2eKeyId + ) { + // KeyID was changed, update instance with new keyID and put room in waiting keys status + this.instancesByRoomId[rid].onRoomKeyReset(room.e2eKeyId); + } + return this.instancesByRoomId[rid]; } @@ -419,6 +431,7 @@ class E2E extends Emitter { Accounts.storageLocation.removeItem('private_key'); this.instancesByRoomId = {}; this.privateKey = undefined; + this.publicKey = undefined; this.started = false; this.keyDistributionInterval && clearInterval(this.keyDistributionInterval); this.keyDistributionInterval = null; @@ -451,6 +464,7 @@ class E2E extends Emitter { async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { Accounts.storageLocation.setItem('public_key', public_key); + this.publicKey = public_key; try { this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']); @@ -477,6 +491,7 @@ class E2E extends Emitter { try { const publicKey = await exportJWKKey(key.publicKey); + this.publicKey = JSON.stringify(publicKey); Accounts.storageLocation.setItem('public_key', JSON.stringify(publicKey)); } catch (error) { this.setState(E2EEState.ERROR); diff --git a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts index e498d282d704..9d74144517fd 100644 --- a/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts +++ b/apps/meteor/app/e2e/server/functions/handleSuggestedGroupKey.ts @@ -24,7 +24,10 @@ export async function handleSuggestedGroupKey( } if (handle === 'accept') { - await Subscriptions.setGroupE2EKey(sub._id, suggestedKey); + // A merging process can happen here, but we're not doing that for now + // If a user already has oldRoomKeys, we will ignore the suggested ones + const oldKeys = sub.oldRoomKeys ? undefined : sub.suggestedOldRoomKeys; + await Subscriptions.setGroupE2EKeyAndOldRoomKeys(sub._id, suggestedKey, oldKeys); const { modifiedCount } = await Rooms.removeUsersFromE2EEQueueByRoomId(sub.rid, [userId]); if (modifiedCount) { void notifyOnRoomChangedById(sub.rid); @@ -38,7 +41,7 @@ export async function handleSuggestedGroupKey( } } - const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKey(sub._id); + const { modifiedCount } = await Subscriptions.unsetGroupE2ESuggestedKeyAndOldRoomKeys(sub._id); if (modifiedCount) { void notifyOnSubscriptionChangedById(sub._id); } diff --git a/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts index 9f69f17920d1..c5b54e368823 100644 --- a/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts +++ b/apps/meteor/app/e2e/server/functions/provideUsersSuggestedGroupKeys.ts @@ -1,4 +1,4 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; import { Rooms, Subscriptions } from '@rocket.chat/models'; import { canAccessRoomIdAsync } from '../../../authorization/server/functions/canAccessRoom'; @@ -6,7 +6,7 @@ import { notifyOnSubscriptionChanged, notifyOnRoomChangedById } from '../../../l export const provideUsersSuggestedGroupKeys = async ( userId: IUser['_id'], - usersSuggestedGroupKeys: Record, + usersSuggestedGroupKeys: Record, ) => { const roomIds = Object.keys(usersSuggestedGroupKeys); @@ -22,7 +22,12 @@ export const provideUsersSuggestedGroupKeys = async ( const usersWithSuggestedKeys = []; for await (const user of usersSuggestedGroupKeys[roomId]) { - const { value } = await Subscriptions.setGroupE2ESuggestedKey(user._id, roomId, user.key); + const { value } = await Subscriptions.setGroupE2ESuggestedKeyAndOldRoomKeys( + user._id, + roomId, + user.key, + parseOldKeysDates(user.oldKeys), + ); if (!value) { continue; } @@ -34,3 +39,11 @@ export const provideUsersSuggestedGroupKeys = async ( void notifyOnRoomChangedById(roomId); } }; + +const parseOldKeysDates = (oldKeys: ISubscription['oldRoomKeys']) => { + if (!oldKeys) { + return; + } + + return oldKeys.map((key) => ({ ...key, ts: new Date(key.ts) })); +}; diff --git a/apps/meteor/app/e2e/server/functions/resetRoomKey.ts b/apps/meteor/app/e2e/server/functions/resetRoomKey.ts new file mode 100644 index 000000000000..635988cb6acf --- /dev/null +++ b/apps/meteor/app/e2e/server/functions/resetRoomKey.ts @@ -0,0 +1,123 @@ +import type { ISubscription, IUser, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; +import type { AnyBulkWriteOperation } from 'mongodb'; + +import { notifyOnRoomChanged, notifyOnSubscriptionChanged } from '../../../lib/server/lib/notifyListener'; + +export async function resetRoomKey(roomId: string, userId: string, newRoomKey: string, newRoomKeyId: string) { + const user = await Users.findOneById>(userId, { projection: { e2e: 1 } }); + if (!user) { + throw new Error('error-user-not-found'); + } + + if (!user.e2e?.private_key || !user.e2e?.public_key) { + throw new Error('error-user-has-no-keys'); + } + + const room = await Rooms.findOneById>(roomId, { projection: { e2eKeyId: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + + if (!room.e2eKeyId) { + throw new Error('error-room-not-encrypted'); + } + + // We will update the subs of everyone who has a key for the room. For the ones that don't have, we will do nothing + const notifySubs = []; + const updateOps: AnyBulkWriteOperation[] = []; + const e2eQueue: IRoom['usersWaitingForE2EKeys'] = []; + + for await (const sub of Subscriptions.find({ + rid: roomId, + $or: [{ E2EKey: { $exists: true } }, { E2ESuggestedKey: { $exists: true } }], + })) { + // This replicates the oldRoomKeys array modifications allowing us to have the changes locally without finding them again + // which allows for quicker notifying + const keys = replicateMongoSlice(room.e2eKeyId, sub); + delete sub.E2ESuggestedKey; + delete sub.E2EKey; + delete sub.suggestedOldRoomKeys; + + const updateSet = { + $set: { + ...(keys && { oldRoomKeys: keys }), + }, + }; + updateOps.push({ + updateOne: { + filter: { _id: sub._id }, + update: { + $unset: { E2EKey: 1, E2ESuggestedKey: 1, suggestedOldRoomKeys: 1 }, + ...(Object.keys(updateSet.$set).length && updateSet), + }, + }, + }); + + if (userId !== sub.u._id) { + // Avoid notifying requesting user as notify will happen at the end + notifySubs.push({ + ...sub, + ...(keys && { oldRoomKeys: keys }), + }); + + // This is for allowing the key distribution process to start inmediately + pushToLimit(e2eQueue, { userId: sub.u._id, ts: new Date() }); + } + + if (updateOps.length >= 100) { + await writeAndNotify(updateOps, notifySubs); + } + } + + if (updateOps.length > 0) { + await writeAndNotify(updateOps, notifySubs); + } + + // after the old keys have been moved to the new prop, store room key on room + the e2e queue so key can be exchanged + // todo move to model method + const roomResult = await Rooms.resetRoomKeyAndSetE2EEQueueByRoomId(roomId, newRoomKeyId, e2eQueue); + // And set the new key to the user that called the func + const result = await Subscriptions.setE2EKeyByUserIdAndRoomId(userId, roomId, newRoomKey); + + if (result.value) { + void notifyOnSubscriptionChanged(result.value); + } + if (roomResult.value) { + void notifyOnRoomChanged(roomResult.value); + } +} + +function pushToLimit( + arr: NonNullable, + item: NonNullable[number], + limit = 50, +) { + if (arr.length < limit) { + arr.push(item); + } +} + +async function writeAndNotify(updateOps: AnyBulkWriteOperation[], notifySubs: ISubscription[]) { + await Subscriptions.col.bulkWrite(updateOps); + notifySubs.forEach((sub) => { + void notifyOnSubscriptionChanged(sub); + }); + notifySubs.length = 0; + updateOps.length = 0; +} + +function replicateMongoSlice(keyId: string, sub: ISubscription) { + if (!sub.E2EKey) { + return; + } + + if (!sub.oldRoomKeys) { + return [{ e2eKeyId: keyId, ts: new Date(), E2EKey: sub.E2EKey }]; + } + + const sortedKeys = sub.oldRoomKeys.toSorted((a, b) => b.ts.getTime() - a.ts.getTime()); + sortedKeys.unshift({ e2eKeyId: keyId, ts: new Date(), E2EKey: sub.E2EKey }); + + return sortedKeys.slice(0, 10); +} diff --git a/apps/meteor/lib/publishFields.ts b/apps/meteor/lib/publishFields.ts index 48d0f9c87d5c..b567711bdf76 100644 --- a/apps/meteor/lib/publishFields.ts +++ b/apps/meteor/lib/publishFields.ts @@ -37,6 +37,7 @@ export const subscriptionFields = { ignored: 1, E2EKey: 1, E2ESuggestedKey: 1, + oldRoomKeys: 1, tunread: 1, tunreadGroup: 1, tunreadUser: 1, diff --git a/apps/meteor/package.json b/apps/meteor/package.json index aeb8d863444c..02689600e4be 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -336,6 +336,7 @@ "esl": "github:pierre-lehnen-rc/esl", "eventemitter3": "^4.0.7", "exif-be-gone": "^1.3.2", + "expiry-map": "^2.0.0", "express": "^4.17.3", "express-rate-limit": "^5.5.1", "fastq": "^1.13.0", diff --git a/apps/meteor/server/models/raw/Rooms.ts b/apps/meteor/server/models/raw/Rooms.ts index 7d4a0a54dedf..e09b64a49cf5 100644 --- a/apps/meteor/server/models/raw/Rooms.ts +++ b/apps/meteor/server/models/raw/Rooms.ts @@ -25,6 +25,7 @@ import type { UpdateFilter, UpdateOptions, UpdateResult, + ModifyResult, } from 'mongodb'; import { readSecondaryPreferred } from '../../database/readSecondaryPreferred'; @@ -2144,4 +2145,16 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, ]); } + + resetRoomKeyAndSetE2EEQueueByRoomId( + roomId: string, + e2eKeyId: string, + e2eQueue?: IRoom['usersWaitingForE2EKeys'], + ): Promise> { + return this.findOneAndUpdate( + { _id: roomId }, + { $set: { e2eKeyId, ...(Array.isArray(e2eQueue) && { usersWaitingForE2EKeys: e2eQueue }) } }, + { returnDocument: 'after' }, + ); + } } diff --git a/apps/meteor/server/models/raw/Subscriptions.ts b/apps/meteor/server/models/raw/Subscriptions.ts index 02eb7927ed4d..829261e7f94a 100644 --- a/apps/meteor/server/models/raw/Subscriptions.ts +++ b/apps/meteor/server/models/raw/Subscriptions.ts @@ -580,6 +580,12 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.updateMany(query, update); } + async setGroupE2EKeyAndOldRoomKeys(_id: string, key: string, oldRoomKeys?: ISubscription['oldRoomKeys']): Promise { + const query = { _id }; + const update = { $set: { E2EKey: key, ...(oldRoomKeys && { oldRoomKeys }) } }; + return this.updateOne(query, update); + } + async setGroupE2EKey(_id: string, key: string): Promise { const query = { _id }; const update = { $set: { E2EKey: key } }; @@ -592,9 +598,27 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri return this.findOneAndUpdate(query, update, { returnDocument: 'after' }); } - unsetGroupE2ESuggestedKey(_id: string): Promise { + setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise> { + const query = { rid, 'u._id': userId }; + const update = { $set: { E2EKey: key } }; + + return this.findOneAndUpdate(query, update, { returnDocument: 'after' }); + } + + setGroupE2ESuggestedKeyAndOldRoomKeys( + uid: string, + rid: string, + key: string, + suggestedOldRoomKeys?: ISubscription['suggestedOldRoomKeys'], + ): Promise> { + const query = { rid, 'u._id': uid }; + const update = { $set: { E2ESuggestedKey: key, ...(suggestedOldRoomKeys && { suggestedOldRoomKeys }) } }; + return this.findOneAndUpdate(query, update, { returnDocument: 'after' }); + } + + unsetGroupE2ESuggestedKeyAndOldRoomKeys(_id: string): Promise { const query = { _id }; - return this.updateOne(query, { $unset: { E2ESuggestedKey: 1 } }); + return this.updateOne(query, { $unset: { E2ESuggestedKey: 1, suggestedOldRoomKeys: 1 } }); } setOnHoldByRoomId(rid: string): Promise { @@ -991,6 +1015,7 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri $unset: { E2EKey: '', E2ESuggestedKey: 1, + oldRoomKeys: 1, }, }, ); diff --git a/apps/meteor/server/modules/watchers/watchers.module.ts b/apps/meteor/server/modules/watchers/watchers.module.ts index c42b24d35b7e..875c9a34d904 100644 --- a/apps/meteor/server/modules/watchers/watchers.module.ts +++ b/apps/meteor/server/modules/watchers/watchers.module.ts @@ -133,6 +133,7 @@ export function initWatchers(watcher: DatabaseWatcher, broadcast: BroadcastCallb | 'ignored' | 'E2EKey' | 'E2ESuggestedKey' + | 'oldRoomKeys' | 'tunread' | 'tunreadGroup' | 'tunreadUser' diff --git a/packages/core-services/src/events/Events.ts b/packages/core-services/src/events/Events.ts index 7d023925cef6..f149aec9f9ac 100644 --- a/packages/core-services/src/events/Events.ts +++ b/packages/core-services/src/events/Events.ts @@ -211,6 +211,7 @@ export type EventSignatures = { | 'ignored' | 'E2EKey' | 'E2ESuggestedKey' + | 'oldRoomKeys' | 'tunread' | 'tunreadGroup' | 'tunreadUser' diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 673bddcd374c..742f63dac9c3 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -5,6 +5,8 @@ import type { RoomType } from './RoomType'; type RoomID = string; +export type OldKey = { e2eKeyId: string; ts: Date; E2EKey: string }; + export interface ISubscription extends IRocketChatRecord { u: Pick; v?: Pick & { token?: string }; @@ -68,6 +70,8 @@ export interface ISubscription extends IRocketChatRecord { /* @deprecated */ customFields?: Record; + oldRoomKeys?: OldKey[]; + suggestedOldRoomKeys?: OldKey[]; } export interface IOmnichannelSubscription extends ISubscription { diff --git a/packages/ddp-client/src/types/streams.ts b/packages/ddp-client/src/types/streams.ts index 8d9d3b5302dd..99aa9b77c7c9 100644 --- a/packages/ddp-client/src/types/streams.ts +++ b/packages/ddp-client/src/types/streams.ts @@ -142,6 +142,7 @@ export interface StreamerEvents { | 'ignored' | 'E2EKey' | 'E2ESuggestedKey' + | 'oldRoomKeys' | 'tunread' | 'tunreadGroup' | 'tunreadUser' diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index e717f48d97a5..ac9ea870c8a1 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -1835,6 +1835,9 @@ "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", + "E2E_indecipherable": "This message is end-to-end encrypted and cannot be decrypted due to multiple room key resets", + "E2E_Key_Error": "This message is end-to-end encrypted and cannot be decrypted due to incorrect encryption key", + "E2E_Invalid_Key": "No E2E encryption key found for this room", "ECDH_Enabled": "Enable second layer encryption for data transport", "Edit": "Edit", "Edit_team": "Edit team", diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 91802f836719..74d2fbe301e9 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -1,5 +1,14 @@ import type { IDirectMessageRoom, IMessage, IOmnichannelGenericRoom, IRoom, IRoomFederated, ITeam, IUser } from '@rocket.chat/core-typings'; -import type { AggregationCursor, DeleteResult, Document, FindCursor, FindOptions, UpdateOptions, UpdateResult } from 'mongodb'; +import type { + AggregationCursor, + DeleteResult, + Document, + FindCursor, + FindOptions, + UpdateOptions, + UpdateResult, + ModifyResult, +} from 'mongodb'; import type { Updater } from '../updater'; import type { FindPaginated, IBaseModel } from './IBaseModel'; @@ -290,4 +299,9 @@ export interface IRoomsModel extends IBaseModel { type?: 'channels' | 'discussions', options?: FindOptions, ): AggregationCursor<{ totalCount: { count: number }[]; paginatedResults: IRoom[] }>; + resetRoomKeyAndSetE2EEQueueByRoomId( + roomId: string, + e2eKeyId: string, + e2eQueue?: IRoom['usersWaitingForE2EKeys'], + ): Promise>; } diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 24fd36a073ba..27c5f80e20e7 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -116,9 +116,18 @@ export interface ISubscriptionsModel extends IBaseModel { setGroupE2EKey(_id: string, key: string): Promise; + setGroupE2EKeyAndOldRoomKeys(_id: string, key: string, oldRoomKeys: ISubscription['oldRoomKeys']): Promise; + setGroupE2ESuggestedKey(uid: string, rid: string, key: string): Promise>; - unsetGroupE2ESuggestedKey(_id: string): Promise; + setGroupE2ESuggestedKeyAndOldRoomKeys( + uid: string, + rid: string, + key: string, + suggestedOldRoomKeys: ISubscription['suggestedOldRoomKeys'], + ): Promise>; + + unsetGroupE2ESuggestedKeyAndOldRoomKeys(_id: string): Promise; setOnHoldByRoomId(roomId: string): Promise; unsetOnHoldByRoomId(roomId: string): Promise; @@ -311,4 +320,5 @@ export interface ISubscriptionsModel extends IBaseModel { openByRoomIdAndUserId(roomId: string, userId: string): Promise; countByRoomIdAndNotUserId(rid: string, uid: string): Promise; countByRoomIdWhenUsernameExists(rid: string): Promise; + setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; } diff --git a/packages/rest-typings/src/v1/e2e.ts b/packages/rest-typings/src/v1/e2e.ts index 1974445eb348..b065ac10386e 100644 --- a/packages/rest-typings/src/v1/e2e.ts +++ b/packages/rest-typings/src/v1/e2e.ts @@ -1,4 +1,4 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser, ISubscription } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; const ajv = new Ajv({ @@ -89,7 +89,7 @@ const E2eSetRoomKeyIdSchema = { export const isE2eSetRoomKeyIdProps = ajv.compile(E2eSetRoomKeyIdSchema); type E2EProvideUsersGroupKeyProps = { - usersSuggestedGroupKeys: Record; + usersSuggestedGroupKeys: Record; }; const E2EProvideUsersGroupKeySchema = { @@ -104,6 +104,13 @@ const E2EProvideUsersGroupKeySchema = { properties: { _id: { type: 'string' }, key: { type: 'string' }, + oldKeys: { + type: 'array', + items: { + type: 'object', + properties: { e2eKeyId: { type: 'string' }, ts: { type: 'string' }, E2EKey: { type: 'string' } }, + }, + }, }, required: ['_id', 'key'], additionalProperties: false, @@ -137,6 +144,31 @@ export const isE2EFetchUsersWaitingForGroupKeyProps = ajv.compile(E2EResetRoomKeySchema); + export type E2eEndpoints = { '/v1/e2e.setUserPublicAndPrivateKeys': { POST: (params: E2eSetUserPublicAndPrivateKeysProps) => void; @@ -169,4 +201,7 @@ export type E2eEndpoints = { '/v1/e2e.provideUsersSuggestedGroupKeys': { POST: (params: E2EProvideUsersGroupKeyProps) => void; }; + '/v1/e2e.resetRoomKey': { + POST: (params: E2EResetRoomKeyProps) => void; + }; };