From f9d1ba23f181dafe218804d27f627253d85ea94d Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Mon, 16 Dec 2024 13:27:31 -0300 Subject: [PATCH 1/9] feat: Async E2EE keys exchange (#5995) --- app/containers/List/ListInfo.tsx | 1 + app/definitions/IRoom.ts | 4 + app/definitions/ISubscription.ts | 8 +- app/definitions/rest/v1/e2e.ts | 11 + app/i18n/locales/en.json | 11 +- app/lib/database/model/Subscription.js | 6 + app/lib/database/model/migrations.js | 12 + app/lib/database/schema/app.js | 4 +- app/lib/encryption/encryption.ts | 191 +++++-- app/lib/encryption/helpers/hooks.ts | 52 ++ app/lib/encryption/helpers/toggleRoomE2EE.ts | 48 +- app/lib/encryption/room.ts | 242 ++++++-- .../helpers/mergeSubscriptionsRooms.ts | 1 + app/lib/services/restApi.ts | 10 + app/sagas/login.js | 2 +- app/stacks/InsideStack.tsx | 2 + app/stacks/MasterDetailStack/index.tsx | 2 + app/stacks/MasterDetailStack/types.ts | 3 + app/stacks/types.ts | 33 +- app/views/E2EEToggleRoomView/index.tsx | 82 +++ app/views/E2EEToggleRoomView/resetRoomKey.ts | 46 ++ app/views/E2EEToggleRoomView/useRoom.ts | 32 ++ app/views/RoomActionsView/index.tsx | 46 +- app/views/RoomView/RightButtons.tsx | 39 +- app/views/RoomView/index.tsx | 1 - e2e/helpers/app.ts | 28 +- e2e/helpers/data_setup.ts | 1 + e2e/tests/assorted/01-e2eencryption.spec.ts | 526 ++++++------------ yarn.lock | 4 +- 29 files changed, 937 insertions(+), 511 deletions(-) create mode 100644 app/lib/encryption/helpers/hooks.ts create mode 100644 app/views/E2EEToggleRoomView/index.tsx create mode 100644 app/views/E2EEToggleRoomView/resetRoomKey.ts create mode 100644 app/views/E2EEToggleRoomView/useRoom.ts diff --git a/app/containers/List/ListInfo.tsx b/app/containers/List/ListInfo.tsx index 98050d16b6..4fc808da14 100644 --- a/app/containers/List/ListInfo.tsx +++ b/app/containers/List/ListInfo.tsx @@ -14,6 +14,7 @@ const styles = StyleSheet.create({ }, text: { fontSize: 14, + lineHeight: 20, ...sharedStyles.textRegular } }); diff --git a/app/definitions/IRoom.ts b/app/definitions/IRoom.ts index ddafdad58e..c2858d3450 100644 --- a/app/definitions/IRoom.ts +++ b/app/definitions/IRoom.ts @@ -13,6 +13,8 @@ interface IRequestTranscript { subject: string; } +export type TUserWaitingForE2EKeys = { userId: string; ts: Date }; + export interface IRoom { fname?: string; _id: string; @@ -34,6 +36,7 @@ export interface IRoom { livechatData?: any; tags?: string[]; e2eKeyId?: string; + usersWaitingForE2EKeys?: TUserWaitingForE2EKeys[]; avatarETag?: string; latest?: string; default?: boolean; @@ -217,6 +220,7 @@ export interface IServerRoom extends IRocketChatRecord { reactWhenReadOnly?: boolean; joinCodeRequired?: boolean; e2eKeyId?: string; + usersWaitingForE2EKeys?: TUserWaitingForE2EKeys[]; v?: { _id?: string; token?: string; diff --git a/app/definitions/ISubscription.ts b/app/definitions/ISubscription.ts index 90b99a1f07..f7cb3d7408 100644 --- a/app/definitions/ISubscription.ts +++ b/app/definitions/ISubscription.ts @@ -3,7 +3,7 @@ import Relation from '@nozbe/watermelondb/Relation'; import { ILastMessage, TMessageModel } from './IMessage'; import { IRocketChatRecord } from './IRocketChatRecord'; -import { IOmnichannelSource, RoomID, RoomType } from './IRoom'; +import { IOmnichannelSource, RoomID, RoomType, TUserWaitingForE2EKeys } from './IRoom'; import { IServedBy } from './IServedBy'; import { TThreadModel } from './IThread'; import { TThreadMessageModel } from './IThreadMessage'; @@ -35,6 +35,8 @@ export enum ERoomTypes { type RelationModified = { fetch(): Promise } & Relation; +type OldKey = { e2eKeyId: string; ts: Date; E2EKey: string }; + export interface ISubscription { _id: string; id: string; @@ -93,9 +95,11 @@ export interface ISubscription { livechatData?: any; tags?: string[]; E2EKey?: string; + oldRoomKeys?: OldKey[]; E2ESuggestedKey?: string | null; encrypted?: boolean; e2eKeyId?: string; + usersWaitingForE2EKeys?: TUserWaitingForE2EKeys[]; avatarETag?: string; teamId?: string; teamMain?: boolean; @@ -153,7 +157,9 @@ export interface IServerSubscription extends IRocketChatRecord { onHold?: boolean; encrypted?: boolean; E2EKey?: string; + oldRoomKeys?: OldKey[]; E2ESuggestedKey?: string | null; + usersWaitingForE2EKeys?: TUserWaitingForE2EKeys[]; unreadAlert?: 'default' | 'all' | 'mentions' | 'nothing'; fname?: unknown; diff --git a/app/definitions/rest/v1/e2e.ts b/app/definitions/rest/v1/e2e.ts index bf4db5b117..202c295826 100644 --- a/app/definitions/rest/v1/e2e.ts +++ b/app/definitions/rest/v1/e2e.ts @@ -18,10 +18,21 @@ export type E2eEndpoints = { 'e2e.rejectSuggestedGroupKey': { POST: (params: { rid: string }) => {}; }; + 'e2e.fetchUsersWaitingForGroupKey': { + GET: (params: { roomIds: string[] }) => { + usersWaitingForE2EKeys: any; + }; + }; + 'e2e.provideUsersSuggestedGroupKeys': { + POST: (params: { usersSuggestedGroupKeys: any }) => void; + }; 'e2e.setRoomKeyID': { POST: (params: { rid: string; keyID: string }) => {}; }; 'e2e.fetchMyKeys': { GET: () => { public_key: string; private_key: string }; }; + 'e2e.resetRoomKey': { + POST: (params: { rid: string; e2eKey: string; e2eKeyId: string }) => void; + }; }; diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 4f30bd4728..efde9034a4 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -227,7 +227,7 @@ "Dont_activate": "Don't activate now", "Dont_Have_An_Account": "Don't you have an account?", "Downloaded_file": "Downloaded file", - "E2E_Encryption": "E2E encryption", + "E2E_Encryption": "End-to-end encryption", "E2E_encryption_change_password_confirmation": "Yes, change it", "E2E_encryption_change_password_description": "You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted. \nThis is end to end encryption so the key to encode/decode your messages will not be saved on the workspace. For that reason you need to store your password somewhere safe. You will be required to enter it on other devices you wish to use e2e encryption on.", "E2E_encryption_change_password_error": "Error while changing E2E key password!", @@ -262,6 +262,8 @@ "Enable_writing_in_room": "Enable writing in room", "Enabled": "Enabled", "Enabled_E2E_Encryption_for_this_room": "enabled E2E encryption for this room", + "Encrypt__room_type__": "Encrypt {{room_type}}", + "Encrypt__room_type__info__room_name__": "Ensure only intended recipients can access messages and files in {{room_name}}.", "Encrypted": "Encrypted", "Encrypted_file": "Encrypted file", "Encrypted_message": "Encrypted message", @@ -269,6 +271,8 @@ "encrypted_room_title": "{{room_name}} is encrypted", "Encryption_error_desc": "It wasn't possible to decode your encryption key to be imported.", "Encryption_error_title": "Your encryption password seems wrong", + "Encryption_keys_reset": "Encryption keys reset", + "Encryption_keys_reset_failed": "Encryption keys reset failed", "End_to_end_encrypted_room": "End to end encrypted room", "Enter_E2EE_Password": "Enter E2EE password", "Enter_E2EE_Password_description": "To access your encrypted channels and direct messages, enter your encryption password. This is not stored on the server, so you’ll need to use it on every device.", @@ -609,7 +613,12 @@ "Resend": "Resend", "Resend_email": "Resend email", "RESET": "RESET", + "Reset": "Reset", + "Reset_encryption_keys": "Reset encryption keys", + "Reset_encryption_keys_info__room_type__": "Resetting E2EE keys is only recommend if no {{room_type}} member has a valid key to regain access to the previously encrypted content.", "Reset_password": "Reset password", + "Reset_room_key_message": "All members may lose access to previously encrypted content.", + "Reset_room_key_title": "Reset encryption key", "resetting_password": "resetting password", "Resume": "Resume", "Return_to_waiting_line": "Return to waiting line", diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index cda8faa8f5..97eeed60b1 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -127,12 +127,16 @@ export default class Subscription extends Model { @field('e2e_key') E2EKey; + @json('old_room_keys', sanitizer) oldRoomKeys; + @field('e2e_suggested_key') E2ESuggestedKey; @field('encrypted') encrypted; @field('e2e_key_id') e2eKeyId; + @json('users_waiting_for_e2e_keys', sanitizer) usersWaitingForE2EKeys; + @field('avatar_etag') avatarETag; @field('team_id') teamId; @@ -201,9 +205,11 @@ export default class Subscription extends Model { livechatData: this.livechatData, tags: this.tags, E2EKey: this.E2EKey, + oldKeys: this.oldKeys, E2ESuggestedKey: this.E2ESuggestedKey, encrypted: this.encrypted, e2eKeyId: this.e2eKeyId, + usersWaitingForE2EKeys: this.usersWaitingForE2EKeys, avatarETag: this.avatarETag, teamId: this.teamId, teamMain: this.teamMain, diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index a40ceb0a5b..1fa1c734dc 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -310,6 +310,18 @@ export default schemaMigrations({ columns: [{ name: 'content', type: 'string', isOptional: true }] }) ] + }, + { + toVersion: 26, + steps: [ + addColumns({ + table: 'subscriptions', + columns: [ + { name: 'users_waiting_for_e2e_keys', type: 'string', isOptional: true }, + { name: 'old_room_keys', type: 'string', isOptional: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index 73220f11de..0a69c06390 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 25, + version: 26, tables: [ tableSchema({ name: 'subscriptions', @@ -56,9 +56,11 @@ export default appSchema({ { name: 'livechat_data', type: 'string', isOptional: true }, { name: 'tags', type: 'string', isOptional: true }, { name: 'e2e_key', type: 'string', isOptional: true }, + { name: 'old_room_keys', type: 'string', isOptional: true }, { name: 'e2e_suggested_key', type: 'string', isOptional: true }, { name: 'encrypted', type: 'boolean', isOptional: true }, { name: 'e2e_key_id', type: 'string', isOptional: true }, + { name: 'users_waiting_for_e2e_keys', type: 'string', isOptional: true }, { name: 'avatar_etag', type: 'string', isOptional: true }, { name: 'team_id', type: 'string', isIndexed: true }, { name: 'team_main', type: 'boolean', isOptional: true }, // Use `Q.notEq(true)` to get false or null diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index bb287adc17..924d35262f 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -3,6 +3,7 @@ import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import EJSON from 'ejson'; import { deleteAsync } from 'expo-file-system'; import SimpleCrypto from 'react-native-simple-crypto'; +import { sampleSize } from 'lodash'; import { IMessage, @@ -36,9 +37,11 @@ import Deferred from './helpers/deferred'; import EncryptionRoom from './room'; import { decryptAESCTR, joinVectorData, randomPassword, splitVectorData, toString, utf8ToBuffer } from './utils'; +const ROOM_KEY_EXCHANGE_SIZE = 10; class Encryption { ready: boolean; privateKey: string | null; + publicKey: string | null; readyPromise: Deferred; userId: string | null; roomInstances: { @@ -53,15 +56,20 @@ class Encryption { encryptFile: TEncryptFile; encryptUpload: Function; importRoomKey: Function; + resetRoomKey: Function; + hasSessionKey: () => boolean; + encryptGroupKeyForParticipantsWaitingForTheKeys: (params: any) => Promise; }; }; decryptionFileQueue: IDecryptionFileQueue[]; decryptionFileQueueActiveCount: number; + keyDistributionInterval: ReturnType | null; constructor() { this.userId = ''; this.ready = false; this.privateKey = null; + this.publicKey = null; this.roomInstances = {}; this.readyPromise = new Deferred(); this.readyPromise @@ -73,6 +81,7 @@ class Encryption { }); this.decryptionFileQueue = []; this.decryptionFileQueueActiveCount = 0; + this.keyDistributionInterval = null; } // Initialize Encryption client @@ -84,6 +93,7 @@ class Encryption { // so they can run parallelized this.decryptPendingSubscriptions(); this.decryptPendingMessages(); + this.initiateKeyDistribution(); // Mark Encryption client as ready this.readyPromise.resolve(); @@ -105,6 +115,7 @@ class Encryption { stop = () => { this.userId = null; this.privateKey = null; + this.publicKey = null; this.roomInstances = {}; // Cancel ongoing encryption/decryption requests this.readyPromise.reject(); @@ -139,13 +150,17 @@ class Encryption { } const roomE2E = await this.getRoomInstance(rid); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return; + } return roomE2E.provideKeyToUser(keyId); }; // Persist keys on UserPreferences persistKeys = async (server: string, publicKey: string, privateKey: string) => { this.privateKey = await SimpleCrypto.RSA.importKey(EJSON.parse(privateKey)); - UserPreferences.setString(`${server}-${E2E_PUBLIC_KEY}`, EJSON.stringify(publicKey)); + this.publicKey = EJSON.stringify(publicKey); + UserPreferences.setString(`${server}-${E2E_PUBLIC_KEY}`, this.publicKey); UserPreferences.setString(`${server}-${E2E_PRIVATE_KEY}`, privateKey); }; @@ -242,33 +257,43 @@ class Encryption { // get a encryption room instance getRoomInstance = async (rid: string) => { - // Prevent handshake again - if (this.roomInstances[rid]?.ready) { - return this.roomInstances[rid]; - } - - // If doesn't have a instance of this room - if (!this.roomInstances[rid]) { + try { + // Prevent handshake again + if (this.roomInstances[rid]) { + await this.roomInstances[rid].handshake(); + return this.roomInstances[rid]; + } this.roomInstances[rid] = new EncryptionRoom(rid, this.userId as string); - } - const roomE2E = this.roomInstances[rid]; + const roomE2E = this.roomInstances[rid]; - // Start Encryption Room instance handshake - await roomE2E.handshake(); + // Start Encryption Room instance handshake + await roomE2E.handshake(); - return roomE2E; + return roomE2E; + } catch (e) { + log(e); + return null; + } }; evaluateSuggestedKey = async (rid: string, E2ESuggestedKey: string) => { if (this.privateKey) { try { const roomE2E = await this.getRoomInstance(rid); - await roomE2E.importRoomKey(E2ESuggestedKey, this.privateKey); - delete this.roomInstances[rid]; + if (!roomE2E) { + return; + } + + try { + await roomE2E.importRoomKey(E2ESuggestedKey, this.privateKey); + } catch (error) { + await Services.e2eRejectSuggestedGroupKey(rid); + return; + } await Services.e2eAcceptSuggestedGroupKey(rid); } catch (e) { - await Services.e2eRejectSuggestedGroupKey(rid); + console.error(e); } } }; @@ -350,10 +375,7 @@ class Encryption { const subsEncrypted = await subCollection.query(Q.where('e2e_key_id', Q.notEq(null)), Q.where('encrypted', true)).fetch(); await Promise.all( subsEncrypted.map(async (sub: TSubscriptionModel) => { - const { rid, lastMessage, E2ESuggestedKey } = sub; - if (E2ESuggestedKey) { - await this.evaluateSuggestedKey(rid, E2ESuggestedKey); - } + const { rid, lastMessage } = sub; const newSub = await this.decryptSubscription({ rid, lastMessage }); try { return sub.prepareUpdate( @@ -375,6 +397,97 @@ class Encryption { } }; + async getSuggestedE2EEKeys(usersWaitingForE2EKeys: Record) { + const roomIds = Object.keys(usersWaitingForE2EKeys); + return Object.fromEntries( + // @ts-ignore + ( + await Promise.all( + roomIds.map(async room => { + const roomE2E = await this.getRoomInstance(room); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return; + } + const usersWithKeys = await roomE2E.encryptGroupKeyForParticipantsWaitingForTheKeys(usersWaitingForE2EKeys[room]); + + if (!usersWithKeys) { + return; + } + + return [room, usersWithKeys]; + }) + ) + ).filter(Boolean) + ); + } + + 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 roomE2E = await this.getRoomInstance(roomId); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + continue; + } + + sampleIds.push(roomId); + } + + if (!sampleIds.length && roomIds.length > limit) { + return this.getSample(roomIds, limit - 1); + } + + return sampleIds; + } + + initiateKeyDistribution = async () => { + if (this.keyDistributionInterval) { + return; + } + + const keyDistribution = async () => { + const db = database.active; + const subCollection = db.get('subscriptions'); + try { + const subscriptions = await subCollection.query(Q.where('users_waiting_for_e2e_keys', Q.notEq(null))); + if (subscriptions) { + const filteredSubs = subscriptions + .filter(sub => sub.usersWaitingForE2EKeys && !sub.usersWaitingForE2EKeys.some(user => user.userId === this.userId)) + .map(sub => sub.rid); + + const sampleIds = await this.getSample(filteredSubs); + + if (!sampleIds.length) { + return; + } + + const result = await Services.fetchUsersWaitingForGroupKey(sampleIds); + if (!result.success || !Object.keys(result.usersWaitingForE2EKeys).length) { + return; + } + + const userKeysWithRooms = await this.getSuggestedE2EEKeys(result.usersWaitingForE2EKeys); + + if (!Object.keys(userKeysWithRooms).length) { + return; + } + + await Services.provideUsersSuggestedGroupKeys(userKeysWithRooms); + } + } catch (e) { + log(e); + } + }; + + await keyDistribution(); + this.keyDistributionInterval = setInterval(keyDistribution, 10000); + }; + // Creating the instance is enough to generate room e2ee key encryptSubscription = (rid: string) => this.getRoomInstance(rid as string); @@ -406,17 +519,14 @@ class Encryption { } const { rid } = subscription; - const db = database.active; - const subCollection = db.get('subscriptions'); - - let subRecord; - try { - subRecord = await subCollection.find(rid as string); - } catch { - // Do nothing + if (!rid) { + return subscription; } + const subRecord = await getSubscriptionByRoomId(rid); try { + const db = database.active; + const subCollection = db.get('subscriptions'); const batch: Model[] = []; // If the subscription doesn't exists yet if (!subRecord) { @@ -455,6 +565,9 @@ class Encryption { // Get a instance using the subscription const roomE2E = await this.getRoomInstance(rid as string); + if (!roomE2E) { + return; + } const decryptedMessage = await roomE2E.decrypt(lastMessage); return { ...subscription, @@ -464,6 +577,9 @@ class Encryption { encryptText = async (rid: string, text: string) => { const roomE2E = await this.getRoomInstance(rid); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return; + } return roomE2E.encryptText(text); }; @@ -490,6 +606,9 @@ class Encryption { } const roomE2E = await this.getRoomInstance(rid); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return; + } return roomE2E.encrypt(message); } catch { // Subscription not found @@ -523,13 +642,15 @@ class Encryption { const { rid } = message; const roomE2E = await this.getRoomInstance(rid); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return message; + } return roomE2E.decrypt(message); }; decryptFileContent = async (file: IServerAttachment) => { const roomE2E = await this.getRoomInstance(file.rid); - - if (!roomE2E) { + if (!roomE2E || !roomE2E?.hasSessionKey()) { return file; } @@ -555,6 +676,9 @@ class Encryption { } const roomE2E = await this.getRoomInstance(rid); + if (!roomE2E || !roomE2E?.hasSessionKey()) { + return { file }; + } return roomE2E.encryptFile(rid, file); }; @@ -608,7 +732,12 @@ class Encryption { Promise.all(messages.map((m: Partial) => this.decryptMessage(m as IMessage))); // Decrypt multiple subscriptions - decryptSubscriptions = (subscriptions: ISubscription[]) => Promise.all(subscriptions.map(s => this.decryptSubscription(s))); + decryptSubscriptions = (subscriptions: ISubscription[]) => { + if (!this.ready) { + return subscriptions; + } + return Promise.all(subscriptions.map(s => this.decryptSubscription(s))); + }; // Decrypt multiple files decryptFiles = (files: IServerAttachment[]) => Promise.all(files.map(f => this.decryptFileContent(f))); diff --git a/app/lib/encryption/helpers/hooks.ts b/app/lib/encryption/helpers/hooks.ts new file mode 100644 index 0000000000..3386af8987 --- /dev/null +++ b/app/lib/encryption/helpers/hooks.ts @@ -0,0 +1,52 @@ +import { compareServerVersion } from '../../methods/helpers'; +import { useAppSelector } from '../../hooks'; +import { TSubscriptionModel } from '../../../definitions'; + +const isMissingRoomE2EEKey = ({ + encryptionEnabled, + roomEncrypted, + E2EKey +}: { + encryptionEnabled: boolean; + roomEncrypted: TSubscriptionModel['encrypted']; + E2EKey: TSubscriptionModel['E2EKey']; +}) => (encryptionEnabled && roomEncrypted && !E2EKey) ?? false; + +const isE2EEDisabledEncryptedRoom = ({ + encryptionEnabled, + roomEncrypted +}: { + encryptionEnabled: boolean; + roomEncrypted: TSubscriptionModel['encrypted']; +}) => (!encryptionEnabled && roomEncrypted) ?? false; + +export const useIsMissingRoomE2EEKey = (roomEncrypted: TSubscriptionModel['encrypted'], E2EKey: TSubscriptionModel['E2EKey']) => { + const serverVersion = useAppSelector(state => state.server.version); + const e2eeEnabled = useAppSelector(state => state.settings.E2E_Enable); + const encryptionEnabled = useAppSelector(state => state.encryption.enabled); + if (!e2eeEnabled) { + return false; + } + if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { + return false; + } + + return isMissingRoomE2EEKey({ encryptionEnabled, roomEncrypted, E2EKey }); +}; + +export const useHasE2EEWarning = (roomEncrypted: TSubscriptionModel['encrypted'], E2EKey: TSubscriptionModel['E2EKey']) => { + const serverVersion = useAppSelector(state => state.server.version); + const e2eeEnabled = useAppSelector(state => state.settings.E2E_Enable); + const encryptionEnabled = useAppSelector(state => state.encryption.enabled); + if (!e2eeEnabled) { + return false; + } + if (compareServerVersion(serverVersion, 'lowerThan', '6.10.0')) { + return false; + } + + return ( + isMissingRoomE2EEKey({ encryptionEnabled, roomEncrypted, E2EKey }) || + isE2EEDisabledEncryptedRoom({ encryptionEnabled, roomEncrypted }) + ); +}; diff --git a/app/lib/encryption/helpers/toggleRoomE2EE.ts b/app/lib/encryption/helpers/toggleRoomE2EE.ts index 1f63704dde..e9995549d0 100644 --- a/app/lib/encryption/helpers/toggleRoomE2EE.ts +++ b/app/lib/encryption/helpers/toggleRoomE2EE.ts @@ -5,6 +5,22 @@ import database from '../../database'; import { getSubscriptionByRoomId } from '../../database/services/Subscription'; import log from '../../methods/helpers/log'; import I18n from '../../../i18n'; +import { TSubscriptionModel } from '../../../definitions'; + +const optimisticUpdate = async (room: TSubscriptionModel, value: TSubscriptionModel['encrypted']) => { + try { + const db = database.active; + + // Instantly feedback to the user + await db.write(async () => { + await room.update(r => { + r.encrypted = value; + }); + }); + } catch { + // do nothing + } +}; export const toggleRoomE2EE = async (rid: string): Promise => { const room = await getSubscriptionByRoomId(rid); @@ -17,34 +33,32 @@ export const toggleRoomE2EE = async (rid: string): Promise => { const message = I18n.t(isEncrypted ? 'Disable_encryption_description' : 'Enable_encryption_description'); const confirmationText = I18n.t(isEncrypted ? 'Disable' : 'Enable'); + // Toggle encrypted value + const newValue = !room.encrypted; + + // Instantly feedback to the user + await optimisticUpdate(room, newValue); + Alert.alert( title, message, [ { text: I18n.t('Cancel'), - style: 'cancel' + style: 'cancel', + onPress: async () => { + // Revert to original value + await optimisticUpdate(room, !newValue); + } }, { text: confirmationText, style: isEncrypted ? 'destructive' : 'default', onPress: async () => { try { - const db = database.active; - - // Toggle encrypted value - const encrypted = !room.encrypted; - - // Instantly feedback to the user - await db.write(async () => { - await room.update(r => { - r.encrypted = encrypted; - }); - }); - try { // Send new room setting value to server - const { result } = await Services.saveRoomSettings(rid, { encrypted }); + const { result } = await Services.saveRoomSettings(rid, { encrypted: newValue }); // If it was saved successfully if (result) { return; @@ -54,11 +68,7 @@ export const toggleRoomE2EE = async (rid: string): Promise => { } // If something goes wrong we go back to the previous value - await db.write(async () => { - await room.update(r => { - r.encrypted = room.encrypted; - }); - }); + await optimisticUpdate(room, !newValue); } catch (e) { log(e); } diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index 28a4c4470e..4f1f32d049 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -6,10 +6,16 @@ import parse from 'url-parse'; import { sha256 } from 'js-sha256'; import getSingleMessage from '../methods/getSingleMessage'; -import { IAttachment, IMessage, IUpload, TSendFileMessageFileInfo, IUser, IServerAttachment } from '../../definitions'; +import { + IAttachment, + IMessage, + IUpload, + TSendFileMessageFileInfo, + IServerAttachment, + TSubscriptionModel +} from '../../definitions'; import Deferred from './helpers/deferred'; import { compareServerVersion, debounce } from '../methods/helpers'; -import database from '../database'; import log from '../methods/helpers/log'; import { b64ToBuffer, @@ -33,6 +39,7 @@ import { getMessageUrlRegex } from './helpers/getMessageUrlRegex'; import { mapMessageFromAPI } from './helpers/mapMessageFromApi'; import { mapMessageFromDB } from './helpers/mapMessageFromDB'; import { createQuoteAttachment } from './helpers/createQuoteAttachment'; +import { getSubscriptionByRoomId } from '../database/services/Subscription'; import { getMessageById } from '../database/services/Message'; import { TEncryptFileResult, TGetContent } from './definitions'; import { store } from '../store/auxStore'; @@ -46,6 +53,7 @@ export default class EncryptionRoom { sessionKeyExportedString: string | ByteBuffer; keyID: string; roomKey: ArrayBuffer; + subscription: TSubscriptionModel | null; constructor(roomId: string, userId: string) { this.ready = false; @@ -62,6 +70,7 @@ export default class EncryptionRoom { // Mark as established this.establishing = false; }); + this.subscription = null; } // Initialize the E2E room @@ -77,54 +86,55 @@ export default class EncryptionRoom { return this.readyPromise; } - const db = database.active; - const subCollection = db.get('subscriptions'); - try { - // Find the subscription - const subscription = await subCollection.find(this.roomId); + if (!this.subscription) { + this.subscription = await getSubscriptionByRoomId(this.roomId); + if (!this.subscription) { + return; + } + } - // Similar to Encryption.evaluateSuggestedKey - const { E2EKey, e2eKeyId, E2ESuggestedKey } = subscription; - if (E2EKey && E2ESuggestedKey && Encryption.privateKey) { + // Similar to Encryption.evaluateSuggestedKey + const { E2EKey, e2eKeyId, E2ESuggestedKey } = this.subscription; + if (E2ESuggestedKey && Encryption.privateKey) { + try { try { + this.establishing = true; const { keyID, roomKey, sessionKeyExportedString } = await this.importRoomKey(E2ESuggestedKey, Encryption.privateKey); this.keyID = keyID; this.roomKey = roomKey; this.sessionKeyExportedString = sessionKeyExportedString; - await Services.e2eAcceptSuggestedGroupKey(this.roomId); - this.readyPromise.resolve(); - return; - } catch (e) { + } catch (error) { await Services.e2eRejectSuggestedGroupKey(this.roomId); } - } - - // If this room has a E2EKey, we import it - if (E2EKey && Encryption.privateKey) { - // We're establishing a new room encryption client - this.establishing = true; - const { keyID, roomKey, sessionKeyExportedString } = await this.importRoomKey(E2EKey, Encryption.privateKey); - this.keyID = keyID; - this.roomKey = roomKey; - this.sessionKeyExportedString = sessionKeyExportedString; + await Services.e2eAcceptSuggestedGroupKey(this.roomId); this.readyPromise.resolve(); return; + } catch (e) { + log(e); } + } - // If it doesn't have a e2eKeyId, we need to create keys to the room - if (!e2eKeyId) { - // We're establishing a new room encryption client - this.establishing = true; - await this.createRoomKey(); - this.readyPromise.resolve(); - return; - } + // If this room has a E2EKey, we import it + if (E2EKey && Encryption.privateKey) { + this.establishing = true; + const { keyID, roomKey, sessionKeyExportedString } = await this.importRoomKey(E2EKey, Encryption.privateKey); + this.keyID = keyID; + this.roomKey = roomKey; + this.sessionKeyExportedString = sessionKeyExportedString; + this.readyPromise.resolve(); + return; + } - // Request a E2EKey for this room to other users - await this.requestRoomKey(e2eKeyId); - } catch (e) { - log(e); + // If it doesn't have a e2eKeyId, we need to create keys to the room + if (!e2eKeyId) { + this.establishing = true; + await this.createRoomKey(); + this.readyPromise.resolve(); + return; } + + // Request a E2EKey for this room to other users + this.requestRoomKey(e2eKeyId); }; // Import roomKey as an AES Decrypt key @@ -163,8 +173,9 @@ export default class EncryptionRoom { } }; - // Create a key to a room - createRoomKey = async () => { + hasSessionKey = () => !!this.sessionKeyExportedString; + + createNewRoomKey = async () => { const key = (await SimpleCrypto.utils.randomBytes(16)) as Uint8Array; this.roomKey = key; @@ -189,12 +200,29 @@ export default class EncryptionRoom { } else { this.keyID = Base64.encode(this.sessionKeyExportedString as string).slice(0, 12); } + }; + createRoomKey = async () => { + await this.createNewRoomKey(); await Services.e2eSetRoomKeyID(this.roomId, this.keyID); - - await this.encryptRoomKey(); + await this.encryptKeyForOtherParticipants(); }; + async resetRoomKey() { + if (!Encryption.publicKey) { + console.log('Public key not found'); + return; + } + try { + await this.createNewRoomKey(); + const e2eNewKeys = { e2eKeyId: this.keyID, e2eKey: await this.encryptRoomKeyForUser(Encryption.publicKey) }; + return e2eNewKeys; + } catch (error) { + console.error('Error resetting group key: ', error); + throw error; + } + } + // Request a key to this room // We're debouncing this function to avoid multiple calls // when you join a room with a lot of messages and nobody @@ -214,12 +242,37 @@ export default class EncryptionRoom { ); // Create an encrypted key for this room based on users - encryptRoomKey = async () => { + encryptKeyForOtherParticipants = async () => { try { + const decryptedOldGroupKeys = await this.exportOldRoomKeys(this.subscription?.oldRoomKeys); const result = await Services.e2eGetUsersOfRoomWithoutKey(this.roomId); if (result.success) { const { users } = result; - await Promise.all(users.map(user => this.encryptRoomKeyForUser(user))); + if (!users.length) { + return; + } + const { version } = store.getState().server; + if (compareServerVersion(version, 'greaterThanOrEqualTo', '7.0.0')) { + const usersSuggestedGroupKeys = { [this.roomId]: [] as any[] }; + for await (const user of users) { + const key = await this.encryptRoomKeyForUser(user.e2e!.public_key); + const oldKeys = await this.encryptOldKeysForParticipant(user.e2e?.public_key, decryptedOldGroupKeys); + + usersSuggestedGroupKeys[this.roomId].push({ _id: user._id, key, ...(oldKeys && { oldKeys }) }); + } + await Services.provideUsersSuggestedGroupKeys(usersSuggestedGroupKeys); + } else { + await Promise.all( + users.map(async user => { + if (user.e2e?.public_key) { + const key = await this.encryptRoomKeyForUser(user.e2e.public_key); + if (key) { + await Services.e2eUpdateGroupKey(user?._id, this.roomId, key); + } + } + }) + ); + } } } catch (e) { log(e); @@ -227,12 +280,13 @@ export default class EncryptionRoom { }; // Encrypt the room key to each user in - encryptRoomKeyForUser = async (user: Pick) => { - if (user?.e2e?.public_key) { - const { public_key: publicKey } = user.e2e; + encryptRoomKeyForUser = async (publicKey: string) => { + try { const userKey = await SimpleCrypto.RSA.importKey(EJSON.parse(publicKey)); const encryptedUserKey = await SimpleCrypto.RSA.encrypt(this.sessionKeyExportedString as string, userKey); - await Services.e2eUpdateGroupKey(user?._id, this.roomId, this.keyID + encryptedUserKey); + return this.keyID + encryptedUserKey; + } catch (e) { + log(e); } }; @@ -244,9 +298,82 @@ export default class EncryptionRoom { return; } - await this.encryptRoomKey(); + await this.encryptKeyForOtherParticipants(); }; + async encryptOldKeysForParticipant(publicKey: any, oldRoomKeys: any) { + if (!oldRoomKeys || oldRoomKeys.length === 0) { + return; + } + + let userKey; + + try { + userKey = await SimpleCrypto.RSA.importKey(EJSON.parse(publicKey)); + } catch (e) { + log(e); + return; + } + + try { + const keys = []; + for await (const oldRoomKey of oldRoomKeys) { + if (!oldRoomKey.E2EKey) { + continue; + } + const encryptedKey = await SimpleCrypto.RSA.encrypt(oldRoomKey.E2EKey, userKey); + const encryptedUserKey = oldRoomKey.e2eKeyId + encryptedKey; + keys.push({ ...oldRoomKey, E2EKey: encryptedUserKey }); + } + return keys; + } catch (e) { + log(e); + } + } + + async exportOldRoomKeys(oldKeys: any) { + if (!oldKeys || oldKeys.length === 0) { + return []; + } + + const keys = []; + for await (const key of oldKeys) { + try { + if (!key.E2EKey || !Encryption.privateKey) { + continue; + } + + const { sessionKeyExportedString } = await this.importRoomKey(key.E2EKey, Encryption.privateKey); + keys.push({ + ...key, + E2EKey: sessionKeyExportedString + }); + } catch (e) { + log(e); + } + } + + return keys; + } + + async encryptGroupKeyForParticipantsWaitingForTheKeys(users: any[]) { + if (!this.ready) { + return; + } + + const decryptedOldGroupKeys = await this.exportOldRoomKeys(this.subscription?.oldRoomKeys); + const usersWithKeys = await Promise.all( + users.map(async user => { + const { _id, public_key } = user; + const key = await this.encryptRoomKeyForUser(public_key); + const oldKeys = await this.encryptOldKeysForParticipant(public_key, decryptedOldGroupKeys); + return { _id, key, ...(oldKeys && { oldKeys }) }; + }) + ); + + return usersWithKeys; + } + // Encrypt text encryptText = async (text: string | ArrayBuffer) => { text = utf8ToBuffer(text as string); @@ -439,13 +566,7 @@ export default class EncryptionRoom { return null; } - msg = b64ToBuffer(msg.slice(12) as string); - const [vector, cipherText] = splitVectorData(msg); - - const decrypted = await SimpleCrypto.AES.decrypt(cipherText, this.roomKey, vector); - - const m = EJSON.parse(bufferToUtf8(decrypted)); - + const m = await this.decryptContent(msg as string); return m.text; }; @@ -462,9 +583,20 @@ export default class EncryptionRoom { return null; } + const keyID = contentBase64.slice(0, 12); const contentBuffer = b64ToBuffer(contentBase64.slice(12) as string); const [vector, cipherText] = splitVectorData(contentBuffer); - const decrypted = await SimpleCrypto.AES.decrypt(cipherText, this.roomKey, vector); + + let oldKey; + if (keyID !== this.keyID) { + const oldRoomKey = this.subscription?.oldRoomKeys?.find((key: any) => key.e2eKeyId === keyID); + if (oldRoomKey?.E2EKey && Encryption.privateKey) { + const { roomKey } = await this.importRoomKey(oldRoomKey.E2EKey, Encryption.privateKey); + oldKey = roomKey; + } + } + + const decrypted = await SimpleCrypto.AES.decrypt(cipherText, oldKey || this.roomKey, vector); return EJSON.parse(bufferToUtf8(decrypted)); }; diff --git a/app/lib/methods/helpers/mergeSubscriptionsRooms.ts b/app/lib/methods/helpers/mergeSubscriptionsRooms.ts index 4bd1cefb15..6e9ef7c4c0 100644 --- a/app/lib/methods/helpers/mergeSubscriptionsRooms.ts +++ b/app/lib/methods/helpers/mergeSubscriptionsRooms.ts @@ -53,6 +53,7 @@ export const merge = ( } mergedSubscription.encrypted = room?.encrypted; mergedSubscription.e2eKeyId = room?.e2eKeyId; + mergedSubscription.usersWaitingForE2EKeys = room?.usersWaitingForE2EKeys; mergedSubscription.avatarETag = room?.avatarETag; mergedSubscription.teamId = room?.teamId; mergedSubscription.teamMain = room?.teamMain; diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index b43a8fd6de..6f9540a0f6 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -86,6 +86,11 @@ export const e2eRejectSuggestedGroupKey = (rid: string): Promise<{ success: bool // RC 5.5 sdk.post('e2e.rejectSuggestedGroupKey', { rid }); +export const fetchUsersWaitingForGroupKey = (roomIds: string[]) => sdk.get('e2e.fetchUsersWaitingForGroupKey', { roomIds }); + +export const provideUsersSuggestedGroupKeys = (usersSuggestedGroupKeys: any) => + sdk.post('e2e.provideUsersSuggestedGroupKeys', { usersSuggestedGroupKeys }); + export const updateJitsiTimeout = (roomId: string) => // RC 0.74.0 sdk.post('video-conference/jitsi.update-timeout', { roomId }); @@ -933,6 +938,11 @@ export function e2eResetOwnKey(): Promise { return sdk.methodCallWrapper('e2e.resetOwnE2EKey'); } +export function e2eResetRoomKey(rid: string, e2eKey: string, e2eKeyId: string): Promise { + // RC ? + return sdk.post('e2e.resetRoomKey', { rid, e2eKey, e2eKeyId }); +} + export const editMessage = async (message: Pick) => { const { rid, msg } = await Encryption.encryptMessage(message as IMessage); // RC 0.49.0 diff --git a/app/sagas/login.js b/app/sagas/login.js index 4f422b50fe..ebd123e579 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -227,8 +227,8 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { getUserPresence(user.id); const server = yield select(getServer); - yield put(encryptionInit()); yield put(roomsRequest()); + yield put(encryptionInit()); yield fork(fetchPermissionsFork); yield fork(fetchCustomEmojisFork); yield fork(fetchRolesFork); diff --git a/app/stacks/InsideStack.tsx b/app/stacks/InsideStack.tsx index adbd46a588..a6a1ae482c 100644 --- a/app/stacks/InsideStack.tsx +++ b/app/stacks/InsideStack.tsx @@ -22,6 +22,7 @@ import MessagesView from '../views/MessagesView'; import AutoTranslateView from '../views/AutoTranslateView'; import DirectoryView from '../views/DirectoryView'; import NotificationPrefView from '../views/NotificationPreferencesView'; +import E2EEToggleRoomView from '../views/E2EEToggleRoomView'; import ForwardLivechatView from '../views/ForwardLivechatView'; import CloseLivechatView from '../views/CloseLivechatView'; import LivechatEditView from '../views/LivechatEditView'; @@ -122,6 +123,7 @@ const ChatsStackNavigator = () => { {/* @ts-ignore */} + {/* @ts-ignore */} diff --git a/app/stacks/MasterDetailStack/index.tsx b/app/stacks/MasterDetailStack/index.tsx index f3d552240c..dd1f051e22 100644 --- a/app/stacks/MasterDetailStack/index.tsx +++ b/app/stacks/MasterDetailStack/index.tsx @@ -21,6 +21,7 @@ import MessagesView from '../../views/MessagesView'; import AutoTranslateView from '../../views/AutoTranslateView'; import DirectoryView from '../../views/DirectoryView'; import NotificationPrefView from '../../views/NotificationPreferencesView'; +import E2EEToggleRoomView from '../../views/E2EEToggleRoomView'; import PushTroubleshootView from '../../views/PushTroubleshootView'; import ForwardLivechatView from '../../views/ForwardLivechatView'; import ForwardMessageView from '../../views/ForwardMessageView'; @@ -141,6 +142,7 @@ const ModalStackNavigator = React.memo(({ navigation }: INavigation) => { /> + {/* @ts-ignore */} diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts index f6d5c41a05..232f9e2d2b 100644 --- a/app/stacks/MasterDetailStack/types.ts +++ b/app/stacks/MasterDetailStack/types.ts @@ -117,6 +117,9 @@ export type ModalStackParamList = { rid: string; room: ISubscription; }; + E2EEToggleRoomView: { + rid: string; + }; ForwardMessageView: { message: TAnyMessageModel; }; diff --git a/app/stacks/types.ts b/app/stacks/types.ts index c3c9142216..fc67d9fe97 100644 --- a/app/stacks/types.ts +++ b/app/stacks/types.ts @@ -28,22 +28,22 @@ export type ChatsStackParamList = { NewMessageStack: undefined; RoomsListView: undefined; RoomView: - | { - rid: string; - t: SubscriptionType; - tmid?: string; - messageId?: string; - name?: string; - fname?: string; - prid?: string; - room?: TSubscriptionModel | { rid: string; t: string; name?: string; fname?: string; prid?: string }; - jumpToMessageId?: string; - jumpToThreadId?: string; - roomUserId?: string | null; - usedCannedResponse?: string; - status?: string; + | { + rid: string; + t: SubscriptionType; + tmid?: string; + messageId?: string; + name?: string; + fname?: string; + prid?: string; + room?: TSubscriptionModel | { rid: string; t: string; name?: string; fname?: string; prid?: string }; + jumpToMessageId?: string; + jumpToThreadId?: string; + roomUserId?: string | null; + usedCannedResponse?: string; + status?: string; } - | undefined; // Navigates back to RoomView already on stack + | undefined; // Navigates back to RoomView already on stack RoomActionsView: { room: TSubscriptionModel; member?: any; @@ -119,6 +119,9 @@ export type ChatsStackParamList = { room: TSubscriptionModel; }; DirectoryView: undefined; + E2EEToggleRoomView: { + rid: string; + }; NotificationPrefView: { rid: string; room: TSubscriptionModel; diff --git a/app/views/E2EEToggleRoomView/index.tsx b/app/views/E2EEToggleRoomView/index.tsx new file mode 100644 index 0000000000..4afb681331 --- /dev/null +++ b/app/views/E2EEToggleRoomView/index.tsx @@ -0,0 +1,82 @@ +import { RouteProp, useRoute } from '@react-navigation/native'; +import React, { useLayoutEffect } from 'react'; + +import * as List from '../../containers/List'; +import SafeAreaView from '../../containers/SafeAreaView'; +import StatusBar from '../../containers/StatusBar'; +import Switch from '../../containers/Switch'; +import I18n from '../../i18n'; +import { useIsMissingRoomE2EEKey } from '../../lib/encryption/helpers/hooks'; +import { toggleRoomE2EE } from '../../lib/encryption/helpers/toggleRoomE2EE'; +import { getRoomTitle } from '../../lib/methods/helpers'; +import { ChatsStackParamList } from '../../stacks/types'; +import { useTheme } from '../../theme'; +import { resetRoomKey } from './resetRoomKey'; +import { useRoom } from './useRoom'; + +const getRoomTypeI18n = (t?: string, teamMain?: boolean) => { + if (teamMain) { + return I18n.t('Team'); + } + if (t === 'd') { + return I18n.t('Direct_message'); + } + return I18n.t('Channel'); +}; + +const E2EEToggleRoomView = ({ navigation }: { navigation: any }) => { + const route = useRoute>(); + const { rid } = route.params; + const { colors } = useTheme(); + const room = useRoom(rid); + const isMissingRoomKey = useIsMissingRoomE2EEKey(room?.encrypted, room?.E2EKey); + + useLayoutEffect(() => { + navigation?.setOptions({ + title: I18n.t('E2E_Encryption') + }); + }, []); + + if (!room) { + return null; + } + + const roomType = getRoomTypeI18n(room?.t, room?.teamMain); + const roomName = getRoomTitle(room); + + return ( + + + + + + toggleRoomE2EE(rid)} />} + translateTitle={false} + /> + + + + + {isMissingRoomKey ? ( + + + resetRoomKey(rid)} + testID='e2ee-toggle-room-reset-key' + /> + + + + ) : null} + + + ); +}; +export default E2EEToggleRoomView; diff --git a/app/views/E2EEToggleRoomView/resetRoomKey.ts b/app/views/E2EEToggleRoomView/resetRoomKey.ts new file mode 100644 index 0000000000..aac1ed914d --- /dev/null +++ b/app/views/E2EEToggleRoomView/resetRoomKey.ts @@ -0,0 +1,46 @@ +import { Alert } from 'react-native'; + +import I18n from '../../i18n'; +import { Encryption } from '../../lib/encryption'; +import log from '../../lib/methods/helpers/log'; +import { showToast } from '../../lib/methods/helpers/showToast'; +import { e2eResetRoomKey } from '../../lib/services/restApi'; + +export const resetRoomKey = (rid: string) => { + Alert.alert( + I18n.t('Reset_room_key_title'), + I18n.t('Reset_room_key_message'), + [ + { + text: I18n.t('Cancel'), + style: 'cancel' + }, + { + text: I18n.t('Reset'), + style: 'destructive', + onPress: async () => { + try { + const e2eRoom = await Encryption.getRoomInstance(rid); + if (!e2eRoom) { + console.log('Encryption room instance not found'); + return; + } + + const { e2eKey, e2eKeyId } = (await e2eRoom.resetRoomKey()) ?? {}; + + if (!e2eKey || !e2eKeyId) { + return; + } + + await e2eResetRoomKey(rid, e2eKey, e2eKeyId); + showToast(I18n.t('Encryption_keys_reset')); + } catch (e) { + log(e); + showToast(I18n.t('Encryption_keys_failed')); + } + } + } + ], + { cancelable: true } + ); +}; diff --git a/app/views/E2EEToggleRoomView/useRoom.ts b/app/views/E2EEToggleRoomView/useRoom.ts new file mode 100644 index 0000000000..9bd7503e54 --- /dev/null +++ b/app/views/E2EEToggleRoomView/useRoom.ts @@ -0,0 +1,32 @@ +import { useEffect, useState } from 'react'; +import { Subscription } from 'rxjs'; + +import { getSubscriptionByRoomId } from '../../lib/database/services/Subscription'; +import { ISubscription } from '../../definitions'; + +type TResult = ISubscription | null; + +export const useRoom = (rid: string): TResult => { + const [room, setRoom] = useState(null); + + useEffect(() => { + let subSubscription: Subscription; + getSubscriptionByRoomId(rid).then(sub => { + if (!sub) { + return; + } + const observable = sub.observe(); + subSubscription = observable.subscribe(s => { + setRoom(s.asPlain()); + }); + }); + + return () => { + if (subSubscription && subSubscription?.unsubscribe) { + subSubscription.unsubscribe(); + } + }; + }, []); + + return room; +}; diff --git a/app/views/RoomActionsView/index.tsx b/app/views/RoomActionsView/index.tsx index 7f3ed3c8e8..a3fc62249c 100644 --- a/app/views/RoomActionsView/index.tsx +++ b/app/views/RoomActionsView/index.tsx @@ -52,9 +52,7 @@ import { ILivechatDepartment } from '../../definitions/ILivechatDepartment'; import { ILivechatTag } from '../../definitions/ILivechatTag'; import CallSection from './components/CallSection'; import { TNavigation } from '../../stacks/stackType'; -import Switch from '../../containers/Switch'; import * as EncryptionUtils from '../../lib/encryption/utils'; -import { toggleRoomE2EE } from '../../lib/encryption/helpers/toggleRoomE2EE'; type StackType = ChatsStackParamList & TNavigation; @@ -361,19 +359,6 @@ class RoomActionsView extends React.Component { - const { room, canToggleEncryption, canEdit } = this.state; - const { rid, encrypted } = room; - const { serverVersion } = this.props; - let hasPermission = false; - if (compareServerVersion(serverVersion, 'lowerThan', '3.11.0')) { - hasPermission = canEdit; - } else { - hasPermission = canToggleEncryption; - } - return toggleRoomE2EE(rid)} disabled={!hasPermission} />; - }; - closeLivechat = async () => { try { const { @@ -802,21 +787,34 @@ class RoomActionsView extends React.Component { - const { room } = this.state; - const { encryptionEnabled } = this.props; + const { room, canToggleEncryption, canEdit } = this.state; - // If this room type can be encrypted - // If e2e is enabled - if (E2E_ROOM_TYPES[room.t] && encryptionEnabled) { + const { serverVersion } = this.props; + let hasPermission = false; + if (compareServerVersion(serverVersion, 'lowerThan', '3.11.0')) { + hasPermission = canEdit; + } else { + hasPermission = canToggleEncryption; + } + + if (E2E_ROOM_TYPES[room.t]) { return ( } - right={this.renderEncryptedSwitch} - additionalAcessibilityLabel={!!room.encrypted} + onPress={() => + this.onPressTouchable({ + route: 'E2EEToggleRoomView', + params: { + rid: room.rid + } + }) + } + disabled={!hasPermission} + showActionIndicator /> diff --git a/app/views/RoomView/RightButtons.tsx b/app/views/RoomView/RightButtons.tsx index dae2f343de..51a832594d 100644 --- a/app/views/RoomView/RightButtons.tsx +++ b/app/views/RoomView/RightButtons.tsx @@ -22,7 +22,6 @@ import { TNavigation } from '../../stacks/stackType'; import { ChatsStackParamList } from '../../stacks/types'; import { HeaderCallButton } from './components'; import { TColors, TSupportedThemes, withTheme } from '../../theme'; -import { toggleRoomE2EE } from '../../lib/encryption/helpers/toggleRoomE2EE'; interface IRightButtonsProps extends Pick { userId?: string; @@ -79,7 +78,7 @@ class RightButtonsContainer extends Component): void { const { toggleRoomE2EEncryptionPermission } = this.props; - if (prevProps.toggleRoomE2EEncryptionPermission !== toggleRoomE2EEncryptionPermission) { + if (!dequal(prevProps.toggleRoomE2EEncryptionPermission, toggleRoomE2EEncryptionPermission)) { this.setCanToggleEncryption(); } } @@ -191,6 +192,11 @@ class RightButtonsContainer extends Component { this.updateSubscription(sub); + + const { hasE2EEWarning } = this.props; + if (hasE2EEWarning) { + this.setCanToggleEncryption(); + } }); }; @@ -391,6 +397,24 @@ class RightButtonsContainer extends Component { + logEvent(events.ROOM_GO_SEARCH); + const { rid, navigation, isMasterDetail } = this.props; + if (!rid) { + return; + } + if (isMasterDetail) { + // @ts-ignore TODO: find a way to make this work + navigation.navigate('ModalStackNavigator', { + screen: 'E2EEToggleRoomView', + params: { rid } + }); + } else { + // @ts-ignore + navigation.navigate('E2EEToggleRoomView', { rid }); + } + }; + toggleFollowThread = () => { logEvent(events.ROOM_TOGGLE_FOLLOW_THREADS); const { isFollowingThread } = this.state; @@ -442,7 +466,12 @@ class RightButtonsContainer extends Component {hasE2EEWarning ? ( - toggleRoomE2EE(rid)} disabled={!canToggleEncryption} /> + ) : null} {issuesWithNotifications || notificationsDisabled ? ( { onPress={this.goRoomActionsView} testID={`room-view-title-${title}`} sourceType={sourceType} - disabled={e2eeWarning} rightButtonsWidth={rightButtonsWidth} /> ), diff --git a/e2e/helpers/app.ts b/e2e/helpers/app.ts index 6df4f9718d..73d51d2f98 100644 --- a/e2e/helpers/app.ts +++ b/e2e/helpers/app.ts @@ -132,20 +132,28 @@ async function searchRoom( nativeElementAction: keyof Pick = 'typeText', roomTestID?: string ) { + const testID = roomTestID || `rooms-list-view-item-${room}`; await waitFor(element(by.id('rooms-list-view'))) .toExist() .withTimeout(30000); - await tapAndWaitFor(element(by.id('rooms-list-view-search')), element(by.id('rooms-list-view-search-input')), 5000); - if (nativeElementAction === 'replaceText') { - // trigger the input's onChangeText - await element(by.id('rooms-list-view-search-input')).typeText(' '); + + try { + await waitFor(element(by.id(testID))) + .toBeVisible() + .withTimeout(2000); + await expect(element(by.id(testID))).toBeVisible(); + } catch { + await tapAndWaitFor(element(by.id('rooms-list-view-search')), element(by.id('rooms-list-view-search-input')), 5000); + if (nativeElementAction === 'replaceText') { + // trigger the input's onChangeText + await element(by.id('rooms-list-view-search-input')).typeText(' '); + } + await element(by.id('rooms-list-view-search-input'))[nativeElementAction](room); + await sleep(500); + await waitFor(element(by.id(testID))) + .toBeVisible() + .withTimeout(60000); } - await element(by.id('rooms-list-view-search-input'))[nativeElementAction](room); - await sleep(500); - await sleep(500); - await waitFor(element(by.id(roomTestID || `rooms-list-view-item-${room}`))) - .toBeVisible() - .withTimeout(60000); } async function navigateToRoom(room: string) { diff --git a/e2e/helpers/data_setup.ts b/e2e/helpers/data_setup.ts index d963877b43..b451344a3a 100644 --- a/e2e/helpers/data_setup.ts +++ b/e2e/helpers/data_setup.ts @@ -48,6 +48,7 @@ export const createRandomUser = async (customProps?: Object): Promise email: user.email, ...(customProps || {}) }); + console.log(`Created ${user.username} / ${user.password}`); return user; } catch (error) { console.log(JSON.stringify(error)); diff --git a/e2e/tests/assorted/01-e2eencryption.spec.ts b/e2e/tests/assorted/01-e2eencryption.spec.ts index 369a3cea6d..e17151f70b 100644 --- a/e2e/tests/assorted/01-e2eencryption.spec.ts +++ b/e2e/tests/assorted/01-e2eencryption.spec.ts @@ -5,46 +5,45 @@ import { login, sleep, tapBack, - logout, platformTypes, TTextMatcher, tapAndWaitFor, - expectValidRegisterOrRetry, mockMessage, tryTapping, - navigateToRoom + navigateToRoom, + checkRoomTitle } from '../../helpers/app'; -import data from '../../data'; import { createRandomUser, deleteCreatedUsers, IDeleteCreateUser, ITestUser } from '../../helpers/data_setup'; import random from '../../helpers/random'; -const checkServer = async (server: string) => { - const label = `Connected to ${server}`; - await waitFor(element(by.id('rooms-list-view-sidebar'))) - .toBeVisible() - .withTimeout(10000); - await element(by.id('rooms-list-view-sidebar')).tap(); - await waitFor(element(by.id('sidebar-view'))) - .toBeVisible() - .withTimeout(2000); - await waitFor(element(by.label(label))) - .toBeVisible() - .withTimeout(60000); - await element(by.id('sidebar-close-drawer')).tap(); -}; - -const checkBanner = async () => { - // TODO: Assert 'Save Your Encryption Password' - await waitFor(element(by.id('listheader-encryption'))) - .toExist() - .withTimeout(10000); +let alertButtonType: string; +let textMatcher: TTextMatcher; +const newPassword = 'abc'; +const getMessage = (i: number) => `m${i}`; + +const loginAs = async (user: ITestUser, enterE2EEPassword = true) => { + await device.launchApp({ permissions: { notifications: 'YES' }, delete: true }); + await navigateToLogin(); + await login(user.username, user.password); + + if (enterE2EEPassword) { + await waitFor(element(by.id('listheader-encryption'))) + .toBeVisible() + .withTimeout(2000); + await tapAndWaitFor(element(by.id('listheader-encryption')), element(by.id('e2e-enter-your-password-view')), 2000); + await element(by.id('e2e-enter-your-password-view-password')).replaceText(newPassword); + await element(by.id('e2e-enter-your-password-view-confirm')).tap(); + await waitFor(element(by.id('listheader-encryption'))) + .not.toExist() + .withTimeout(10000); + } }; async function waitForToast() { await sleep(300); } -async function navigateSecurityPrivacy() { +const navToE2EESecurity = async () => { await waitFor(element(by.id('rooms-list-view'))) .toBeVisible() .withTimeout(2000); @@ -63,374 +62,211 @@ async function navigateSecurityPrivacy() { await waitFor(element(by.id('security-privacy-view'))) .toBeVisible() .withTimeout(2000); -} + await expect(element(by.id('security-privacy-view-e2e-encryption'))).toExist(); + await element(by.id('security-privacy-view-e2e-encryption')).tap(); + await waitFor(element(by.id('e2e-encryption-security-view'))) + .toBeVisible() + .withTimeout(2000); + // await expect(element(by.id('e2e-encryption-security-view-password'))).toExist(); + // await expect(element(by.id('e2e-encryption-security-view-change-password'))).toExist(); + await expect(element(by.id('e2e-encryption-security-view-reset-key'))).toExist(); +}; + +const changeE2EEPassword = async () => { + await navToE2EESecurity(); + await element(by.id('e2e-encryption-security-view-password')).replaceText(newPassword); + await element(by.id('e2e-encryption-security-view-change-password')).tap(); + await waitFor(element(by[textMatcher]('Are you sure?'))) + .toExist() + .withTimeout(2000); + await expect(element(by[textMatcher]("Make sure you've saved it carefully somewhere else."))).toExist(); + await element(by[textMatcher]('Yes, change it')).atIndex(0).tap(); + await waitForToast(); +}; + +const resetE2EEKey = async () => { + await navToE2EESecurity(); + await element(by.id('e2e-encryption-security-view-reset-key')).tap(); + await waitFor(element(by[textMatcher]('Are you sure?'))) + .toExist() + .withTimeout(2000); + await expect(element(by[textMatcher]("You're going to be logged out."))).toExist(); + await element(by[textMatcher]('Yes, reset it').and(by.type(alertButtonType))).tap(); + await waitFor(element(by.id('new-server-view'))) + .toBeVisible() + .withTimeout(60000); +}; + +const readMessages = async (count: number) => { + for (let i = 0; i < count; i++) { + await waitFor(element(by[textMatcher](getMessage(i))).atIndex(0)) + .toExist() + .withTimeout(2000); + } +}; describe('E2E Encryption', () => { const room = `encrypted${random()}`; - let user: ITestUser; - let otherUser: ITestUser; - let mockedMessageText: string; - const newPassword = 'abc'; - - let alertButtonType: string; - let textMatcher: TTextMatcher; + let UserA: ITestUser; + let UserB: ITestUser; const deleteUsersAfterAll: IDeleteCreateUser[] = []; beforeAll(async () => { - user = await createRandomUser(); - otherUser = await createRandomUser(); - await device.launchApp({ permissions: { notifications: 'YES' }, delete: true }); + UserA = await createRandomUser(); + UserB = await createRandomUser(); ({ alertButtonType, textMatcher } = platformTypes[device.getPlatform()]); - await navigateToLogin(); - await login(user.username, user.password); + await loginAs(UserB, false); + await changeE2EEPassword(); + await loginAs(UserA, false); + await changeE2EEPassword(); }); afterAll(async () => { await deleteCreatedUsers(deleteUsersAfterAll); }); - describe('Banner', () => { - describe('Render', () => { - it('should have encryption badge', async () => { - await checkBanner(); - }); - }); - - describe('Usage', () => { - it('should tap encryption badge and open save password modal', async () => { - await element(by.id('listheader-encryption')).tap(); - await waitFor(element(by.id('e2e-save-password-view'))) - .toBeVisible() - .withTimeout(2000); - }); - - it('should tap "How it works" and navigate', async () => { - await element(by.id('e2e-save-password-view-how-it-works')).tap(); - await waitFor(element(by.id('e2e-how-it-works-view'))) - .toBeVisible() - .withTimeout(2000); - await tapBack(); - }); - - it('should tap "Save my password" and close modal', async () => { - await element(by.id('e2e-save-password-view-saved-password')).tap(); - await sleep(300); // wait for animation - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(2000); - }); - - it('should create encrypted room', async () => { - await element(by.id('rooms-list-view-create-channel')).tap(); - await waitFor(element(by.id('new-message-view'))) - .toBeVisible() - .withTimeout(5000); - await waitFor(element(by.id('new-message-view-create-channel'))) - .toBeVisible() - .withTimeout(2000); - await element(by.id('new-message-view-create-channel')).tap(); - await waitFor(element(by.id('select-users-view'))) - .toBeVisible() - .withTimeout(2000); - await element(by.id('select-users-view-search')).replaceText(otherUser.username); - await waitFor(element(by.id(`select-users-view-item-${otherUser.username}`))) - .toBeVisible() - .withTimeout(60000); - await element(by.id(`select-users-view-item-${otherUser.username}`)).tap(); - await waitFor(element(by.id(`selected-user-${otherUser.username}`))) - .toBeVisible() - .withTimeout(5000); - await element(by.id('selected-users-view-submit')).tap(); - await waitFor(element(by.id('create-channel-view'))) - .toExist() - .withTimeout(5000); - await element(by.id('create-channel-name')).replaceText(room); - await element(by.id('create-channel-name')).tapReturnKey(); - await element(by.id('create-channel-encrypted')).longPress(); - await element(by.id('create-channel-submit')).tap(); - await waitFor(element(by.id('room-view'))) - .toBeVisible() - .withTimeout(60000); - await waitFor(element(by.id(`room-view-title-${room}`))) - .toBeVisible() - .withTimeout(60000); - }); - - it('should send message and be able to read it', async () => { - mockedMessageText = await mockMessage('message'); - }); - - it('should quote a message and be able to read both', async () => { - const mockedMessageTextToQuote = await mockMessage('message to be quote'); - const quotedMessage = `${mockedMessageTextToQuote}d`; - await tryTapping(element(by[textMatcher](mockedMessageTextToQuote)).atIndex(0), 2000, true); - await waitFor(element(by.id('action-sheet'))) - .toExist() - .withTimeout(2000); - await expect(element(by.id('action-sheet-handle'))).toBeVisible(); - await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); - await element(by[textMatcher]('Quote')).atIndex(0).tap(); - await element(by.id('message-composer-input')).replaceText(quotedMessage); - await waitFor(element(by.id('message-composer-send'))) - .toExist() - .withTimeout(2000); - await element(by.id('message-composer-send')).tap(); - await waitFor(element(by[textMatcher](quotedMessage)).atIndex(0)) - .toBeVisible() - .withTimeout(3000); - await waitFor( - element( - by.id(`reply-${user.name}-${mockedMessageTextToQuote}`).withDescendant(by[textMatcher](mockedMessageTextToQuote)) - ) - ) - .toBeVisible() - .withTimeout(3000); - await tapBack(); - }); - }); - }); - - describe('Security and Privacy', () => { - it('should navigate to security privacy', async () => { - await waitFor(element(by.id('rooms-list-view'))) + describe('Create room as UserA and send a message', () => { + it('should create encrypted room', async () => { + await device.launchApp({ permissions: { notifications: 'YES' }, newInstance: true }); + await element(by.id('rooms-list-view-create-channel')).tap(); + await waitFor(element(by.id('new-message-view'))) .toBeVisible() - .withTimeout(2000); - await element(by.id('rooms-list-view-sidebar')).tap(); - await waitFor(element(by.id('sidebar-view'))) + .withTimeout(5000); + await waitFor(element(by.id('new-message-view-create-channel'))) .toBeVisible() .withTimeout(2000); - await waitFor(element(by.id('sidebar-settings'))) + await element(by.id('new-message-view-create-channel')).tap(); + await waitFor(element(by.id('select-users-view'))) .toBeVisible() .withTimeout(2000); - await element(by.id('sidebar-settings')).tap(); - await waitFor(element(by.id('settings-view'))) + await element(by.id('select-users-view-search')).replaceText(UserB.username); + await waitFor(element(by.id(`select-users-view-item-${UserB.username}`))) .toBeVisible() - .withTimeout(2000); - await element(by.id('settings-view-security-privacy')).tap(); - await waitFor(element(by.id('security-privacy-view'))) + .withTimeout(60000); + await element(by.id(`select-users-view-item-${UserB.username}`)).tap(); + await waitFor(element(by.id(`selected-user-${UserB.username}`))) .toBeVisible() - .withTimeout(2000); + .withTimeout(5000); + await element(by.id('selected-users-view-submit')).tap(); + await waitFor(element(by.id('create-channel-view'))) + .toExist() + .withTimeout(5000); + await element(by.id('create-channel-name')).replaceText(room); + await element(by.id('create-channel-name')).tapReturnKey(); + await element(by.id('create-channel-encrypted')).longPress(); + await element(by.id('create-channel-submit')).tap(); + await checkRoomTitle(room); }); - it('render', async () => { - await expect(element(by.id('security-privacy-view-e2e-encryption'))).toExist(); - await expect(element(by.id('security-privacy-view-screen-lock'))).toExist(); - await expect(element(by.id('security-privacy-view-analytics-events'))).toExist(); - await expect(element(by.id('security-privacy-view-crash-report'))).toExist(); + it('should send message and be able to read it', async () => { + await mockMessage(getMessage(0)); }); - }); - describe('E2E Encryption Security', () => { - it('should navigate to e2e encryption security', async () => { - await element(by.id('security-privacy-view-e2e-encryption')).tap(); - await waitFor(element(by.id('e2e-encryption-security-view'))) - .toBeVisible() + it('should quote a message and be able to read both', async () => { + const mockedMessageTextToQuote = await mockMessage(getMessage(1)); + const quotedMessage = getMessage(2); + await tryTapping(element(by[textMatcher](mockedMessageTextToQuote)).atIndex(0), 2000, true); + await waitFor(element(by.id('action-sheet'))) + .toExist() .withTimeout(2000); + await expect(element(by.id('action-sheet-handle'))).toBeVisible(); + await element(by.id('action-sheet-handle')).swipe('up', 'fast', 0.5); + await element(by[textMatcher]('Quote')).atIndex(0).tap(); + await element(by.id('message-composer-input')).replaceText(quotedMessage); + await waitFor(element(by.id('message-composer-send'))) + .toExist() + .withTimeout(2000); + await element(by.id('message-composer-send')).tap(); + await waitFor(element(by[textMatcher](quotedMessage)).atIndex(0)) + .toBeVisible() + .withTimeout(3000); + await waitFor( + element( + by.id(`reply-${UserA.name}-${mockedMessageTextToQuote}`).withDescendant(by[textMatcher](mockedMessageTextToQuote)) + ) + ) + .toBeVisible() + .withTimeout(3000); + await tapBack(); }); + }); - describe('Render', () => { - it('should have items', async () => { - await waitFor(element(by.id('e2e-encryption-security-view'))) - .toBeVisible() - .withTimeout(2000); - await expect(element(by.id('e2e-encryption-security-view-password'))).toExist(); - await expect(element(by.id('e2e-encryption-security-view-change-password'))).toExist(); - await expect(element(by.id('e2e-encryption-security-view-reset-key'))).toExist(); - }); + describe('Login as UserB, get keys and send a message', () => { + beforeAll(async () => { + await loginAs(UserB); }); - describe('Change password', () => { - it('should change password', async () => { - await element(by.id('e2e-encryption-security-view-password')).replaceText(newPassword); - await element(by.id('e2e-encryption-security-view-change-password')).tap(); - await waitFor(element(by[textMatcher]('Are you sure?'))) - .toExist() - .withTimeout(2000); - await expect(element(by[textMatcher]("Make sure you've saved it carefully somewhere else."))).toExist(); - await element(by[textMatcher]('Yes, change it')).atIndex(0).tap(); - await waitForToast(); - }); - - it('should navigate to the room and messages should remain decrypted', async () => { - await waitFor(element(by.id('e2e-encryption-security-view'))) - .toBeVisible() - .withTimeout(2000); - await tapBack(); - await waitFor(element(by.id('security-privacy-view'))) - .toBeVisible() - .withTimeout(2000); - await tapBack(); - await waitFor(element(by.id('settings-view'))) - .toBeVisible() - .withTimeout(2000); - await element(by.id('settings-view-drawer')).tap(); - await waitFor(element(by.id('sidebar-view'))) - .toBeVisible() - .withTimeout(2000); - await element(by.id('sidebar-chats')).tap(); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(2000); - await navigateToRoom(room); - await waitFor(element(by[textMatcher](mockedMessageText)).atIndex(0)) - .toExist() - .withTimeout(2000); - }); - - it('should logout, login and messages should be encrypted', async () => { - await tapBack(); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(2000); - await logout(); - await navigateToLogin(); - await login(user.username, user.password); - await navigateToRoom(room); - await waitFor(element(by[textMatcher](mockedMessageText)).atIndex(0)) - .not.toExist() - .withTimeout(2000); - await waitFor(element(by.id('room-view-encrypted-room'))) - .toBeVisible() - .withTimeout(2000); - }); - - it('should enter new e2e password and messages should be decrypted', async () => { - await tapBack(); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(2000); - // TODO: assert 'Enter E2EE Password' - await waitFor(element(by.id('listheader-encryption'))) - .toBeVisible() - .withTimeout(2000); - await tapAndWaitFor(element(by.id('listheader-encryption')), element(by.id('e2e-enter-your-password-view')), 2000); - await element(by.id('e2e-enter-your-password-view-password')).replaceText(newPassword); - await element(by.id('e2e-enter-your-password-view-confirm')).tap(); - await waitFor(element(by.id('listheader-encryption'))) - .not.toExist() - .withTimeout(10000); - await navigateToRoom(room); - await waitFor(element(by[textMatcher](mockedMessageText)).atIndex(0)) - .toExist() - .withTimeout(2000); - }); + it('should be able to read other messages', async () => { + await navigateToRoom(room); + await readMessages(3); }); - describe('Reset E2E key', () => { - beforeAll(async () => { - await tapBack(); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(2000); - }); - it('should reset e2e key', async () => { - await navigateSecurityPrivacy(); - await element(by.id('security-privacy-view-e2e-encryption')).tap(); - await waitFor(element(by.id('e2e-encryption-security-view'))) - .toBeVisible() - .withTimeout(2000); - await element(by.id('e2e-encryption-security-view-reset-key')).tap(); - await waitFor(element(by[textMatcher]('Are you sure?'))) - .toExist() - .withTimeout(2000); - await expect(element(by[textMatcher]("You're going to be logged out."))).toExist(); - await element(by[textMatcher]('Yes, reset it').and(by.type(alertButtonType))).tap(); - await sleep(2000); - - // FIXME: The app isn't showing this alert anymore - // await waitFor(element(by[textMatcher]("You've been logged out by the server. Please log in again."))) - // .toExist() - // .withTimeout(20000); - // await element(by[textMatcher]('OK').and(by.type(alertButtonType))).tap(); - // await waitFor(element(by.id('workspace-view'))) - // .toBeVisible() - // .withTimeout(10000); - // await element(by.id('workspace-view-login')).tap(); - await navigateToLogin(); - await waitFor(element(by.id('login-view'))) - .toBeVisible() - .withTimeout(2000); - await login(user.username, user.password); - // TODO: assert 'Save Your Encryption Password' - await waitFor(element(by.id('listheader-encryption'))) - .toBeVisible() - .withTimeout(5000); - }); + it('should send message and be able to read it', async () => { + await mockMessage(getMessage(3)); }); }); - describe('Persist Banner', () => { - it('check save banner', async () => { - await checkServer(data.server); - await checkBanner(); + describe('Login as UserA, reset user e2ee key, reset room E2EE key and send a message', () => { + beforeAll(async () => { + await loginAs(UserA, false); }); - it('should add server and create new user', async () => { - await sleep(5000); - await element(by.id('rooms-list-header-servers-list-button')).tap(); - await waitFor(element(by.id('rooms-list-header-servers-list'))) - .toBeVisible() - .withTimeout(5000); - await element(by.id('rooms-list-header-server-add')).tap(); + it('should reset user E2EE key, login again and recreate keys', async () => { + await resetE2EEKey(); + await loginAs(UserA, false); + await changeE2EEPassword(); + }); - // TODO: refactor - await waitFor(element(by.id('new-server-view'))) + it('should reset room E2EE key', async () => { + await device.launchApp({ permissions: { notifications: 'YES' }, newInstance: true }); + await navigateToRoom(room); + await waitFor(element(by.id('room-view-header-encryption'))) .toBeVisible() - .withTimeout(60000); - await element(by.id('new-server-view-input')).replaceText(`${data.alternateServer}`); - await element(by.id('new-server-view-input')).tapReturnKey(); - await waitFor(element(by.id('workspace-view'))) + .withTimeout(2000); + await element(by.id('room-view-header-encryption')).tap(); + await waitFor(element(by.id('e2ee-toggle-room-view'))) .toBeVisible() - .withTimeout(60000); - await element(by.id('workspace-view-register')).tap(); - await waitFor(element(by.id('register-view'))) + .withTimeout(2000); + await waitFor(element(by.id('e2ee-toggle-room-reset-key'))) .toBeVisible() .withTimeout(2000); + await element(by.id('e2ee-toggle-room-reset-key')).tap(); + await waitFor(element(by[textMatcher]('Reset encryption key'))) + .toExist() + .withTimeout(2000); + await element(by[textMatcher]('Reset')).atIndex(0).tap(); + await waitForToast(); + await tapBack(); + await checkRoomTitle(room); + }); - // Register new user - const randomUser = data.randomUser(); - await element(by.id('register-view-name')).replaceText(randomUser.name); - await element(by.id('register-view-name')).tapReturnKey(); - await element(by.id('register-view-username')).replaceText(randomUser.username); - await element(by.id('register-view-username')).tapReturnKey(); - await element(by.id('register-view-email')).replaceText(randomUser.email); - await element(by.id('register-view-email')).tapReturnKey(); - await element(by.id('register-view-password')).replaceText(randomUser.password); - await element(by.id('register-view-password')).tapReturnKey(); - await expectValidRegisterOrRetry(device.getPlatform()); - deleteUsersAfterAll.push({ server: data.alternateServer, username: randomUser.username }); - - await checkServer(data.alternateServer); + it('should send message and be able to read it', async () => { + await mockMessage(getMessage(4)); }); + }); - it('should change back', async () => { - await waitFor(element(by.id('rooms-list-header-servers-list-button'))) - .toExist() - .withTimeout(2000); - await element(by.id('rooms-list-header-servers-list-button')).tap(); - await waitFor(element(by.id('rooms-list-header-servers-list'))) - .toBeVisible() - .withTimeout(5000); - await element(by.id(`server-item-${data.server}`)).tap(); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(10000); - await checkServer(data.server); - await checkBanner(); + describe('Login as UserB, accept new room key, send a message and read everything', () => { + beforeAll(async () => { + await loginAs(UserB); }); - it('should reopen the app and have banner', async () => { - await device.launchApp({ - permissions: { notifications: 'YES' }, - newInstance: true - }); - await waitFor(element(by.id('rooms-list-view'))) - .toBeVisible() - .withTimeout(10000); - await checkBanner(); + it('should send message and be able to read it', async () => { + await navigateToRoom(room); + await mockMessage(getMessage(5)); + await readMessages(4); }); }); - // TODO: missing request e2ee room key + // describe('Login as UserA, accept new room key, send a message and read everything', () => { + // beforeAll(async () => { + // await loginAs(UserA); + // }); + + // it('should send message and be able to read it', async () => { + // await navigateToRoom(room); + // await mockMessage(getMessage(6)); + // await readMessages(5); + // }); + // }); }); diff --git a/yarn.lock b/yarn.lock index c18900cf33..5b30579881 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3842,8 +3842,8 @@ tldts "~5.7.112" "@rocket.chat/sdk@RocketChat/Rocket.Chat.js.SDK#mobile": - version "1.3.1-mobile" - resolved "https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/501cd6ceec5f198af288aadc355f2fbbeda2b353" + version "1.3.3-mobile" + resolved "https://codeload.github.com/RocketChat/Rocket.Chat.js.SDK/tar.gz/b6d2b3f25b0ff8283dd71faf6dae720c47fecd8f" dependencies: js-sha256 "^0.9.0" lru-cache "^4.1.1" From 523b8a2738e05e7564b585e2c94a84d0c27f04d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ot=C3=A1vio=20Stasiak?= <91474186+OtavioStasiak@users.noreply.github.com> Date: Mon, 16 Dec 2024 15:55:49 -0300 Subject: [PATCH 2/9] chore: Legal button on SettingsView (#6053) Co-authored-by: Diego Mello --- app/stacks/InsideStack.tsx | 3 +++ app/stacks/MasterDetailStack/index.tsx | 3 +++ app/stacks/MasterDetailStack/types.ts | 1 + app/stacks/types.ts | 1 + app/views/LegalView.tsx | 4 +-- app/views/SettingsView/index.tsx | 8 ++++++ e2e/tests/assorted/04-setting.spec.ts | 36 +++++++++++++++++++++++++- 7 files changed, 53 insertions(+), 3 deletions(-) diff --git a/app/stacks/InsideStack.tsx b/app/stacks/InsideStack.tsx index a6a1ae482c..c1abc6d0b3 100644 --- a/app/stacks/InsideStack.tsx +++ b/app/stacks/InsideStack.tsx @@ -74,6 +74,7 @@ import AddExistingChannelView from '../views/AddExistingChannelView'; import SelectListView from '../views/SelectListView'; import DiscussionsView from '../views/DiscussionsView'; import ChangeAvatarView from '../views/ChangeAvatarView'; +import LegalView from '../views/LegalView'; import { AdminPanelStackParamList, ChatsStackParamList, @@ -188,6 +189,8 @@ const SettingsStackNavigator = () => { + {/* @ts-ignore */} + { options={props => ReadReceiptsView.navigationOptions!({ ...props, isMasterDetail: true })} /> + {/* @ts-ignore */} + diff --git a/app/stacks/MasterDetailStack/types.ts b/app/stacks/MasterDetailStack/types.ts index 232f9e2d2b..dca88bac9e 100644 --- a/app/stacks/MasterDetailStack/types.ts +++ b/app/stacks/MasterDetailStack/types.ts @@ -200,6 +200,7 @@ export type ModalStackParamList = { MediaAutoDownloadView: undefined; E2EEncryptionSecurityView: undefined; PushTroubleshootView: undefined; + LegalView: undefined; SupportedVersionsWarning: { showCloseButton?: boolean; }; diff --git a/app/stacks/types.ts b/app/stacks/types.ts index fc67d9fe97..487483d098 100644 --- a/app/stacks/types.ts +++ b/app/stacks/types.ts @@ -205,6 +205,7 @@ export type ProfileStackParamList = { }; export type SettingsStackParamList = { + LegalView: undefined; SettingsView: undefined; SecurityPrivacyView: undefined; E2EEncryptionSecurityView: undefined; diff --git a/app/views/LegalView.tsx b/app/views/LegalView.tsx index 8d53fe4a46..22acc4d922 100644 --- a/app/views/LegalView.tsx +++ b/app/views/LegalView.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useLayoutEffect } from 'react'; import { useSelector } from 'react-redux'; import I18n from '../i18n'; @@ -18,7 +18,7 @@ const LegalView = ({ navigation }: ILegalViewProps): React.ReactElement => { const server = useSelector((state: IApplicationState) => state.server.server); const { theme } = useTheme(); - useEffect(() => { + useLayoutEffect(() => { navigation.setOptions({ title: I18n.t('Legal') }); diff --git a/app/views/SettingsView/index.tsx b/app/views/SettingsView/index.tsx index 94e25e4c38..1bbe646ac5 100644 --- a/app/views/SettingsView/index.tsx +++ b/app/views/SettingsView/index.tsx @@ -259,6 +259,14 @@ const SettingsView = (): React.ReactElement => { left={() => } /> + navigateToScreen('LegalView')} + testID='settings-view-legal' + left={() => } + /> + { await expect(element(by.id('settings-view-media-auto-download'))).toExist(); }); - it('should have licence', async () => { + it('should have license', async () => { await expect(element(by.id('settings-view-license'))).toExist(); }); + it('should have legal', async () => { + await expect(element(by.id('settings-view-legal'))).toExist(); + }); + it('should have version no', async () => { await expect(element(by.id('settings-view-version'))).toExist(); }); @@ -106,5 +110,35 @@ describe('Settings screen', () => { .toExist() .withTimeout(10000); }); + + describe('Legal button', () => { + it('should navigate to legalview', async () => { + await element(by.id('rooms-list-view-sidebar')).tap(); + await waitFor(element(by.id('sidebar-view'))) + .toBeVisible() + .withTimeout(2000); + await waitFor(element(by.id('sidebar-settings'))) + .toBeVisible() + .withTimeout(2000); + await element(by.id('sidebar-settings')).tap(); + await waitFor(element(by.id('settings-view'))) + .toBeVisible() + .withTimeout(2000); + + await expect(element(by.id('settings-view-legal'))).toExist(); + await element(by.id('settings-view-legal')).tap(); + await waitFor(element(by.id('legal-view'))) + .toBeVisible() + .withTimeout(4000); + }); + + it('should have terms of service button', async () => { + await expect(element(by.id('legal-terms-button'))).toBeVisible(); + }); + + it('should have privacy policy button', async () => { + await expect(element(by.id('legal-privacy-button'))).toBeVisible(); + }); + }); }); }); From c67e02d38dfab37ad7f025c95fbd8b209a2adcab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ot=C3=A1vio=20Stasiak?= <91474186+OtavioStasiak@users.noreply.github.com> Date: Tue, 17 Dec 2024 11:54:00 -0300 Subject: [PATCH 3/9] chore(a11y): ReportUserView (#6017) --- app/containers/TextInput/FormTextInput.tsx | 4 ++-- app/views/ReportUserView/index.tsx | 7 +++---- app/views/ReportUserView/styles.ts | 8 +++++--- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/app/containers/TextInput/FormTextInput.tsx b/app/containers/TextInput/FormTextInput.tsx index f1c9da893d..5c208d9659 100644 --- a/app/containers/TextInput/FormTextInput.tsx +++ b/app/containers/TextInput/FormTextInput.tsx @@ -35,7 +35,7 @@ const styles = StyleSheet.create({ paddingHorizontal: 16, paddingVertical: 10, borderWidth: 1, - borderRadius: 2 + borderRadius: 4 }, inputIconLeft: { paddingLeft: 45 @@ -117,7 +117,7 @@ export const FormTextInput = ({ (secureTextEntry || iconRight || showClearInput) && styles.inputIconRight, { backgroundColor: colors.surfaceRoom, - borderColor: colors.strokeLight, + borderColor: colors.strokeMedium, color: colors.fontTitlesLabels }, error?.error && { diff --git a/app/views/ReportUserView/index.tsx b/app/views/ReportUserView/index.tsx index 40214872a4..c79dd72f42 100644 --- a/app/views/ReportUserView/index.tsx +++ b/app/views/ReportUserView/index.tsx @@ -78,11 +78,11 @@ const ReportUserView = () => { return ( - - + + {