import type { IE2EEMessage, IMessage, IRoom, IUser, IUploadWithUser, Serialized, IE2EEPinnedMessage } from '@rocket.chat/core-typings'; import { isE2EEMessage, isEncryptedMessageContent } from '@rocket.chat/core-typings'; import { Emitter } from '@rocket.chat/emitter'; import { isTruthy } from '@rocket.chat/tools'; import { imperativeModal } from '@rocket.chat/ui-client'; import type { SubscriptionWithRoom } from '@rocket.chat/ui-contexts'; import sampleSize from 'lodash/sampleSize'; import type { E2EEState } from './E2EEState'; import * as Rsa from './crypto/rsa'; import { generatePassphrase } from './helper'; import { Keychain } from './keychain'; import { createLogger } from './logger'; import { E2ERoom } from './rocketchat.e2e.room'; import { t } from '../../../app/utils/lib/i18n'; import { createQuoteAttachment } from '../../../lib/createQuoteAttachment'; import { getMessageUrlRegex } from '../../../lib/getMessageUrlRegex'; import { Rooms, Subscriptions } from '../../stores'; import EnterE2EPasswordModal from '../../views/e2e/EnterE2EPasswordModal'; import SaveE2EPasswordModal from '../../views/e2e/SaveE2EPasswordModal'; import { sdk } from '../SDKClient'; import * as banners from '../banners'; import type { LegacyBannerPayload } from '../banners'; import { getUserAvatarURL } from '../getUserAvatarURL'; import { limitQuoteChain } from '../limitQuoteChain'; import { getDdpSdk } from '../sdk/ddpSdk'; import { STORAGE_KEYS, getStoredItem, removeStoredItem, setStoredItem } from '../sdk/storage'; import { settings } from '../settings'; import { dispatchToastMessage } from '../toast'; import { mapMessageFromApi } from '../utils/mapMessageFromApi'; let failedToDecodeKey = false; const log = createLogger('E2E'); type KeyPair = { public_key: string | null; private_key: string | null; }; const ROOM_KEY_EXCHANGE_SIZE = 10; class E2E extends Emitter { private userId: string | false = false; private keychain: Keychain; private instancesByRoomId: Record = {}; private db_public_key: string | null | undefined; private db_private_key: string | null | undefined; public privateKey: Rsa.PrivateKey | undefined; public publicKey: string | undefined; private keyDistributionInterval: ReturnType | null = null; private state: E2EEState; constructor() { super(); this.on('READY', async () => { await this.onE2EEReady(); }); this.on('SAVE_PASSWORD', async () => { await this.onE2EEReady(); }); this.on('DISABLED', () => { this.unsubscribeFromSubscriptions?.(); }); this.on('NOT_STARTED', () => { this.unsubscribeFromSubscriptions?.(); }); this.on('ERROR', () => { this.unsubscribeFromSubscriptions?.(); }); this.setState('NOT_STARTED'); } getState() { return this.state; } isEnabled(): boolean { return this.state !== 'DISABLED'; } isReady(): boolean { // Save_Password state is also a ready state for E2EE return this.state === 'READY' || this.state === 'SAVE_PASSWORD'; } async onE2EEReady() { this.initiateHandshake(); await this.handleAsyncE2EESuggestedKey(); await this.decryptSubscriptions(); await this.initiateKeyDistribution(); this.observeSubscriptions(); } async onSubscriptionChanged(sub: SubscriptionWithRoom): Promise { const span = log.span('onSubscriptionChanged').set('subscription_id', sub._id).set('room_id', sub.rid).set('encrypted', sub.encrypted); if (!sub.encrypted && !sub.E2EKey) { this.removeInstanceByRoomId(sub.rid); return; } const e2eRoom = await this.getInstanceByRoomId(sub.rid); if (!e2eRoom) { span.warn('no e2eRoom found'); return; } if (sub.E2ESuggestedKey) { if (await e2eRoom.importGroupKey(sub.E2ESuggestedKey)) { await this.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { span.warn('rejected'); await this.rejectSuggestedKey(sub.rid); } } if (sub.encrypted) { e2eRoom.resume(); } else { 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; } if (sub.lastMessage?.e2e !== 'done') { await e2eRoom.decryptSubscription(); } } private unsubscribeFromSubscriptions: (() => void) | undefined; observeSubscriptions() { const span = log.span('observeSubscriptions'); this.unsubscribeFromSubscriptions?.(); this.unsubscribeFromSubscriptions = Subscriptions.use.subscribe((state) => { const subscriptions = Array.from(state.records.values()).filter((sub) => sub.encrypted || sub.E2EKey); const subscribed = new Set(subscriptions.map((sub) => sub.rid)); const instatiated = new Set(Object.keys(this.instancesByRoomId)); const excess = instatiated.difference(subscribed); if (excess.size) { span.info('Unsubscribing from excess instances'); excess.forEach((rid) => this.removeInstanceByRoomId(rid)); } for (const sub of subscriptions) { void this.onSubscriptionChanged(sub); } }); } setState(nextState: E2EEState) { const span = log.span('setState').set('prevState', this.state).set('nextState', nextState); const prevState = this.state; this.state = nextState; span.info(`${prevState} -> ${nextState}`); this.emit('E2E_STATE_CHANGED', { prevState, nextState }); this.emit(nextState); } async handleAsyncE2EESuggestedKey() { const span = log.span('handleAsyncE2EESuggestedKey'); const subs = Subscriptions.state.filter((sub) => typeof sub.E2ESuggestedKey !== 'undefined'); await Promise.all( subs .filter((sub) => sub.E2ESuggestedKey && !sub.E2EKey) .map(async (sub) => { const e2eRoom = await e2e.getInstanceByRoomId(sub.rid); span.set('subscription_id', sub._id).set('room_id', sub.rid); if (!e2eRoom) { return; } if (sub.E2ESuggestedKey && (await e2eRoom.importGroupKey(sub.E2ESuggestedKey))) { span.info('importedE2ESuggestedKey'); await e2e.acceptSuggestedKey(sub.rid); e2eRoom.keyReceived(); } else { span.error('invalidE2ESuggestedKey'); await e2e.rejectSuggestedKey(sub.rid); } if (sub.encrypted) { e2eRoom.resume(); } else { e2eRoom.pause(); } }), ); span.info('handledAsyncE2ESuggestedKey'); } private waitForRoom(rid: IRoom['_id']): Promise { return new Promise((resolve) => { const room = Rooms.state.get(rid); if (room) resolve(room); const unsubscribe = Rooms.use.subscribe((state) => { const room = state.get(rid); if (room) { unsubscribe(); resolve(room); } }); }); } async getInstanceByRoomId(rid: IRoom['_id']): Promise { if (!this.userId) { return null; } const room = await this.waitForRoom(rid); if (room.t !== 'd' && room.t !== 'p') { return null; } if (!room.encrypted) { return null; } if (!this.instancesByRoomId[rid] && this.userId) { this.instancesByRoomId[rid] = new E2ERoom(this.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] ?? null; } removeInstanceByRoomId(rid: IRoom['_id']): void { delete this.instancesByRoomId[rid]; } private async persistKeys( { public_key, private_key }: KeyPair, password: string, { force }: { force: boolean } = { force: false }, ): Promise { if (typeof public_key !== 'string' || typeof private_key !== 'string') { throw new Error('Failed to persist keys as they are not strings.'); } const encodedPrivateKey = await this.keychain.encryptKey(private_key, password); if (!encodedPrivateKey) { throw new Error('Failed to encode private key with provided password.'); } await sdk.rest.post('/v1/e2e.setUserPublicAndPrivateKeys', { public_key, private_key: JSON.stringify(encodedPrivateKey), force, }); } async acceptSuggestedKey(rid: string): Promise { await sdk.rest.post('/v1/e2e.acceptSuggestedGroupKey', { rid, }); } async rejectSuggestedKey(rid: string): Promise { await sdk.rest.post('/v1/e2e.rejectSuggestedGroupKey', { rid, }); } getKeysFromLocalStorage(): KeyPair { return { public_key: getStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY), private_key: getStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY), }; } initiateHandshake() { Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake()); } openSaveE2EEPasswordModal(randomPassword: string) { imperativeModal.open({ component: SaveE2EPasswordModal, props: { randomPassword, onClose: imperativeModal.close, onCancel: () => { this.closeAlert(); imperativeModal.close(); }, onConfirm: () => { removeStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD); this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); this.closeAlert(); imperativeModal.close(); }, }, }); } async startClient(userId: string): Promise { if (!isSecureContext) { throw new Error('E2E encryption can only be enabled in secure contexts (HTTPS)'); } const span = log.span('startClient'); if (this.userId === userId) { return; } span.info(this.state); this.userId = userId; this.keychain = new Keychain(userId); let { public_key, private_key } = this.getKeysFromLocalStorage(); await this.loadKeysFromDB(); if (!public_key && this.db_public_key) { public_key = this.db_public_key; } if (this.db_private_key && !private_key) { try { this.setState('ENTER_PASSWORD'); private_key = await this.decodePrivateKey(this.db_private_key); } catch (error) { this.userId = false; failedToDecodeKey = true; this.openAlert({ title: "Wasn't possible to decode your encryption key to be imported.", // TODO: missing translation html: '
Your encryption password seems wrong. Click here to try again.
', // TODO: missing translation modifiers: ['large', 'danger'], closable: true, icon: 'key', action: async () => { await this.startClient(userId); this.closeAlert(); }, }); return span.error('E2E -> Error decoding private key: ', error); } } if (public_key && private_key) { await this.loadKeys({ public_key, private_key }); this.setState('READY'); } else { await this.createAndLoadKeys(); this.setState('READY'); } if (!this.db_public_key || !this.db_private_key) { this.setState('LOADING_KEYS'); await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword()); } const randomPassword = getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD); if (randomPassword) { this.setState('SAVE_PASSWORD'); this.openAlert({ title: () => t('Save_your_new_E2EE_password'), html: () => t('Click_here_to_view_and_save_your_new_E2EE_password'), modifiers: ['large'], closable: false, icon: 'key', action: () => this.openSaveE2EEPasswordModal(randomPassword), }); } } async stopClient(): Promise { const span = log.span('stopClient'); span.info(this.state); this.closeAlert(); removeStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY); removeStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY); this.instancesByRoomId = {}; this.privateKey = undefined; this.publicKey = undefined; this.userId = false; if (this.keyDistributionInterval) { clearInterval(this.keyDistributionInterval); } this.keyDistributionInterval = null; this.setState('DISABLED'); } async changePassword(newPassword: string): Promise { await this.persistKeys(this.getKeysFromLocalStorage(), newPassword, { force: true }); if (getStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD)) { setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, newPassword); } } async loadKeysFromDB(): Promise { const span = log.span('loadKeysFromDB'); try { this.setState('LOADING_KEYS'); const { public_key, private_key } = await sdk.rest.get('/v1/e2e.fetchMyKeys'); this.db_public_key = public_key; this.db_private_key = private_key; span.info('fetched keys from db'); } catch (error) { this.setState('ERROR'); span.error('Error fetching RSA keys: ', error); // Stop any process since we can't communicate with the server // to get the keys. This prevents new key generation throw error; } } async loadKeys({ public_key, private_key }: { public_key: string; private_key: string }): Promise { const span = log.span('loadKeys'); setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, public_key); this.publicKey = public_key; try { this.privateKey = await Rsa.importPrivateKey(JSON.parse(private_key)); setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, private_key); } catch (error) { this.setState('ERROR'); return span.error('Error importing private key: ', error); } } async createAndLoadKeys(): Promise { const span = log.span('createAndLoadKeys'); // Could not obtain public-private keypair from server. this.setState('LOADING_KEYS'); let keyPair; try { keyPair = await Rsa.generate(); this.privateKey = keyPair.privateKey; } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error generating key'); } try { const publicKey = await Rsa.exportPublicKey(keyPair.publicKey); this.publicKey = JSON.stringify(publicKey); setStoredItem(STORAGE_KEYS.E2EE_PUBLIC_KEY, JSON.stringify(publicKey)); } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error exporting public key'); } try { const privateKey = await Rsa.exportPrivateKey(keyPair.privateKey); setStoredItem(STORAGE_KEYS.E2EE_PRIVATE_KEY, JSON.stringify(privateKey)); } catch (error) { this.setState('ERROR'); return span.set('error', error).error('Error exporting private key'); } await this.requestSubscriptionKeys(); } async requestSubscriptionKeys(): Promise { await sdk.rest.post('/v1/e2e.requestSubscriptionKeys'); } async createRandomPassword(): Promise { const randomPassword = await generatePassphrase(); setStoredItem(STORAGE_KEYS.E2EE_RANDOM_PASSWORD, randomPassword); return randomPassword; } openEnterE2EEPasswordModal(onEnterE2EEPassword: (password: string) => Promise) { const close = () => { imperativeModal.close(); }; const dismiss = () => { this.closeAlert(); imperativeModal.close(); }; imperativeModal.open({ component: EnterE2EPasswordModal, props: { onClose: close, onCancel: () => { failedToDecodeKey = false; dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); dismiss(); }, onConfirm: async (password) => { await onEnterE2EEPassword(password); dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); dismiss(); }, }, }); } async requestPasswordAlert(validatePassword: (password: string) => Promise): Promise { return new Promise((resolve) => { const showModal = () => this.openEnterE2EEPasswordModal(async (password) => { await validatePassword(password); resolve(); }); const showAlert = () => { this.openAlert({ title: () => t('Enter_your_E2E_password'), html: () => t('Click_here_to_enter_your_password'), modifiers: ['large'], closable: false, icon: 'key', action() { showModal(); }, }); }; if (failedToDecodeKey) { showModal(); } else { showAlert(); } }); } async requestPasswordModal(validatePassword: (password: string) => Promise): Promise { return new Promise((resolve) => { this.openEnterE2EEPasswordModal(async (password) => { await validatePassword(password); resolve(); }); }); } async decodePrivateKeyFlow() { if (!this.db_private_key) { return; } try { let privateKey: string | undefined; await this.requestPasswordModal(async (password) => { privateKey = await this.keychain.decryptKey(this.db_private_key as string, password); }); if (this.db_public_key && privateKey) { await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey }); } else { await this.createAndLoadKeys(); } this.setState('READY'); dispatchToastMessage({ type: 'success', message: t('E2E_encryption_enabled') }); } catch (error) { this.setState('ENTER_PASSWORD'); dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') }); throw new Error('E2E -> Error loading keys', { cause: error }); } } async decodePrivateKey(privateKey: string): Promise { let decryptedKey: string | undefined; await this.requestPasswordAlert(async (password) => { decryptedKey = await this.keychain.decryptKey(privateKey, password); }); return decryptedKey as string; } async decryptFileContent(file: IUploadWithUser): Promise { if (!file.rid || !isEncryptedMessageContent(file)) { return file; } const e2eRoom = await this.getInstanceByRoomId(file.rid); if (!e2eRoom) { return file; } return e2eRoom.decryptContent(file); } async decryptMessage(message: IMessage | IE2EEMessage): Promise { if (!isE2EEMessage(message) || message.e2e === 'done') { return message; } const e2eRoom = await this.getInstanceByRoomId(message.rid); if (!e2eRoom) { return message; } const decryptedMessage = (await e2eRoom.decryptMessage(message)) as IE2EEMessage; const decryptedMessageWithQuote = await this.parseQuoteAttachment(decryptedMessage); return decryptedMessageWithQuote; } async decryptPinnedMessage(message: IE2EEPinnedMessage) { const span = log.span('decryptPinnedMessage'); const [pinnedMessage] = message.attachments; if (!pinnedMessage) { span.warn('No pinned message found'); return message; } const e2eRoom = await this.getInstanceByRoomId(message.rid); if (!e2eRoom) { span.warn('No e2eRoom found'); return message; } const data = await e2eRoom.decrypt(pinnedMessage.content); // TODO(@cardoso): review backward compatibility message.attachments[0].text = data.msg; span.info('pinned message decrypted'); return message; } async decryptSubscription(subscription: SubscriptionWithRoom): Promise { const span = log.span('decryptSubscription'); const e2eRoom = await this.getInstanceByRoomId(subscription.rid); span.info(subscription._id); if (!e2eRoom) { span.warn('no e2eRoom found'); return; } if (e2eRoom.isReady()) { span.info('e2e room ready'); await e2eRoom.decryptSubscription(); return; } e2eRoom.once('READY', async () => { span.info('e2e room ready'); await e2eRoom.decryptSubscription(); }); } async decryptSubscriptions(): Promise { const subscriptions = Subscriptions.state.filter((subscription) => !!subscription.encrypted); await Promise.all( subscriptions.map(async (subscription) => { await this.decryptSubscription(subscription); }), ); } openAlert(config: Omit): void { banners.open({ id: 'e2e', ...config }); } closeAlert(): void { banners.closeById('e2e'); } async parseQuoteAttachment(message: IE2EEMessage): Promise { if (!message?.msg) { return message; } const urls = message.msg.match(getMessageUrlRegex()) || []; const siteUrl = settings.peek('Site_Url'); await Promise.all( urls.map(async (url) => { if (siteUrl && !url.includes(siteUrl)) { return; } // URLs come from regex-matched message content, so they may be malformed; skip those instead of throwing let urlObj: URL; try { urlObj = new URL(url); } catch (error) { return; } const [msgId, ...extraMsgIds] = urlObj.searchParams.getAll('msg'); // skip if the msg param is missing, empty, or duplicated if (!msgId || extraMsgIds.length) { return; } let quotedMessage: Serialized; try { const getQuotedMessage = await sdk.rest.get('/v1/chat.getMessage', { msgId }); quotedMessage = getQuotedMessage?.message; } catch (error) { console.error(`Error getting quoted message: ${error}`); return; } if (!quotedMessage) { return; } const decryptedQuoteMessage = await this.decryptMessage(mapMessageFromApi(quotedMessage)); message.attachments = message.attachments || []; const useRealName = settings.peek('UI_Use_Real_Name'); const quoteAttachment = createQuoteAttachment( decryptedQuoteMessage, url, useRealName, getUserAvatarURL(decryptedQuoteMessage.u.username || '') as string, ); message.attachments.push(limitQuoteChain(quoteAttachment, settings.peek('Message_QuoteChainLimit') ?? 2)); }), ); 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; } getUserId(): string { if (!this.userId) { throw new Error('No userId found'); } return this.userId; } async initiateKeyDistribution() { if (this.keyDistributionInterval) { return; } const predicate = (record: IRoom) => Boolean('usersWaitingForE2EKeys' in record && record.usersWaitingForE2EKeys?.every((user) => user.userId !== this.getUserId())); const span = log.span('initiateKeyDistribution'); const keyDistribution = async () => { const roomIds = Rooms.state.filter(predicate).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 span.error('provideUsersSuggestedGroupKeys', 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(); getDdpSdk().account.onLogout(() => { void e2e.stopClient(); });