feat: Un-encrypted messages not allowed in E2EE rooms (#32040)

Co-authored-by: gabriellsh <40830821+gabriellsh@users.noreply.github.com>
Co-authored-by: Hugo Costa <20212776+hugocostadev@users.noreply.github.com>
pull/32489/head^2
Yash Rajpal 2 years ago committed by GitHub
parent a565999ae0
commit 4fd9c4cbaa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 8
      .changeset/slow-cars-press.md
  2. 9
      apps/meteor/app/e2e/client/E2EEState.ts
  3. 15
      apps/meteor/app/e2e/client/rocketchat.e2e.room.js
  4. 225
      apps/meteor/app/e2e/client/rocketchat.e2e.ts
  5. 8
      apps/meteor/app/lib/server/methods/sendMessage.ts
  6. 10
      apps/meteor/client/hooks/roomActions/useE2EERoomAction.ts
  7. 14
      apps/meteor/client/startup/e2e.ts
  8. 2
      apps/meteor/client/views/e2e/SaveE2EPasswordModal.tsx
  9. 58
      apps/meteor/client/views/room/E2EESetup/RoomE2EENotAllowed.tsx
  10. 69
      apps/meteor/client/views/room/E2EESetup/RoomE2EESetup.tsx
  11. 8
      apps/meteor/client/views/room/Room.tsx
  12. 8
      apps/meteor/client/views/room/contextualBar/Info/EditRoomInfo/useEditRoomPermissions.ts
  13. 9
      apps/meteor/client/views/room/hooks/useE2EERoom.ts
  14. 20
      apps/meteor/client/views/room/hooks/useE2EERoomState.ts
  15. 9
      apps/meteor/client/views/room/hooks/useE2EEState.ts
  16. 4
      apps/meteor/client/views/room/providers/ChatProvider.tsx
  17. 12
      apps/meteor/client/views/room/providers/hooks/useChatMessagesInstance.ts
  18. 6
      apps/meteor/server/settings/e2e.ts
  19. 216
      apps/meteor/tests/e2e/e2e-encryption.spec.ts
  20. 11
      apps/meteor/tests/e2e/page-objects/fragments/home-sidenav.ts
  21. 72
      apps/meteor/tests/end-to-end/api/03-groups.js
  22. 13
      packages/i18n/src/locales/en.i18n.json

@ -0,0 +1,8 @@
---
'@rocket.chat/i18n': minor
'@rocket.chat/meteor': minor
---
Introduced a new setting which doesn't allow users to access encrypted rooms until E2EE is configured and also doesn't allow users to send un-encrypted messages in encrypted rooms.
New room setup for E2EE feature which helps users to setup their E2EE keys and introduced states to E2EE feature.

@ -0,0 +1,9 @@
export enum E2EEState {
NOT_STARTED = 'NOT_STARTED',
DISABLED = 'DISABLED',
LOADING_KEYS = 'LOADING_KEYS',
READY = 'READY',
SAVE_PASSWORD = 'SAVE_PASSWORD',
ENTER_PASSWORD = 'ENTER_PASSWORD',
ERROR = 'ERROR',
}

@ -41,6 +41,7 @@ const permitedMutations = {
E2ERoomState.ERROR,
E2ERoomState.DISABLED,
E2ERoomState.WAITING_KEYS,
E2ERoomState.CREATING_KEYS,
],
};
@ -92,6 +93,10 @@ export class E2ERoom extends Emitter {
logError(`E2E ROOM { state: ${this.state}, rid: ${this.roomId} }`, ...msg);
}
getState() {
return this.state;
}
setState(requestedState) {
const currentState = this.state;
const nextState = filterMutation(currentState, requestedState);
@ -208,6 +213,10 @@ export class E2ERoom extends Emitter {
// Initiates E2E Encryption
async handshake() {
if (!e2e.isReady()) {
return;
}
if (this.state !== E2ERoomState.KEYS_RECEIVED && this.state !== E2ERoomState.NOT_STARTED) {
return;
}
@ -459,5 +468,11 @@ export class E2ERoom extends Emitter {
}
this.encryptKeyForOtherParticipants();
this.setState(E2ERoomState.READY);
}
onStateChange(cb) {
this.on('STATE_CHANGED', cb);
return () => this.off('STATE_CHANGED', cb);
}
}

@ -6,8 +6,7 @@ import { isE2EEMessage } from '@rocket.chat/core-typings';
import { Emitter } from '@rocket.chat/emitter';
import EJSON from 'ejson';
import { Meteor } from 'meteor/meteor';
import type { ReactiveVar as ReactiveVarType } from 'meteor/reactive-var';
import { ReactiveVar } from 'meteor/reactive-var';
import { Tracker } from 'meteor/tracker';
import * as banners from '../../../client/lib/banners';
import type { LegacyBannerPayload } from '../../../client/lib/banners';
@ -24,6 +23,7 @@ import { settings } from '../../settings/client';
import { getUserAvatarURL } from '../../utils/client';
import { sdk } from '../../utils/client/lib/SDKClient';
import { t } from '../../utils/lib/i18n';
import { E2EEState } from './E2EEState';
import {
toString,
toArrayBuffer,
@ -49,36 +49,39 @@ type KeyPair = {
private_key: string | null;
};
const E2EEStateDependency = new Tracker.Dependency();
class E2E extends Emitter {
private started: boolean;
public enabled: ReactiveVarType<boolean>;
private _ready: ReactiveVarType<boolean>;
private instancesByRoomId: Record<IRoom['_id'], E2ERoom>;
private db_public_key: string | null;
private db_public_key: string | null | undefined;
private db_private_key: string | null;
private db_private_key: string | null | undefined;
public privateKey: CryptoKey | undefined;
private state: E2EEState;
constructor() {
super();
this.started = false;
this.enabled = new ReactiveVar(false);
this._ready = new ReactiveVar(false);
this.instancesByRoomId = {};
this.on('ready', async () => {
this._ready.set(true);
this.log('startClient -> Done');
this.log('decryptSubscriptions');
this.on('E2E_STATE_CHANGED', ({ prevState, nextState }) => {
this.log(`${prevState} -> ${nextState}`);
});
this.on(E2EEState.READY, async () => {
await this.onE2EEReady();
});
await this.decryptSubscriptions();
this.log('decryptSubscriptions -> Done');
this.on(E2EEState.SAVE_PASSWORD, async () => {
await this.onE2EEReady();
});
this.setState(E2EEState.NOT_STARTED);
}
log(...msg: unknown[]) {
@ -89,12 +92,46 @@ class E2E extends Emitter {
logError('E2E', ...msg);
}
getState() {
return this.state;
}
isEnabled(): boolean {
return this.enabled.get();
return this.state !== E2EEState.DISABLED;
}
isReady(): boolean {
return this.enabled.get() && this._ready.get();
E2EEStateDependency.depend();
// Save_Password state is also a ready state for E2EE
return this.state === E2EEState.READY || this.state === E2EEState.SAVE_PASSWORD;
}
async onE2EEReady() {
this.log('startClient -> Done');
this.log('decryptSubscriptions');
this.initiateHandshake();
await this.decryptSubscriptions();
this.log('decryptSubscriptions -> Done');
await this.initiateDecryptingPendingMessages();
this.log('DecryptingPendingMessages -> Done');
}
shouldAskForE2EEPassword() {
const { private_key } = this.getKeysFromLocalStorage();
return this.db_private_key && !private_key;
}
setState(nextState: E2EEState) {
const prevState = this.state;
this.state = nextState;
E2EEStateDependency.changed();
this.emit('E2E_STATE_CHANGED', { prevState, nextState });
this.emit(nextState);
}
async getInstanceByRoomId(rid: IRoom['_id']): Promise<E2ERoom | null> {
@ -155,6 +192,35 @@ class E2E extends Emitter {
};
}
initiateHandshake() {
Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].handshake());
}
async initiateDecryptingPendingMessages() {
await Promise.all(Object.keys(this.instancesByRoomId).map((key) => this.instancesByRoomId[key].decryptPendingMessages()));
}
openSaveE2EEPasswordModal(randomPassword: string) {
imperativeModal.open({
component: SaveE2EPasswordModal,
props: {
randomPassword,
onClose: imperativeModal.close,
onCancel: () => {
this.closeAlert();
imperativeModal.close();
},
onConfirm: () => {
Meteor._localStorage.removeItem('e2e.randomPassword');
this.setState(E2EEState.READY);
dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') });
this.closeAlert();
imperativeModal.close();
},
},
});
}
async startClient(): Promise<void> {
if (this.started) {
return;
@ -172,9 +238,10 @@ class E2E extends Emitter {
public_key = this.db_public_key;
}
if (!private_key && this.db_private_key) {
if (this.shouldAskForE2EEPassword()) {
try {
private_key = await this.decodePrivateKey(this.db_private_key);
this.setState(E2EEState.ENTER_PASSWORD);
private_key = await this.decodePrivateKey(this.db_private_key as string);
} catch (error) {
this.started = false;
failedToDecodeKey = true;
@ -195,44 +262,29 @@ class E2E extends Emitter {
if (public_key && private_key) {
await this.loadKeys({ public_key, private_key });
this.setState(E2EEState.READY);
} else {
await this.createAndLoadKeys();
this.setState(E2EEState.READY);
}
if (!this.db_public_key || !this.db_private_key) {
this.setState(E2EEState.LOADING_KEYS);
await this.persistKeys(this.getKeysFromLocalStorage(), await this.createRandomPassword());
}
const randomPassword = Meteor._localStorage.getItem('e2e.randomPassword');
if (randomPassword) {
this.setState(E2EEState.SAVE_PASSWORD);
this.openAlert({
title: () => t('Save_your_encryption_password'),
html: () => t('Click_here_to_view_and_copy_your_password'),
modifiers: ['large'],
closable: false,
icon: 'key',
action: () => {
imperativeModal.open({
component: SaveE2EPasswordModal,
props: {
randomPassword,
onClose: imperativeModal.close,
onCancel: () => {
this.closeAlert();
imperativeModal.close();
},
onConfirm: () => {
Meteor._localStorage.removeItem('e2e.randomPassword');
this.closeAlert();
dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Set') });
imperativeModal.close();
},
},
});
},
action: () => this.openSaveE2EEPasswordModal(randomPassword),
});
}
this.emit('ready');
}
async stopClient(): Promise<void> {
@ -243,9 +295,8 @@ class E2E extends Emitter {
Meteor._localStorage.removeItem('private_key');
this.instancesByRoomId = {};
this.privateKey = undefined;
this.enabled.set(false);
this._ready.set(false);
this.started = false;
this.setState(E2EEState.DISABLED);
}
async changePassword(newPassword: string): Promise<void> {
@ -258,11 +309,13 @@ class E2E extends Emitter {
async loadKeysFromDB(): Promise<void> {
try {
this.setState(E2EEState.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;
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error fetching RSA keys: ', error);
}
}
@ -275,17 +328,20 @@ class E2E extends Emitter {
Meteor._localStorage.setItem('private_key', private_key);
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error importing private key: ', error);
}
}
async createAndLoadKeys(): Promise<void> {
// Could not obtain public-private keypair from server.
this.setState(E2EEState.LOADING_KEYS);
let key;
try {
key = await generateRSAKey();
this.privateKey = key.privateKey;
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error generating key: ', error);
}
@ -294,6 +350,7 @@ class E2E extends Emitter {
Meteor._localStorage.setItem('public_key', JSON.stringify(publicKey));
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error exporting public key: ', error);
}
@ -302,6 +359,7 @@ class E2E extends Emitter {
Meteor._localStorage.setItem('private_key', JSON.stringify(privateKey));
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error exporting private key: ', error);
}
@ -327,6 +385,7 @@ class E2E extends Emitter {
return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey));
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error encrypting encodedPrivateKey: ', error);
}
}
@ -341,6 +400,7 @@ class E2E extends Emitter {
try {
baseKey = await importRawKey(toArrayBuffer(password));
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error creating a key based on user password: ', error);
}
@ -348,30 +408,34 @@ class E2E extends Emitter {
try {
return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey);
} catch (error) {
this.setState(E2EEState.ERROR);
return this.error('Error deriving baseKey: ', error);
}
}
async requestPassword(): Promise<string> {
openEnterE2EEPasswordModal(onEnterE2EEPassword?: (password: string) => void) {
imperativeModal.open({
component: EnterE2EPasswordModal,
props: {
onClose: imperativeModal.close,
onCancel: () => {
failedToDecodeKey = false;
dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') });
this.closeAlert();
imperativeModal.close();
},
onConfirm: (password) => {
onEnterE2EEPassword?.(password);
this.closeAlert();
imperativeModal.close();
},
},
});
}
async requestPasswordAlert(): Promise<string> {
return new Promise((resolve) => {
const showModal = () => {
imperativeModal.open({
component: EnterE2EPasswordModal,
props: {
onClose: imperativeModal.close,
onCancel: () => {
failedToDecodeKey = false;
this.closeAlert();
imperativeModal.close();
},
onConfirm: (password) => {
resolve(password);
this.closeAlert();
imperativeModal.close();
},
},
});
};
const showModal = () => this.openEnterE2EEPasswordModal((password) => resolve(password));
const showAlert = () => {
this.openAlert({
@ -394,8 +458,42 @@ class E2E extends Emitter {
});
}
async requestPasswordModal(): Promise<string> {
return new Promise((resolve) => this.openEnterE2EEPasswordModal((password) => resolve(password)));
}
async decodePrivateKeyFlow() {
const password = await this.requestPasswordModal();
const masterKey = await this.getMasterKey(password);
if (!this.db_private_key) {
return;
}
const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(this.db_private_key));
try {
const privKey = await decryptAES(vector, masterKey, cipherText);
const privateKey = toString(privKey) as string;
if (this.db_public_key && privateKey) {
await this.loadKeys({ public_key: this.db_public_key, private_key: privateKey });
this.setState(E2EEState.READY);
} else {
await this.createAndLoadKeys();
this.setState(E2EEState.READY);
}
dispatchToastMessage({ type: 'success', message: t('End_To_End_Encryption_Enabled') });
} catch (error) {
this.setState(E2EEState.ENTER_PASSWORD);
dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') });
dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') });
throw new Error('E2E -> Error decrypting private key');
}
}
async decodePrivateKey(privateKey: string): Promise<string> {
const password = await this.requestPassword();
const password = await this.requestPasswordAlert();
const masterKey = await this.getMasterKey(password);
@ -405,6 +503,9 @@ class E2E extends Emitter {
const privKey = await decryptAES(vector, masterKey, cipherText);
return toString(privKey);
} catch (error) {
this.setState(E2EEState.ENTER_PASSWORD);
dispatchToastMessage({ type: 'error', message: t('Your_E2EE_password_is_incorrect') });
dispatchToastMessage({ type: 'info', message: t('End_To_End_Encryption_Not_Enabled') });
throw new Error('E2E -> Error decrypting private key');
}
}

@ -81,6 +81,14 @@ export async function executeSendMessage(uid: IUser['_id'], message: AtLeast<IMe
try {
const room = await canSendMessageAsync(rid, { uid, username: user.username, type: user.type });
if (room.encrypted && settings.get<boolean>('E2E_Enable') && !settings.get<boolean>('E2E_Allow_Unencrypted_Messages')) {
if (message.t !== 'e2e' || message.e2e !== 'pending') {
throw new Meteor.Error('error-not-allowed', 'Not allowed to send un-encrypted messages in an encrypted room', {
method: 'sendMessage',
});
}
}
metrics.messagesSent.inc(); // TODO This line needs to be moved to it's proper place. See the comments on: https://github.com/RocketChat/Rocket.Chat/pull/5736
return await sendMessage(user, message, room, false, previewUrls);
} catch (err: any) {

@ -1,20 +1,22 @@
import { isRoomFederated } from '@rocket.chat/core-typings';
import { useMutableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetting, usePermission, useEndpoint } from '@rocket.chat/ui-contexts';
import { useCallback, useMemo } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { e2e } from '../../../app/e2e/client/rocketchat.e2e';
import { E2EEState } from '../../../app/e2e/client/E2EEState';
import { dispatchToastMessage } from '../../lib/toast';
import { useRoom, useRoomSubscription } from '../../views/room/contexts/RoomContext';
import type { RoomToolboxActionConfig } from '../../views/room/contexts/RoomToolboxContext';
import { useReactiveValue } from '../useReactiveValue';
import { useE2EEState } from '../../views/room/hooks/useE2EEState';
export const useE2EERoomAction = () => {
const enabled = useSetting('E2E_Enable', false);
const room = useRoom();
const subscription = useRoomSubscription();
const readyToEncrypt = useReactiveValue(useCallback(() => e2e.isReady(), [])) || room.encrypted;
const e2eeState = useE2EEState();
const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD;
const readyToEncrypt = isE2EEReady || room.encrypted;
const permittedToToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id);
const permittedToEditRoom = usePermission('edit-room', room._id);
const permitted = (room.t === 'd' || (permittedToEditRoom && permittedToToggleEncryption)) && readyToEncrypt;

@ -2,6 +2,7 @@ import type { AtLeast, IMessage, ISubscription } 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 { settings } from '../../app/settings/client';
@ -28,9 +29,8 @@ Meteor.startup(() => {
if (enabled && !adminEmbedded) {
e2e.startClient();
e2e.enabled.set(true);
} else {
e2e.enabled.set(false);
e2e.setState(E2EEState.DISABLED);
e2e.closeAlert();
}
});
@ -39,6 +39,8 @@ Meteor.startup(() => {
let offClientMessageReceived: undefined | (() => void);
let offClientBeforeSendMessage: undefined | (() => void);
let unsubNotifyUser: undefined | (() => void);
let listenersAttached = false;
Tracker.autorun(() => {
if (!e2e.isReady()) {
offClientMessageReceived?.();
@ -46,6 +48,11 @@ Meteor.startup(() => {
unsubNotifyUser = undefined;
observable?.stop();
offClientBeforeSendMessage?.();
listenersAttached = false;
return;
}
if (listenersAttached) {
return;
}
@ -141,11 +148,12 @@ Meteor.startup(() => {
// Should encrypt this message.
const msg = await e2eRoom.encrypt(message);
message.msg = msg;
message.t = 'e2e';
message.e2e = 'pending';
return message;
});
listenersAttached = true;
});
});

@ -14,7 +14,7 @@ type SaveE2EPasswordModalProps = {
onConfirm: () => void;
};
const DOCS_URL = 'https://rocket.chat/docs/user-guides/end-to-end-encryption/';
const DOCS_URL = 'https://go.rocket.chat/i/e2ee-guide';
const SaveE2EPasswordModal = ({ randomPassword, onClose, onCancel, onConfirm }: SaveE2EPasswordModalProps): ReactElement => {
const t = useTranslation();

@ -0,0 +1,58 @@
import {
Box,
Button,
States,
StatesAction,
StatesActions,
StatesIcon,
StatesLink,
StatesSubtitle,
StatesTitle,
} from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import { useRouter, useTranslation } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React from 'react';
const DOCS_URL = 'https://go.rocket.chat/i/e2ee-guide';
type RoomE2EENotAllowedProps = {
title: string;
subTitle: string;
action?: () => void;
btnText?: string;
icon: IconName;
};
const RoomE2EENotAllowed = ({ title, subTitle, action, btnText, icon }: RoomE2EENotAllowedProps): ReactElement => {
const router = useRouter();
const t = useTranslation();
const handleGoHomeClick = () => {
router.navigate('/home');
};
return (
<Box display='flex' justifyContent='center' height='full'>
<States>
<StatesIcon name={icon} variation='primary' />
<StatesTitle>{title}</StatesTitle>
<StatesSubtitle>{subTitle}</StatesSubtitle>
{action && (
<StatesActions>
<Button secondary={true} role='link' onClick={handleGoHomeClick}>
{t('Back_to_home')}
</Button>
<StatesAction primary onClick={action} role='button'>
{btnText}
</StatesAction>
</StatesActions>
)}
<StatesLink target='_blank' href={DOCS_URL}>
{t('Learn_more_about_E2EE')}
</StatesLink>
</States>
</Box>
);
};
export default RoomE2EENotAllowed;

@ -0,0 +1,69 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import React, { useCallback } from 'react';
import { e2e } from '../../../../app/e2e/client';
import { E2EEState } from '../../../../app/e2e/client/E2EEState';
import { E2ERoomState } from '../../../../app/e2e/client/E2ERoomState';
import RoomBody from '../body/RoomBody';
import { useRoom } from '../contexts/RoomContext';
import { useE2EERoomState } from '../hooks/useE2EERoomState';
import { useE2EEState } from '../hooks/useE2EEState';
import RoomE2EENotAllowed from './RoomE2EENotAllowed';
const RoomE2EESetup = () => {
const room = useRoom();
const e2eeState = useE2EEState();
const e2eRoomState = useE2EERoomState(room._id);
const t = useTranslation();
const randomPassword = window.localStorage.getItem('e2e.randomPassword');
const onSavePassword = useCallback(() => {
if (!randomPassword) {
return;
}
e2e.openSaveE2EEPasswordModal(randomPassword);
}, [randomPassword]);
const onEnterE2EEPassword = useCallback(() => e2e.decodePrivateKeyFlow(), []);
if (e2eeState === E2EEState.SAVE_PASSWORD) {
return (
<RoomE2EENotAllowed
title={t('__roomName__is_encrypted', { roomName: room.name })}
subTitle={t('Save_your_encryption_password_to_access')}
icon='key'
action={onSavePassword}
btnText={t('Save_E2EE_password')}
/>
);
}
if (e2eeState === E2EEState.ENTER_PASSWORD) {
return (
<RoomE2EENotAllowed
title={t('__roomName__is_encrypted', { roomName: room.name })}
subTitle={t('Enter_your_E2E_password_to_access')}
icon='key'
action={onEnterE2EEPassword}
btnText={t('Enter_your_E2E_password')}
/>
);
}
if (e2eRoomState === E2ERoomState.WAITING_KEYS) {
return (
<RoomE2EENotAllowed
title={t('Check_back_later')}
subTitle={t('__roomName__encryption_keys_need_to_be_updated', { roomName: room.name })}
icon='clock'
/>
);
}
return <RoomBody />;
};
export default RoomE2EESetup;

@ -1,10 +1,11 @@
import { useTranslation } from '@rocket.chat/ui-contexts';
import { useTranslation, useSetting } from '@rocket.chat/ui-contexts';
import type { ReactElement } from 'react';
import React, { createElement, lazy, memo, Suspense } from 'react';
import { FocusScope } from 'react-aria';
import { ErrorBoundary } from 'react-error-boundary';
import { ContextualbarSkeleton } from '../../components/Contextualbar';
import RoomE2EESetup from './E2EESetup/RoomE2EESetup';
import Header from './Header';
import MessageHighlightProvider from './MessageList/providers/MessageHighlightProvider';
import RoomBody from './body/RoomBody';
@ -23,6 +24,9 @@ const Room = (): ReactElement => {
const room = useRoom();
const toolbox = useRoomToolbox();
const contextualBarView = useAppsContextualBar();
const isE2EEnabled = useSetting('E2E_Enable');
const unencryptedMessagesAllowed = useSetting('E2E_Allow_Unencrypted_Messages');
const shouldDisplayE2EESetup = room?.encrypted && !unencryptedMessagesAllowed && isE2EEnabled;
return (
<ChatProvider>
@ -37,7 +41,7 @@ const Room = (): ReactElement => {
: t('Channel__roomName__', { roomName: room.name })
}
header={<Header room={room} />}
body={<RoomBody />}
body={shouldDisplayE2EESetup ? <RoomE2EESetup /> : <RoomBody />}
aside={
(toolbox.tab?.tabComponent && (
<ErrorBoundary fallback={null}>

@ -2,9 +2,10 @@ import type { IRoom, IRoomWithRetentionPolicy } from '@rocket.chat/core-typings'
import { usePermission, useAtLeastOnePermission, useRole } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { e2e } from '../../../../../../app/e2e/client/rocketchat.e2e';
import { E2EEState } from '../../../../../../app/e2e/client/E2EEState';
import { RoomSettingsEnum } from '../../../../../../definition/IRoomTypeConfig';
import { roomCoordinator } from '../../../../../lib/rooms/roomCoordinator';
import { useE2EEState } from '../../../hooks/useE2EEState';
const getCanChangeType = (room: IRoom | IRoomWithRetentionPolicy, canCreateChannel: boolean, canCreateGroup: boolean, isAdmin: boolean) =>
(!room.default || isAdmin) && ((room.t === 'p' && canCreateChannel) || (room.t === 'c' && canCreateGroup));
@ -13,7 +14,8 @@ export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) =
const isAdmin = useRole('admin');
const canCreateChannel = usePermission('create-c');
const canCreateGroup = usePermission('create-p');
const e2eeState = useE2EEState();
const isE2EEReady = e2eeState === E2EEState.READY || e2eeState === E2EEState.SAVE_PASSWORD;
const canChangeType = getCanChangeType(room, canCreateChannel, canCreateGroup, isAdmin);
const canSetReadOnly = usePermission('set-readonly', room._id);
const canSetReactWhenReadOnly = usePermission('set-react-when-readonly', room._id);
@ -22,7 +24,7 @@ export const useEditRoomPermissions = (room: IRoom | IRoomWithRetentionPolicy) =
useMemo(() => ['archive-room', 'unarchive-room'], []),
room._id,
);
const canToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id) && (room.encrypted || e2e.isReady());
const canToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id) && (room.encrypted || isE2EEReady);
const [
canViewName,

@ -0,0 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { e2e } from '../../../../app/e2e/client';
export const useE2EERoom = (rid: string) => {
const { data } = useQuery(['e2eRoom', rid], () => e2e.getInstanceByRoomId(rid));
return data;
};

@ -0,0 +1,20 @@
import { useMemo } from 'react';
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import type { E2ERoomState } from '../../../../app/e2e/client/E2ERoomState';
import { useE2EERoom } from './useE2EERoom';
export const useE2EERoomState = (rid: string) => {
const e2eRoom = useE2EERoom(rid);
const subscribeE2EERoomState = useMemo(
() =>
[
(callback: () => void): (() => void) => (e2eRoom ? e2eRoom.onStateChange(callback) : () => undefined),
(): E2ERoomState | undefined => (e2eRoom ? e2eRoom.getState() : undefined),
] as const,
[e2eRoom],
);
return useSyncExternalStore(...subscribeE2EERoomState);
};

@ -0,0 +1,9 @@
import { useSyncExternalStore } from 'use-sync-external-store/shim';
import { e2e } from '../../../../app/e2e/client';
import type { E2EEState } from '../../../../app/e2e/client/E2EEState';
const subscribe = (callback: () => void): (() => void) => e2e.on('E2E_STATE_CHANGED', callback);
const getSnapshot = (): E2EEState => e2e.getState();
export const useE2EEState = (): E2EEState => useSyncExternalStore(subscribe, getSnapshot);

@ -11,8 +11,8 @@ type ChatProviderProps = {
};
const ChatProvider = ({ children, tmid }: ChatProviderProps): ReactElement => {
const { _id: rid } = useRoom();
const value = useChatMessagesInstance({ rid, tmid });
const { _id: rid, encrypted } = useRoom();
const value = useChatMessagesInstance({ rid, tmid, encrypted });
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};

@ -9,7 +9,15 @@ import { useUiKitActionManager } from '../../../../uikit/hooks/useUiKitActionMan
import { useRoomSubscription } from '../../contexts/RoomContext';
import { useInstance } from './useInstance';
export function useChatMessagesInstance({ rid, tmid }: { rid: IRoom['_id']; tmid?: IMessage['_id'] }): ChatAPI {
export function useChatMessagesInstance({
rid,
tmid,
encrypted,
}: {
rid: IRoom['_id'];
tmid?: IMessage['_id'];
encrypted: IRoom['encrypted'];
}): ChatAPI {
const uid = useUserId();
const subscription = useRoomSubscription();
const actionManager = useUiKitActionManager();
@ -17,7 +25,7 @@ export function useChatMessagesInstance({ rid, tmid }: { rid: IRoom['_id']; tmid
const instance = new ChatMessages({ rid, tmid, uid, actionManager });
return [instance, () => instance.release()];
}, [rid, tmid, uid]);
}, [rid, tmid, uid, encrypted]);
useEffect(() => {
if (subscription) {

@ -10,6 +10,12 @@ export const createE2ESettings = () =>
alert: 'E2E_Enable_alert',
});
await this.add('E2E_Allow_Unencrypted_Messages', true, {
type: 'boolean',
public: true,
enableQuery: { _id: 'E2E_Enable', value: true },
});
await this.add('E2E_Enabled_Default_DirectRooms', false, {
type: 'boolean',
public: true,

@ -7,15 +7,6 @@ import { Users, storeState, restoreState } from './fixtures/userStates';
import { AccountProfile, HomeChannel } from './page-objects';
import { test, expect } from './utils/test';
// OK Enable e2ee on admin
// OK Test banner and check password, logout and use password
// OK Set new password, logout and use the password
// OK Reset key, should logout, login and check banner
// OK Create channel encrypted and send message
// OK Disable encryption and send message
// OK Enable encryption and send message
// OK Create channel not encrypted, encrypt end send message
test.use({ storageState: Users.admin.state });
test.describe.serial('e2e-encryption initial setup', () => {
@ -79,7 +70,7 @@ test.describe.serial('e2e-encryption initial setup', () => {
await page.locator('role=banner >> text="Enter your E2E password"').click();
await page.locator('#modal-root input').type(password);
await page.locator('#modal-root input').fill(password);
await page.locator('#modal-root .rcx-button--primary').click();
@ -96,8 +87,8 @@ test.describe.serial('e2e-encryption initial setup', () => {
await poAccountProfile.securityE2EEncryptionSection.click();
await poAccountProfile.securityE2EEncryptionPassword.click();
await poAccountProfile.securityE2EEncryptionPassword.type(newPassword);
await poAccountProfile.securityE2EEncryptionPasswordConfirmation.type(newPassword);
await poAccountProfile.securityE2EEncryptionPassword.fill(newPassword);
await poAccountProfile.securityE2EEncryptionPasswordConfirmation.fill(newPassword);
await poAccountProfile.securityE2EEncryptionSavePasswordButton.click();
await poAccountProfile.btnClose.click();
@ -112,13 +103,13 @@ test.describe.serial('e2e-encryption initial setup', () => {
await page.locator('role=banner >> text="Enter your E2E password"').click();
await page.locator('#modal-root input').type(password);
await page.locator('#modal-root input').fill(password);
await page.locator('#modal-root .rcx-button--primary').click();
await page.locator('role=banner >> text="Wasn\'t possible to decode your encryption key to be imported."').click();
await page.locator('#modal-root input').type(newPassword);
await page.locator('#modal-root input').fill(newPassword);
await page.locator('#modal-root .rcx-button--primary').click();
@ -139,19 +130,19 @@ test.describe.serial('e2e-encryption', () => {
await page.goto('/home');
});
test.afterAll(async ({ api }) => {
const statusCode = (await api.post('/settings/E2E_Enable', { value: false })).status();
test.beforeAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: true })).status()).toBe(200);
});
await expect(statusCode).toBe(200);
test.afterAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Enable', { value: false })).status()).toBe(200);
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200);
});
test('expect create a private channel encrypted and send an encrypted message', async ({ page }) => {
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.type(channelName);
await poHomeChannel.sidenav.checkboxEncryption.click();
await poHomeChannel.sidenav.btnCreate.click();
await poHomeChannel.sidenav.createEncryptedChannel(channelName);
await expect(page).toHaveURL(`/group/${channelName}`);
@ -192,7 +183,7 @@ test.describe.serial('e2e-encryption', () => {
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.type(channelName);
await poHomeChannel.sidenav.inputChannelName.fill(channelName);
await poHomeChannel.sidenav.btnCreate.click();
await expect(page).toHaveURL(`/group/${channelName}`);
@ -217,10 +208,7 @@ test.describe.serial('e2e-encryption', () => {
test('expect placeholder text in place of encrypted message, when E2EE is not setup', async ({ page }) => {
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.fill(channelName);
await poHomeChannel.sidenav.checkboxEncryption.click();
await poHomeChannel.sidenav.btnCreate.click();
await poHomeChannel.sidenav.createEncryptedChannel(channelName);
await expect(page).toHaveURL(`/group/${channelName}`);
@ -285,3 +273,179 @@ test.describe.serial('e2e-encryption', () => {
});
});
});
test.describe.serial('e2ee room setup', () => {
let poAccountProfile: AccountProfile;
let poHomeChannel: HomeChannel;
let e2eePassword: string;
test.beforeEach(async ({ page }) => {
poAccountProfile = new AccountProfile(page);
poHomeChannel = new HomeChannel(page);
});
test.beforeAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Enable', { value: true })).status()).toBe(200);
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200);
});
test.afterAll(async ({ api }) => {
expect((await api.post('/settings/E2E_Enable', { value: false })).status()).toBe(200);
expect((await api.post('/settings/E2E_Allow_Unencrypted_Messages', { value: false })).status()).toBe(200);
});
test('expect save password state on encrypted room', async ({ page }) => {
await page.goto('/account/security');
await poAccountProfile.securityE2EEncryptionSection.click();
await poAccountProfile.securityE2EEncryptionResetKeyButton.click();
await page.locator('role=button[name="Login"]').waitFor();
await page.reload();
await page.locator('role=button[name="Login"]').waitFor();
await injectInitialData();
await restoreState(page, Users.admin);
await page.goto('/home');
await page.locator('role=banner >> text="Save your encryption password"').waitFor();
await expect(page.locator('role=banner >> text="Save your encryption password"')).toBeVisible();
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.fill(channelName);
await poHomeChannel.sidenav.checkboxEncryption.click();
await poHomeChannel.sidenav.btnCreate.click();
await expect(page).toHaveURL(`/group/${channelName}`);
await poHomeChannel.dismissToast();
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await page.locator('role=button[name="Save E2EE password"]').waitFor();
await expect(page.locator('role=button[name="Save E2EE password"]')).toBeVisible();
await expect(poHomeChannel.content.inputMessage).not.toBeVisible();
await page.locator('role=button[name="Save E2EE password"]').click();
e2eePassword = (await page.evaluate(() => localStorage.getItem('e2e.randomPassword'))) || 'undefined';
await expect(page.locator('role=dialog[name="Save your encryption password"]')).toBeVisible();
await expect(page.locator('#modal-root')).toContainText(e2eePassword);
await page.locator('#modal-root >> button:has-text("I saved my password")').click();
await poHomeChannel.content.inputMessage.waitFor();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
});
test('expect enter password state on encrypted room', async ({ page }) => {
await page.goto('/home');
// Logout to remove e2ee keys
await poHomeChannel.sidenav.logout();
await page.locator('role=button[name="Login"]').waitFor();
await page.reload();
await page.locator('role=button[name="Login"]').waitFor();
await injectInitialData();
await restoreState(page, Users.admin, { except: ['private_key', 'public_key'] });
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.fill(channelName);
await poHomeChannel.sidenav.checkboxEncryption.click();
await poHomeChannel.sidenav.btnCreate.click();
await expect(page).toHaveURL(`/group/${channelName}`);
await poHomeChannel.dismissToast();
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await page.locator('role=button[name="Enter your E2E password"]').waitFor();
await expect(page.locator('role=banner >> text="Enter your E2E password"')).toBeVisible();
await expect(poHomeChannel.content.inputMessage).not.toBeVisible();
await page.locator('role=button[name="Enter your E2E password"]').click();
await page.locator('#modal-root input').fill(e2eePassword);
await page.locator('#modal-root .rcx-button--primary').click();
await expect(page.locator('role=banner >> text="Enter your E2E password"')).not.toBeVisible();
await poHomeChannel.content.inputMessage.waitFor();
// For E2EE to complete init setup
await page.waitForTimeout(300);
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await storeState(page, Users.admin);
});
test('expect waiting for room keys state', async ({ page }) => {
await page.goto('/home');
const channelName = faker.string.uuid();
await poHomeChannel.sidenav.openNewByLabel('Channel');
await poHomeChannel.sidenav.inputChannelName.fill(channelName);
await poHomeChannel.sidenav.checkboxEncryption.click();
await poHomeChannel.sidenav.btnCreate.click();
await expect(page).toHaveURL(`/group/${channelName}`);
await poHomeChannel.dismissToast();
await expect(poHomeChannel.content.encryptedRoomHeaderIcon).toBeVisible();
await poHomeChannel.content.sendMessage('hello world');
await expect(poHomeChannel.content.lastUserMessageBody).toHaveText('hello world');
await expect(poHomeChannel.content.lastUserMessage.locator('.rcx-icon--name-key')).toBeVisible();
await poHomeChannel.sidenav.userProfileMenu.click();
await poHomeChannel.sidenav.accountProfileOption.click();
await page.locator('role=navigation >> a:has-text("Security")').click();
await poAccountProfile.securityE2EEncryptionSection.click();
await poAccountProfile.securityE2EEncryptionResetKeyButton.click();
await page.locator('role=button[name="Login"]').waitFor();
await page.reload();
await page.locator('role=button[name="Login"]').waitFor();
await injectInitialData();
await restoreState(page, Users.admin);
await page.locator('role=navigation >> role=button[name=Search]').click();
await page.locator('role=search >> role=searchbox').fill(channelName);
await page.locator(`role=search >> role=listbox >> role=link >> text="${channelName}"`).click();
await page.locator('role=button[name="Save E2EE password"]').click();
await page.locator('#modal-root >> button:has-text("I saved my password")').click();
await expect(poHomeChannel.content.inputMessage).not.toBeVisible();
await expect(page.locator('.rcx-states__title')).toContainText('Check back later');
});
});

@ -49,6 +49,10 @@ export class HomeSidenav {
return this.page.getByRole('toolbar', { name: 'Sidebar actions' });
}
get accountProfileOption(): Locator {
return this.page.locator('role=menuitemcheckbox[name="Profile"]');
}
getSidebarItemByName(name: string): Locator {
return this.page.locator(`[data-qa="sidebar-item"][aria-label="${name}"]`);
}
@ -140,4 +144,11 @@ export class HomeSidenav {
await this.inputChannelName.type(name);
await this.btnCreate.click();
}
async createEncryptedChannel(name: string) {
await this.openNewByLabel('Channel');
await this.inputChannelName.type(name);
await this.checkboxEncryption.click();
await this.btnCreate.click();
}
}

@ -122,6 +122,16 @@ describe('[Groups]', function () {
});
describe('validate E2E rooms', () => {
before(async () => {
await Promise.all([updateSetting('E2E_Enable', true), updateSetting('E2E_Allow_Unencrypted_Messages', false)]);
});
after(async () => {
await Promise.all([updateSetting('E2E_Enable', false), updateSetting('E2E_Allow_Unencrypted_Messages', true)]);
});
let rid;
it('should create a new encrypted group', async () => {
await request
.post(api('groups.create'))
@ -140,6 +150,68 @@ describe('[Groups]', function () {
expect(res.body).to.have.nested.property('group.t', 'p');
expect(res.body).to.have.nested.property('group.msgs', 0);
expect(res.body).to.have.nested.property('group.encrypted', true);
rid = res.body.group._id;
});
});
it('should send an encrypted message in encrypted group', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
text: 'Encrypted Message',
t: 'e2e',
e2e: 'pending',
rid,
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('message');
expect(res.body).to.have.nested.property('message.text', 'Encrypted Message');
expect(res.body).to.have.nested.property('message.t', 'e2e');
expect(res.body).to.have.nested.property('message.e2e', 'pending');
});
});
it('should give an error on sending un-encrypted message in encrypted room', async () => {
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
text: 'Unencrypted Message',
rid,
},
})
.expect('Content-Type', 'application/json')
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error').that.is.a('string');
});
});
it('should allow sending un-encrypted messages in encrypted room when setting is enabled', async () => {
await updateSetting('E2E_Allow_Unencrypted_Messages', true);
await request
.post(api('chat.sendMessage'))
.set(credentials)
.send({
message: {
text: 'Unencrypted Message',
rid,
},
})
.expect('Content-Type', 'application/json')
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
expect(res.body).to.have.property('message');
expect(res.body).to.have.nested.property('message.text', 'Unencrypted Message');
});
});
});

@ -18,6 +18,8 @@
"__count__without__assignee__": "{{count}} without assignee",
"__roomName__was_added_to_favorites": "{{roomName}} was added to favorites",
"__roomName__was_removed_from_favorites": "{{roomName}} was removed from favorites",
"__roomName__is_encrypted": "{{roomName}} is encrypted",
"__roomName__encryption_keys_need_to_be_updated": "{{roomName}} encryption keys need to be updated to give you access. Another room member needs to be online for this to happen.",
"removed__username__as__role_": "removed {{username}} as {{role}}",
"set__username__as__role_": "set {{username}} as {{role}}",
"sequential_message": "sequential message",
@ -767,6 +769,7 @@
"Back_to_applications": "Back to applications",
"Back_to_calendar": "Back to calendar",
"Back_to_chat": "Back to chat",
"Back_to_home": "Back to home",
"Back_to_imports": "Back to imports",
"Back_to_integration_detail": "Back to the integration detail",
"Back_to_integrations": "Back to integrations",
@ -1003,6 +1006,7 @@
"Chat_Duration": "Chat Duration",
"Chats_removed": "Chats Removed",
"Check_All": "Check All",
"Check_back_later": "Check back later",
"Check_if_the_spelling_is_correct": "Check if the spelling is correct",
"Check_Progress": "Check Progress",
"Check_device_activity": "Check device activity",
@ -1774,6 +1778,8 @@
"Duplicated_Email_address_will_be_ignored": "Duplicated email address will be ignored.",
"Markdown_Marked_Tables": "Enable Marked Tables",
"duplicated-account": "Duplicated account",
"E2E_Allow_Unencrypted_Messages": "Unencrypted messages in encrypted rooms",
"E2E_Allow_Unencrypted_Messages_Description": "Allow plain text messages to be sent in encrypted rooms. These messages will not be encrypted.",
"E2E Encryption": "E2E Encryption",
"E2E_Encryption_enabled_for_room": "End-to-end encryption enabled for #{{roomName}}",
"E2E_Encryption_disabled_for_room": "End-to-end encryption disabled for #{{roomName}}",
@ -1942,7 +1948,8 @@
"End_suspicious_sessions": "End any suspicious sessions",
"End_call": "End call",
"End_conversation": "End conversation",
"End_To_End_Encryption_Set": "End-to-end encryption is set",
"End_To_End_Encryption_Enabled": "End-to-end encryption is enabled",
"End_To_End_Encryption_Not_Enabled": "End-to-end encryption is not enabled",
"Expand_view": "Expand view",
"Explore": "Explore",
"Explore_marketplace": "Explore Marketplace",
@ -1974,6 +1981,7 @@
"Enter_to": "Enter to",
"Enter_TOTP_password": "Enter TOTP password",
"Enter_your_E2E_password": "Enter your E2E password",
"Enter_your_E2E_password_to_access": "Enter your end-to-end encryption password to access",
"Enter_your_password_to_delete_your_account": "Enter your password to delete your account. This cannot be undone.",
"Enter_your_username_to_delete_your_account": "Enter your username to delete your account. This cannot be undone.",
"Premium_capabilities": "Premium capabilities",
@ -4672,10 +4680,12 @@
"Saturday": "Saturday",
"Save": "Save",
"Save_changes": "Save changes",
"Save_E2EE_password": "Save E2EE password",
"Save_Mobile_Bandwidth": "Save Mobile Bandwidth",
"Save_to_enable_this_action": "Save to enable this action",
"Save_To_Webdav": "Save to WebDAV",
"Save_your_encryption_password": "Save your encryption password",
"Save_your_encryption_password_to_access": "Save your end-to-end encryption password to access",
"save-all-canned-responses": "Save All Canned Responses",
"save-all-canned-responses_description": "Permission to save all canned responses",
"save-canned-responses": "Save Canned Responses",
@ -6017,6 +6027,7 @@
"Your_new_email_is_email": "Your new email address is <strong>[email]</strong>.",
"Your_E2EE_password_is": "Your E2EE password is:",
"Your_password_is_wrong": "Your password is wrong!",
"Your_E2EE_password_is_incorrect": "Your E2EE password is incorrect",
"Your_password_was_changed_by_an_admin": "Your password was changed by an admin.",
"Your_push_was_sent_to_s_devices": "Your push was sent to %s devices",
"Your_request_to_join__roomName__has_been_made_it_could_take_up_to_15_minutes_to_be_processed": "Your request to join {{roomName}} has been made, it could take up to 15 minutes to be processed. You'll be notified when it's ready to go.",

Loading…
Cancel
Save