diff --git a/.maestro/helpers/login-with-deeplink.yaml b/.maestro/helpers/login-with-deeplink.yaml index 6c592e17a7d..114974a59e5 100644 --- a/.maestro/helpers/login-with-deeplink.yaml +++ b/.maestro/helpers/login-with-deeplink.yaml @@ -4,8 +4,12 @@ tags: - 'util' --- -- clearState - stopApp: chat.rocket.reactnative +- runFlow: + when: + true: CLEAR_STATE + commands: + - clearState: chat.rocket.reactnative - evalScript: ${output.login = output.utils.login(USERNAME, PASSWORD)} - runFlow: file: 'open-deeplink.yaml' diff --git a/.maestro/helpers/open-deeplink.yaml b/.maestro/helpers/open-deeplink.yaml index 432fc3ed2b3..2ca10361e47 100644 --- a/.maestro/helpers/open-deeplink.yaml +++ b/.maestro/helpers/open-deeplink.yaml @@ -19,3 +19,9 @@ tags: text: Open index: 1 optional: true +- runFlow: + when: + visible: '.*Would like to send you notifications.*' + platform: iOS + commands: + - tapOn: 'Allow' diff --git a/.maestro/scripts/data.js b/.maestro/scripts/data.js index 515a972a8cd..5e7d6c97edb 100644 --- a/.maestro/scripts/data.js +++ b/.maestro/scripts/data.js @@ -11,7 +11,8 @@ const data = { name: 'detox-public-protected', joinCode: '123' } - } + }, + e2eePassword: 'Password1@abcdefghijklmnopqrst' }; output.data = data; \ No newline at end of file diff --git a/.maestro/tests/assorted/e2e-encryption.yaml b/.maestro/tests/assorted/e2e-encryption.yaml deleted file mode 100644 index 1040b442757..00000000000 --- a/.maestro/tests/assorted/e2e-encryption.yaml +++ /dev/null @@ -1,408 +0,0 @@ -appId: chat.rocket.reactnative -name: E2E Encryption -onFlowStart: - - runFlow: '../../helpers/setup.yaml' -onFlowComplete: - - evalScript: ${output.utils.deleteCreatedUsers()} -tags: - - test-9 - ---- -- evalScript: ${output.room = 'encrypted' + output.random()} -- evalScript: ${output.userA = output.utils.createUser()} -- evalScript: ${output.userB = output.utils.createUser()} - -# login as User B and change E2E password -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userB.username} - PASSWORD: ${output.userB.password} -- runFlow: './utils/navigate-to-e2ee-security.yaml' -- runFlow: './utils/change-e2ee-key.yaml' - -# login as User A and change E2E password -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userA.username} - PASSWORD: ${output.userA.password} - CLEAR_STATE: true -- runFlow: './utils/navigate-to-e2ee-security.yaml' -- runFlow: './utils/change-e2ee-key.yaml' - -# Create room as UserA and send a message -# should create encrypted room -- launchApp -- extendedWaitUntil: - visible: - id: 'rooms-list-view-create-channel' - timeout: 60000 -- tapOn: - id: 'rooms-list-view-create-channel' -- extendedWaitUntil: - visible: - id: 'new-message-view' -- extendedWaitUntil: - visible: - id: 'new-message-view-create-channel' - timeout: 60000 -- tapOn: - id: 'new-message-view-create-channel' -- extendedWaitUntil: - visible: - id: 'select-users-view' - timeout: 60000 -- tapOn: - id: 'select-users-view-search' -- inputText: ${output.userB.username} -- extendedWaitUntil: - visible: - id: 'select-users-view-item-${output.userB.username}' - timeout: 60000 -- tapOn: - id: 'select-users-view-item-${output.userB.username}' -- extendedWaitUntil: - visible: - id: 'selected-user-${output.userB.username}' -- tapOn: - id: 'selected-users-view-submit' -- extendedWaitUntil: - visible: - id: 'create-channel-name' - timeout: 60000 -- tapOn: - id: 'create-channel-name' -- inputText: ${output.room} -- hideKeyboard -- extendedWaitUntil: - visible: - id: 'create-channel-encrypted' - timeout: 60000 -- tapOn: - id: 'create-channel-encrypted' -- tapOn: - id: 'create-channel-submit' -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room}' - timeout: 60000 - -# should send message and be able to read it -- extendedWaitUntil: - visible: - id: 'message-composer-input' - timeout: 60000 -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: 'm0' - -# should quote a message and be able to read both -- evalScript: ${output.mockedMessageTextToQuote = 'm1'} -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: ${output.mockedMessageTextToQuote} -- evalScript: ${output.quotedMessage = 'm2'} -- longPressOn: - text: .*${output.mockedMessageTextToQuote}.* -- extendedWaitUntil: - visible: - id: action-sheet - timeout: 60000 -- extendedWaitUntil: - visible: - id: action-sheet-handle - timeout: 60000 -- extendedWaitUntil: - visible: - text: Quote - timeout: 60000 -- tapOn: - text: Quote -- extendedWaitUntil: - visible: - id: message-composer-input - timeout: 60000 -- tapOn: - id: message-composer-input -- inputText: ${output.quotedMessage} -- extendedWaitUntil: - visible: - id: message-composer-send - timeout: 60000 -- tapOn: - id: message-composer-send -- extendedWaitUntil: - visible: - text: .*${output.quotedMessage}.* - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'reply-${output.userA.username}-${output.mockedMessageTextToQuote}' - timeout: 60000 - -# If session is not encrypted, it shouldnt trigger read messages -# should login as UserB, dont set e2ee password and dont read messages -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userB.username} - PASSWORD: ${output.userB.password} - CLEAR_STATE: true -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- extendedWaitUntil: - visible: - id: 'room-view-encrypted-room' - timeout: 60000 - -# should login as UserA and check message is not read -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userA.username} - PASSWORD: ${output.userA.password} - CLEAR_STATE: true -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- runFlow: './utils/enter-e2e-key.yaml' -- extendedWaitUntil: - visible: - id: 'read-receipt-unread' - timeout: 60000 - -# should login as UserB, set e2ee password and read messages -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userB.username} - PASSWORD: ${output.userB.password} - CLEAR_STATE: true -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- runFlow: './utils/enter-e2e-key.yaml' -- extendedWaitUntil: - visible: - text: '.*m0.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m1.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m2.*' - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'message-composer-input' - timeout: 60000 -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: 'm3' - -# should login as UserA and check message is read -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userA.username} - PASSWORD: ${output.userA.password} - CLEAR_STATE: true -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- runFlow: './utils/enter-e2e-key.yaml' -- extendedWaitUntil: - visible: - text: '.*m0.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m1.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m2.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m3.*' - timeout: 60000 - -# Login as UserA, reset user e2ee key, reset room E2EE key and send a message -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userA.username} - PASSWORD: ${output.userA.password} - CLEAR_STATE: true - -# should reset user E2EE key, login again and recreate keys -- runFlow: './utils/navigate-to-e2ee-security.yaml' -- runFlow: './utils/reset-e2ee-key.yaml' -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userA.username} - PASSWORD: ${output.userA.password} - CLEAR_STATE: true -- runFlow: './utils/navigate-to-e2ee-security.yaml' -- runFlow: './utils/change-e2ee-key.yaml' - -# should reset room E2EE key -- launchApp -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- extendedWaitUntil: - visible: - text: '.*Check back in a few moments.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*The encryption keys for the room need to be updated, another room member needs to be online for this to happen.*' - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'room-view-header-encryption' - timeout: 60000 -- tapOn: - id: 'room-view-header-encryption' -- extendedWaitUntil: - visible: - id: 'e2ee-toggle-room-view' - timeout: 60000 -- extendedWaitUntil: - visible: - id: 'e2ee-toggle-room-reset-key' - timeout: 60000 -- tapOn: - id: 'e2ee-toggle-room-reset-key' -- extendedWaitUntil: - visible: - text: '.*Reset encryption key.*' - timeout: 60000 -- tapOn: - text: 'Reset' - index: 0 -- runFlow: '../../helpers/go-back.yaml' -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- extendedWaitUntil: - visible: - id: 'room-view-title-${output.room}' - timeout: 60000 -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: 'm4' - -# Login as UserB, accept new room key, send a message and read everything -- runFlow: - file: '../../helpers/login-with-deeplink.yaml' - env: - USERNAME: ${output.userB.username} - PASSWORD: ${output.userB.password} - CLEAR_STATE: true - -# should send message and be able to read it -- runFlow: - file: '../../helpers/navigate-to-room.yaml' - env: - ROOM: ${output.room} -- runFlow: './utils/enter-e2e-key.yaml' -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: 'm5' -- extendedWaitUntil: - visible: - text: '.*m0.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m1.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m2.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m3.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m4.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m5.*' - timeout: 60000 - -# should send a message, edit it and be able to read it -- runFlow: - file: '../../helpers/send-message.yaml' - env: - message: 'm99' -- extendedWaitUntil: - visible: - text: '.*m99.*' - timeout: 60000 -- longPressOn: - text: '.*m99.*' -- extendedWaitUntil: - visible: - id: 'action-sheet' - timeout: 60000 -- extendedWaitUntil: - visible: - text: 'Edit' - timeout: 60000 -- tapOn: - text: 'Edit' -- eraseText -- inputText: 'm6' -- tapOn: - id: 'message-composer-send' -- extendedWaitUntil: - visible: - text: '.*m0.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m1.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m2.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m3.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m4.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m5.*' - timeout: 60000 -- extendedWaitUntil: - visible: - text: '.*m6.*' - timeout: 60000 diff --git a/.maestro/tests/assorted/utils/change-e2ee-key.yaml b/.maestro/tests/assorted/utils/change-e2ee-key.yaml deleted file mode 100644 index d11a6f1c21f..00000000000 --- a/.maestro/tests/assorted/utils/change-e2ee-key.yaml +++ /dev/null @@ -1,30 +0,0 @@ -appId: chat.rocket.reactnative -name: Change E2EE Key -tags: - - 'util' - ---- -- extendedWaitUntil: - visible: - id: e2e-encryption-security-view-password - timeout: 60000 -- tapOn: - id: e2e-encryption-security-view-password -- inputText: abc -- hideKeyboard -- extendedWaitUntil: - visible: - id: e2e-encryption-security-view-change-password - timeout: 60000 -- tapOn: - id: e2e-encryption-security-view-change-password -- extendedWaitUntil: - visible: - text: .*Are you sure.* - timeout: 60000 -- extendedWaitUntil: - visible: - text: .*Make sure you've saved it carefully somewhere else.* - timeout: 60000 -- tapOn: - text: Yes, change it diff --git a/.maestro/tests/e2ee/e2e-encryption.yaml b/.maestro/tests/e2ee/e2e-encryption.yaml new file mode 100644 index 00000000000..c33017c39c3 --- /dev/null +++ b/.maestro/tests/e2ee/e2e-encryption.yaml @@ -0,0 +1,104 @@ +appId: chat.rocket.reactnative +name: E2E Encryption +onFlowStart: + - runFlow: '../../helpers/setup.yaml' +onFlowComplete: + - evalScript: ${output.utils.deleteCreatedUsers()} +tags: + - test-9 +--- +- evalScript: ${output.room = 'encrypted' + output.random()} +- evalScript: ${output.userA = output.utils.createUser()} +- evalScript: ${output.userB = output.utils.createUser()} +- runFlow: + label: 'Setup UserB' + file: './flows/setup-user.yaml' + env: + USERNAME: ${output.userB.username} # UserB + PASSWORD: ${output.userB.password} +- runFlow: + label: 'Setup UserA' + file: './flows/setup-user.yaml' + env: + USERNAME: ${output.userA.username} # UserA + PASSWORD: ${output.userA.password} + +# should create encrypted room (UserA creates and adds UserB) +- runFlow: + label: 'Create E2EE Room' + file: './flows/create-e2ee-room.yaml' + env: + USER_TO_ADD: ${output.userB.username} + ROOM: ${output.room} + +# should send message and be able to read it +- runFlow: + label: 'Send and Verify Message' + file: './flows/send-and-verify-message.yaml' + +# should quote a message and be able to read both (UserA still logged in) +- runFlow: + label: 'Quote Message' + file: './flows/quote-message.yaml' + env: + MESSAGE_AUTHOR: ${output.userA.username} + +# If session is not encrypted, it shouldnt trigger read messages +# should login as UserB, dont set e2ee password and dont read messages +- runFlow: + label: 'Check Encrypted Room Without Key' + file: './flows/check-encrypted-room-without-key.yaml' + env: + USERNAME: ${output.userB.username} # UserB + PASSWORD: ${output.userB.password} + ROOM: ${output.room} + +# should login as UserA and check message is not read +- runFlow: + label: 'Verify Message Unread' + file: './flows/verify-message-unread.yaml' + env: + USERNAME: ${output.userA.username} # UserA + PASSWORD: ${output.userA.password} + ROOM: ${output.room} + +# should login as UserB, set e2ee password and read messages +- runFlow: + label: 'Enter Key, Read Messages and Send a New Message' + file: './flows/enter-key-read-and-send.yaml' + env: + USERNAME: ${output.userB.username} # UserB + PASSWORD: ${output.userB.password} + ROOM: ${output.room} + +# should login as UserA and check message is read +- runFlow: + label: 'Verify Messages Read' + file: './flows/verify-messages-read.yaml' + env: + USERNAME: ${output.userA.username} # UserA + PASSWORD: ${output.userA.password} + ROOM: ${output.room} + +# Login as UserA, reset user e2ee key, reset room E2EE key and send a message +- runFlow: + label: 'Reset Keys and Send Message' + file: './flows/reset-keys-and-send.yaml' + env: + USERNAME: ${output.userA.username} # UserA + PASSWORD: ${output.userA.password} + ROOM: ${output.room} + +# Login as UserB, accept new room key, send a message and read everything +- runFlow: + label: 'Accept New Key and Verify Messages' + file: './flows/accept-new-key-and-verify.yaml' + env: + USERNAME: ${output.userB.username} # UserB + PASSWORD: ${output.userB.password} + ROOM: ${output.room} + +# should send a message, edit it and be able to read it (UserB still logged in) +- runFlow: + label: 'Edit Message and Verify' + file: './flows/edit-message-and-verify.yaml' diff --git a/.maestro/tests/e2ee/flows/accept-new-key-and-verify.yaml b/.maestro/tests/e2ee/flows/accept-new-key-and-verify.yaml new file mode 100644 index 00000000000..83efffea638 --- /dev/null +++ b/.maestro/tests/e2ee/flows/accept-new-key-and-verify.yaml @@ -0,0 +1,54 @@ +appId: chat.rocket.reactnative +name: Accept New Key and Verify Messages +tags: + - 'flow' +env: + MESSAGE_1: m0 + MESSAGE_2: m1 + MESSAGE_3: m2 + MESSAGE_4: m3 + MESSAGE_5: m4 + NEW_MESSAGE: m5 +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- runFlow: '../utils/enter-e2e-key.yaml' + +# Send a message +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${NEW_MESSAGE} + +# Verify all messages are visible +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_1}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_2}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_3}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_4}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_5}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${NEW_MESSAGE}.*' + timeout: 60000 diff --git a/.maestro/tests/e2ee/flows/check-encrypted-room-without-key.yaml b/.maestro/tests/e2ee/flows/check-encrypted-room-without-key.yaml new file mode 100644 index 00000000000..b84d816072b --- /dev/null +++ b/.maestro/tests/e2ee/flows/check-encrypted-room-without-key.yaml @@ -0,0 +1,18 @@ +appId: chat.rocket.reactnative +name: Check Encrypted Room Without Key +tags: + - 'flow' +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- extendedWaitUntil: + visible: + id: 'room-view-encrypted-room' + timeout: 60000 diff --git a/.maestro/tests/e2ee/flows/create-e2ee-room.yaml b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml new file mode 100644 index 00000000000..07dbebd8c8b --- /dev/null +++ b/.maestro/tests/e2ee/flows/create-e2ee-room.yaml @@ -0,0 +1,59 @@ +appId: chat.rocket.reactnative +name: Create E2EE Room +tags: + - 'flow' +--- +- launchApp +- extendedWaitUntil: + visible: + id: 'rooms-list-view-create-channel' + timeout: 60000 +- tapOn: + id: 'rooms-list-view-create-channel' +- extendedWaitUntil: + visible: + id: 'new-message-view' +- extendedWaitUntil: + visible: + id: 'new-message-view-create-channel' + timeout: 60000 +- tapOn: + id: 'new-message-view-create-channel' +- extendedWaitUntil: + visible: + id: 'select-users-view' + timeout: 60000 +- tapOn: + id: 'select-users-view-search' +- inputText: ${USER_TO_ADD} +- extendedWaitUntil: + visible: + id: 'select-users-view-item-${USER_TO_ADD}' + timeout: 60000 +- tapOn: + id: 'select-users-view-item-${USER_TO_ADD}' +- extendedWaitUntil: + visible: + id: 'selected-user-${USER_TO_ADD}' +- tapOn: + id: 'selected-users-view-submit' +- extendedWaitUntil: + visible: + id: 'create-channel-name' + timeout: 60000 +- tapOn: + id: 'create-channel-name' +- inputText: ${ROOM} +- hideKeyboard +- extendedWaitUntil: + visible: + id: 'create-channel-encrypted' + timeout: 60000 +- tapOn: + id: 'create-channel-encrypted' +- tapOn: + id: 'create-channel-submit' +- extendedWaitUntil: + visible: + id: 'room-view-title-${ROOM}' + timeout: 60000 diff --git a/.maestro/tests/e2ee/flows/edit-message-and-verify.yaml b/.maestro/tests/e2ee/flows/edit-message-and-verify.yaml new file mode 100644 index 00000000000..158da7a36e4 --- /dev/null +++ b/.maestro/tests/e2ee/flows/edit-message-and-verify.yaml @@ -0,0 +1,66 @@ +appId: chat.rocket.reactnative +name: Edit Message and Verify +tags: + - 'flow' +env: + ORIGINAL_MESSAGE: m99 + EDITED_MESSAGE: m6 + MESSAGE_1: m0 + MESSAGE_2: m1 + MESSAGE_3: m2 + MESSAGE_4: m3 + MESSAGE_5: m4 +--- +# Send a message +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${ORIGINAL_MESSAGE} +- extendedWaitUntil: + visible: + text: '.*${ORIGINAL_MESSAGE}.*' + timeout: 60000 + +# Edit the message +- longPressOn: + text: '.*${ORIGINAL_MESSAGE}.*' +- extendedWaitUntil: + visible: + id: 'action-sheet' + timeout: 60000 +- extendedWaitUntil: + visible: + text: 'Edit' + timeout: 60000 +- tapOn: + text: 'Edit' +- eraseText +- inputText: ${EDITED_MESSAGE} +- tapOn: + id: 'message-composer-send' + +# Verify all messages are visible including edited one +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_1}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_2}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_3}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_4}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_5}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${EDITED_MESSAGE}.*' + timeout: 60000 diff --git a/.maestro/tests/e2ee/flows/enter-key-read-and-send.yaml b/.maestro/tests/e2ee/flows/enter-key-read-and-send.yaml new file mode 100644 index 00000000000..fc964dee4ba --- /dev/null +++ b/.maestro/tests/e2ee/flows/enter-key-read-and-send.yaml @@ -0,0 +1,44 @@ +appId: chat.rocket.reactnative +name: Enter Key, Read Messages and Send +tags: + - 'flow' +env: + MESSAGE_1: m0 + MESSAGE_2: m1 + MESSAGE_3: m2 + NEW_MESSAGE: m3 +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- runFlow: '../utils/enter-e2e-key.yaml' + +# Verify messages are visible +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_1}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_2}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_3}.*' + timeout: 60000 + +# Send a new message +- extendedWaitUntil: + visible: + id: 'message-composer-input' + timeout: 60000 +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${NEW_MESSAGE} diff --git a/.maestro/tests/e2ee/flows/quote-message.yaml b/.maestro/tests/e2ee/flows/quote-message.yaml new file mode 100644 index 00000000000..a5529bef310 --- /dev/null +++ b/.maestro/tests/e2ee/flows/quote-message.yaml @@ -0,0 +1,54 @@ +appId: chat.rocket.reactnative +name: Quote Message +tags: + - 'flow' +env: + MESSAGE_TO_QUOTE: m1 + QUOTED_MESSAGE: m2 +--- +# Send the message to be quoted +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${MESSAGE_TO_QUOTE} + +# Long press and quote the message +- longPressOn: + text: .*${MESSAGE_TO_QUOTE}.* +- extendedWaitUntil: + visible: + id: action-sheet + timeout: 60000 +- extendedWaitUntil: + visible: + id: action-sheet-handle + timeout: 60000 +- extendedWaitUntil: + visible: + text: Quote + timeout: 60000 +- tapOn: + text: Quote + +# Input the quoted message +- extendedWaitUntil: + visible: + id: message-composer-input + timeout: 60000 +- tapOn: + id: message-composer-input +- inputText: ${QUOTED_MESSAGE} +- extendedWaitUntil: + visible: + id: message-composer-send + timeout: 60000 +- tapOn: + id: message-composer-send + +# Verify both messages are visible +- extendedWaitUntil: + visible: + text: .*${QUOTED_MESSAGE}.* + timeout: 60000 +- assertVisible: + id: 'reply-${MESSAGE_AUTHOR}-${MESSAGE_TO_QUOTE}' diff --git a/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml b/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml new file mode 100644 index 00000000000..177fd27a304 --- /dev/null +++ b/.maestro/tests/e2ee/flows/reset-keys-and-send.yaml @@ -0,0 +1,79 @@ +appId: chat.rocket.reactnative +name: Reset Keys and Send Message +tags: + - 'flow' +env: + MESSAGE: m4 +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} + +# Reset user E2EE key +- runFlow: '../utils/navigate-to-e2ee-security.yaml' +- runFlow: '../utils/reset-e2ee-key.yaml' + +# Login again and recreate keys +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../utils/navigate-to-e2ee-security.yaml' +- runFlow: '../utils/change-e2ee-key.yaml' + +# Reset room E2EE key +- launchApp +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- extendedWaitUntil: + visible: + text: '.*Check back in a few moments.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*The encryption keys for the room need to be updated, another room member needs to be online for this to happen.*' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'room-view-header-encryption' + timeout: 60000 +- tapOn: + id: 'room-view-header-encryption' +- extendedWaitUntil: + visible: + id: 'e2ee-toggle-room-view' + timeout: 60000 +- extendedWaitUntil: + visible: + id: 'e2ee-toggle-room-reset-key' + timeout: 60000 +- tapOn: + id: 'e2ee-toggle-room-reset-key' +- extendedWaitUntil: + visible: + text: '.*Reset encryption key.*' + timeout: 60000 +- tapOn: + text: 'Reset' + index: 0 +- runFlow: '../../../helpers/go-back.yaml' + +# Navigate to room and send message +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- extendedWaitUntil: + visible: + id: 'room-view-title-${ROOM}' + timeout: 60000 +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${MESSAGE} diff --git a/.maestro/tests/e2ee/flows/send-and-verify-message.yaml b/.maestro/tests/e2ee/flows/send-and-verify-message.yaml new file mode 100644 index 00000000000..4abba6a4124 --- /dev/null +++ b/.maestro/tests/e2ee/flows/send-and-verify-message.yaml @@ -0,0 +1,15 @@ +appId: chat.rocket.reactnative +name: Send and Verify Message +tags: + - 'flow' +env: + MESSAGE: m0 +--- +- extendedWaitUntil: + visible: + id: 'message-composer-input' + timeout: 60000 +- runFlow: + file: '../../../helpers/send-message.yaml' + env: + message: ${MESSAGE} diff --git a/.maestro/tests/e2ee/flows/setup-user.yaml b/.maestro/tests/e2ee/flows/setup-user.yaml new file mode 100644 index 00000000000..c69d9630a87 --- /dev/null +++ b/.maestro/tests/e2ee/flows/setup-user.yaml @@ -0,0 +1,13 @@ +appId: chat.rocket.reactnative +name: Setup User +tags: + - 'flow' +--- +- runFlow: ../../../helpers/launch-app.yaml +- runFlow: + file: '../../../helpers/login.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: '../utils/navigate-to-e2ee-security.yaml' +- runFlow: '../utils/change-e2ee-key.yaml' diff --git a/.maestro/tests/e2ee/flows/verify-message-unread.yaml b/.maestro/tests/e2ee/flows/verify-message-unread.yaml new file mode 100644 index 00000000000..67cd44695fd --- /dev/null +++ b/.maestro/tests/e2ee/flows/verify-message-unread.yaml @@ -0,0 +1,19 @@ +appId: chat.rocket.reactnative +name: Verify Message Unread +tags: + - 'flow' +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- runFlow: '../utils/enter-e2e-key.yaml' +- extendedWaitUntil: + visible: + id: 'read-receipt-unread' + timeout: 60000 diff --git a/.maestro/tests/e2ee/flows/verify-messages-read.yaml b/.maestro/tests/e2ee/flows/verify-messages-read.yaml new file mode 100644 index 00000000000..36704d60962 --- /dev/null +++ b/.maestro/tests/e2ee/flows/verify-messages-read.yaml @@ -0,0 +1,38 @@ +appId: chat.rocket.reactnative +name: Verify Messages Read +tags: + - 'flow' +env: + MESSAGE_1: m0 + MESSAGE_2: m1 + MESSAGE_3: m2 + MESSAGE_4: m3 +--- +- runFlow: + file: '../../../helpers/login-with-deeplink.yaml' + env: + USERNAME: ${USERNAME} + PASSWORD: ${PASSWORD} +- runFlow: + file: '../../../helpers/navigate-to-room.yaml' + env: + ROOM: ${ROOM} +- runFlow: '../utils/enter-e2e-key.yaml' + +# Verify all messages are visible +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_1}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_2}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_3}.*' + timeout: 60000 +- extendedWaitUntil: + visible: + text: '.*${MESSAGE_4}.*' + timeout: 60000 diff --git a/.maestro/tests/e2ee/utils/change-e2ee-key.yaml b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml new file mode 100644 index 00000000000..125cc968a6e --- /dev/null +++ b/.maestro/tests/e2ee/utils/change-e2ee-key.yaml @@ -0,0 +1,23 @@ +appId: chat.rocket.reactnative +name: Change E2EE Key +tags: + - 'util' + +--- +- tapOn: Enter manually +- tapOn: New password +- inputText: ${output.data.e2eePassword} +- hideKeyboard +- swipe: + direction: DOWN + duration: 100 +- tapOn: Save Changes +- extendedWaitUntil: + visible: + text: .*Are you sure.* + timeout: 60000 +- extendedWaitUntil: + visible: + text: .*Make sure you've saved it carefully somewhere else.* + timeout: 60000 +- tapOn: Yes, change it diff --git a/.maestro/tests/assorted/utils/enter-e2e-key.yaml b/.maestro/tests/e2ee/utils/enter-e2e-key.yaml similarity index 93% rename from .maestro/tests/assorted/utils/enter-e2e-key.yaml rename to .maestro/tests/e2ee/utils/enter-e2e-key.yaml index 13b3c2c1f2c..baff994416e 100644 --- a/.maestro/tests/assorted/utils/enter-e2e-key.yaml +++ b/.maestro/tests/e2ee/utils/enter-e2e-key.yaml @@ -18,7 +18,7 @@ tags: timeout: 60000 - tapOn: id: 'e2e-enter-your-password-view-password' -- inputText: 'abc' +- inputText: ${output.data.e2eePassword} - tapOn: text: 'Confirm' - extendedWaitUntil: diff --git a/.maestro/tests/assorted/utils/navigate-to-e2ee-security.yaml b/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml similarity index 53% rename from .maestro/tests/assorted/utils/navigate-to-e2ee-security.yaml rename to .maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml index e463c97700e..33a13e9b05a 100644 --- a/.maestro/tests/assorted/utils/navigate-to-e2ee-security.yaml +++ b/.maestro/tests/e2ee/utils/navigate-to-e2ee-security.yaml @@ -8,6 +8,21 @@ tags: visible: id: rooms-list-view timeout: 60000 +- runFlow: + when: + visible: 'Save your encryption password' + commands: + - assertVisible: Save your encryption password +- runFlow: + when: + visible: 'Enter E2EE password' + commands: + - assertVisible: Enter E2EE password +- extendedWaitUntil: + visible: + id: rooms-list-view-sidebar + enabled: true + timeout: 60000 - tapOn: id: rooms-list-view-sidebar - extendedWaitUntil: @@ -22,27 +37,16 @@ tags: id: sidebar-settings - waitForAnimationToEnd: timeout: 5000 -- extendedWaitUntil: - visible: - id: settings-view - timeout: 60000 -- extendedWaitUntil: - visible: - text: Security and privacy - timeout: 60000 -- tapOn: - text: Security and privacy -- extendedWaitUntil: - visible: - id: security-privacy-view - timeout: 60000 -- assertVisible: - text: End-to-end encryption -- tapOn: - text: End-to-end encryption +- repeat: + while: + notVisible: + id: security-privacy-view + commands: + - tapOn: Security and privacy + +- assertVisible: End-to-end encryption +- tapOn: End-to-end encryption - extendedWaitUntil: visible: id: e2e-encryption-security-view timeout: 60000 -- assertVisible: - id: e2e-encryption-security-view-reset-key diff --git a/.maestro/tests/assorted/utils/reset-e2ee-key.yaml b/.maestro/tests/e2ee/utils/reset-e2ee-key.yaml similarity index 81% rename from .maestro/tests/assorted/utils/reset-e2ee-key.yaml rename to .maestro/tests/e2ee/utils/reset-e2ee-key.yaml index e2128551e10..8c9cd91df71 100644 --- a/.maestro/tests/assorted/utils/reset-e2ee-key.yaml +++ b/.maestro/tests/e2ee/utils/reset-e2ee-key.yaml @@ -4,6 +4,11 @@ tags: - 'util' --- +- scrollUntilVisible: + element: + id: e2e-encryption-security-view-reset-key + direction: DOWN + timeout: 30000 - extendedWaitUntil: visible: id: e2e-encryption-security-view-reset-key diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 53c06df7f33..0d07364246b 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -54,12 +54,11 @@ open class MainApplication : Application(), ReactApplication, INotificationsAppl SoLoader.init(this, OpenSourceMergedSoMapping) Bugsnag.start(this) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() - } + // Load the native entry point for the New Architecture + load() - reactNativeHost.reactInstanceManager.addReactInstanceEventListener(object : ReactInstanceEventListener { + // Register listener to set React context when initialized + reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { override fun onReactContextInitialized(context: ReactContext) { CustomPushNotification.setReactContext(context as ReactApplicationContext) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java index 2a1e13273bc..d17f1b234db 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java @@ -16,6 +16,7 @@ import android.graphics.drawable.Icon; import android.os.Build; import android.os.Bundle; +import android.util.Log; import androidx.annotation.Nullable; @@ -39,36 +40,54 @@ import chat.rocket.reactnative.R; +/** + * Custom push notification handler for Rocket.Chat. + * + * Handles standard push notifications and End-to-End encrypted (E2E) notifications. + * For E2E notifications, waits for React Native initialization before decrypting and displaying. + */ public class CustomPushNotification extends PushNotification { + private static final String TAG = "RocketChat.Push"; + + // Shared state public static ReactApplicationContext reactApplicationContext; + private static final Gson gson = new Gson(); + private static final Map> notificationMessages = new HashMap<>(); + + // Constants + public static final String KEY_REPLY = "KEY_REPLY"; + public static final String NOTIFICATION_ID = "NOTIFICATION_ID"; + + // Instance fields final NotificationManager notificationManager; - // Create a single Gson instance - private static final Gson gson = new Gson(); - - public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) { + public CustomPushNotification(Context context, Bundle bundle, AppLifecycleFacade appLifecycleFacade, + AppLaunchHelper appLaunchHelper, JsIOHelper jsIoHelper) { super(context, bundle, appLifecycleFacade, appLaunchHelper, jsIoHelper); notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); } + /** + * Sets the React application context when React Native initializes. + * Called from MainApplication when React context is ready. + */ public static void setReactContext(ReactApplicationContext context) { reactApplicationContext = context; } - private static Map> notificationMessages = new HashMap>(); - public static String KEY_REPLY = "KEY_REPLY"; - public static String NOTIFICATION_ID = "NOTIFICATION_ID"; - public static void clearMessages(int notId) { notificationMessages.remove(Integer.toString(notId)); } @Override public void onReceived() throws InvalidNotificationException { + + // Load notification data from server if needed Bundle received = mNotificationProps.asBundle(); Ejson receivedEjson = safeFromJson(received.getString("ejson", "{}"), Ejson.class); - if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) { + if (receivedEjson != null && receivedEjson.notificationType != null && + receivedEjson.notificationType.equals("message-id-only")) { notificationLoad(receivedEjson, new Callback() { @Override public void call(@Nullable Bundle bundle) { @@ -79,35 +98,107 @@ public void call(@Nullable Bundle bundle) { }); } - // We should re-read these values since that can be changed by notificationLoad + // Re-read values (may have changed from notificationLoad) Bundle bundle = mNotificationProps.asBundle(); Ejson loadedEjson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); String notId = bundle.getString("notId", "1"); - if (notificationMessages.get(notId) == null) { - notificationMessages.put(notId, new ArrayList()); + // Handle E2E encrypted notifications + if (isE2ENotification(loadedEjson)) { + handleE2ENotification(bundle, loadedEjson, notId); + return; // E2E processor will handle showing the notification } - boolean hasSender = loadedEjson != null && loadedEjson.sender != null; - String title = bundle.getString("title"); + // Handle regular (non-E2E) notifications + showNotification(bundle, loadedEjson, notId); + } - // If it has a encrypted message - if (loadedEjson != null && loadedEjson.msg != null) { - // Override message with the decrypted content - String decrypted = Encryption.shared.decryptMessage(loadedEjson, reactApplicationContext); + /** + * Checks if this is an E2E encrypted notification. + */ + private boolean isE2ENotification(Ejson ejson) { + return ejson != null && "e2e".equals(ejson.messageType); + } + + /** + * Handles E2E encrypted notifications by delegating to the async processor. + */ + private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) { + // Check if React context is immediately available + if (reactApplicationContext != null) { + // Fast path: decrypt immediately + String decrypted = Encryption.shared.decryptMessage(ejson, reactApplicationContext); + if (decrypted != null) { bundle.putString("message", decrypted); + mNotificationProps = createProps(bundle); + bundle = mNotificationProps.asBundle(); + ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); + showNotification(bundle, ejson, notId); + } else { + Log.w(TAG, "E2E decryption failed for notification"); } + return; + } + + // Slow path: wait for React context asynchronously + Log.i(TAG, "Waiting for React context to decrypt E2E notification"); + + E2ENotificationProcessor processor = new E2ENotificationProcessor( + // Context provider + () -> reactApplicationContext, + + // Callback + new E2ENotificationProcessor.NotificationCallback() { + @Override + public void onDecryptionComplete(Bundle decryptedBundle, Ejson decryptedEjson, String notificationId) { + // Update props and show notification + mNotificationProps = createProps(decryptedBundle); + Bundle finalBundle = mNotificationProps.asBundle(); + Ejson finalEjson = safeFromJson(finalBundle.getString("ejson", "{}"), Ejson.class); + showNotification(finalBundle, finalEjson, notificationId); + } + + @Override + public void onDecryptionFailed(Bundle originalBundle, Ejson originalEjson, String notificationId) { + Log.w(TAG, "E2E decryption failed for notification"); + } + + @Override + public void onTimeout(Bundle originalBundle, Ejson originalEjson, String notificationId) { + Log.w(TAG, "Timeout waiting for React context for E2E notification"); + } + } + ); + + processor.processAsync(bundle, ejson, notId); + } + + /** + * Shows the notification to the user. + * Centralizes the notification display logic. + */ + private void showNotification(Bundle bundle, Ejson ejson, String notId) { + // Initialize notification message list for this ID + if (notificationMessages.get(notId) == null) { + notificationMessages.put(notId, new ArrayList<>()); } + // Prepare notification data + boolean hasSender = ejson != null && ejson.sender != null; + String title = bundle.getString("title"); + bundle.putLong("time", new Date().getTime()); - bundle.putString("username", hasSender ? loadedEjson.sender.username : title); - bundle.putString("senderId", hasSender ? loadedEjson.sender._id : "1"); - bundle.putString("avatarUri", loadedEjson != null ? loadedEjson.getAvatarUri() : null); + bundle.putString("username", hasSender ? ejson.sender.username : title); + bundle.putString("senderId", hasSender ? ejson.sender._id : "1"); + bundle.putString("avatarUri", ejson != null ? ejson.getAvatarUri() : null); - if (loadedEjson != null && loadedEjson.notificationType instanceof String && loadedEjson.notificationType.equals("videoconf")) { + // Handle special notification types + if (ejson != null && ejson.notificationType instanceof String && + ejson.notificationType.equals("videoconf")) { notifyReceivedToJS(); } else { + // Show regular notification notificationMessages.get(notId).add(bundle); postNotification(Integer.parseInt(notId)); notifyReceivedToJS(); @@ -154,7 +245,6 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { // message couldn't be loaded from server (Fallback notification) } else { - Gson gson = new Gson(); // iterate over the current notification ids to dismiss fallback notifications from same server for (Map.Entry> bundleList : notificationMessages.entrySet()) { // iterate over the notifications with this id (same host + rid) @@ -276,8 +366,6 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun } else { Notification.MessagingStyle messageStyle; - Gson gson = new Gson(); - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.P) { messageStyle = new Notification.MessagingStyle(""); } else { @@ -381,7 +469,7 @@ private void notificationLoad(Ejson ejson, Callback callback) { } /** - * Safely parse JSON string to object with error handling. + * Safely parses JSON string to object with error handling. * * @param json JSON string to parse * @param classOfT Target class type @@ -390,17 +478,13 @@ private void notificationLoad(Ejson ejson, Callback callback) { */ private static T safeFromJson(String json, Class classOfT) { if (json == null || json.trim().isEmpty() || json.equals("{}")) { - return null; // no need to create a new instance + return null; } try { return gson.fromJson(json, classOfT); } catch (Exception e) { - android.util.Log.e( - "CustomPushNotification", - "Failed to parse JSON into " + classOfT.getSimpleName() + " (payload redacted).", - e - ); + Log.e(TAG, "Failed to parse JSON into " + classOfT.getSimpleName(), e); return null; } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java b/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java new file mode 100644 index 00000000000..532464bf257 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java @@ -0,0 +1,160 @@ +package chat.rocket.reactnative.notification; + +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import com.facebook.react.bridge.ReactApplicationContext; + +import java.util.Date; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * Handles asynchronous processing of End-to-End encrypted push notifications. + * + * When an E2E notification arrives before React Native is initialized, this processor + * waits for the React context to become available, decrypts the message, and then + * triggers the notification display. + * + * Thread-safe and handles timeout scenarios gracefully. + */ +public class E2ENotificationProcessor { + private static final String TAG = "RocketChat.E2E.Async"; + + // Configuration constants + private static final int POLLING_INTERVAL_MS = 100; // Check every 100ms + private static final int MAX_WAIT_TIME_MS = 3000; // Wait up to 3 seconds + private static final int MAX_ATTEMPTS = MAX_WAIT_TIME_MS / POLLING_INTERVAL_MS; + + private final Handler mainHandler; + private final ReactContextProvider contextProvider; + private final NotificationCallback callback; + + /** + * Interface to provide React context. + */ + public interface ReactContextProvider { + ReactApplicationContext getReactContext(); + } + + /** + * Callback interface for notification processing results. + */ + public interface NotificationCallback { + void onDecryptionComplete(Bundle decryptedBundle, Ejson ejson, String notId); + void onDecryptionFailed(Bundle originalBundle, Ejson ejson, String notId); + void onTimeout(Bundle originalBundle, Ejson ejson, String notId); + } + + /** + * Creates a new E2E notification processor. + * + * @param contextProvider Provider for React context + * @param callback Callback for processing results + */ + public E2ENotificationProcessor(ReactContextProvider contextProvider, NotificationCallback callback) { + this.mainHandler = new Handler(Looper.getMainLooper()); + this.contextProvider = contextProvider; + this.callback = callback; + } + + /** + * Processes an E2E encrypted notification asynchronously. + * + * This method returns immediately. The notification will be decrypted and shown + * once React context becomes available, or after a timeout. + * + * @param bundle The notification bundle + * @param ejson The parsed notification data + * @param notId The notification ID + */ + public void processAsync(final Bundle bundle, final Ejson ejson, final String notId) { + final AtomicInteger attempts = new AtomicInteger(0); + + final Runnable pollForContextRunnable = new Runnable() { + @Override + public void run() { + int currentAttempt = attempts.incrementAndGet(); + ReactApplicationContext reactContext = contextProvider.getReactContext(); + + if (reactContext != null) { + // Context is available - decrypt in background thread + Log.i(TAG, "React context available after " + currentAttempt + " attempts"); + decryptAndNotify(reactContext, bundle, ejson, notId); + + } else if (currentAttempt < MAX_ATTEMPTS) { + // Context not ready - poll again + mainHandler.postDelayed(this, POLLING_INTERVAL_MS); + + } else { + // Timeout - give up + Log.w(TAG, "Timeout waiting for React context after " + MAX_WAIT_TIME_MS + "ms"); + handleTimeout(bundle, ejson, notId); + } + } + }; + + // Start polling + mainHandler.post(pollForContextRunnable); + } + + /** + * Decrypts the message in a background thread and invokes the callback on the main thread. + */ + private void decryptAndNotify(final ReactApplicationContext reactContext, + final Bundle bundle, + final Ejson ejson, + final String notId) { + // Decrypt in background thread to avoid blocking + new Thread(() -> { + try { + String decrypted = Encryption.shared.decryptMessage(ejson, reactContext); + + if (decrypted != null) { + bundle.putString("message", decrypted); + + // Call directly on background thread - notification building needs background thread for image loading + try { + callback.onDecryptionComplete(bundle, ejson, notId); + } catch (Exception e) { + Log.e(TAG, "Error in decryption callback", e); + } + + } else { + Log.w(TAG, "Decryption returned null - failed to decrypt"); + handleDecryptionFailure(bundle, ejson, notId); + } + + } catch (Exception e) { + Log.e(TAG, "Exception during decryption", e); + handleDecryptionFailure(bundle, ejson, notId); + } + }, "E2E-Decrypt-" + notId).start(); + } + + /** + * Handles decryption failure by invoking the callback on the current thread. + */ + private void handleDecryptionFailure(final Bundle bundle, final Ejson ejson, final String notId) { + try { + callback.onDecryptionFailed(bundle, ejson, notId); + } catch (Exception e) { + Log.e(TAG, "Error in failure callback", e); + } + } + + /** + * Handles timeout by invoking the callback on the main thread. + */ + private void handleTimeout(final Bundle bundle, final Ejson ejson, final String notId) { + mainHandler.post(() -> { + try { + callback.onTimeout(bundle, ejson, notId); + } catch (Exception e) { + Log.e(TAG, "Error in timeout callback", e); + } + }); + } +} + diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 2d210738858..e926e9271f1 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -29,12 +29,15 @@ static public String toHex(String arg) { } public class Ejson { + private static final String TAG = "RocketChat.Ejson"; + String host; String rid; String type; Sender sender; String messageId; String notificationType; + String messageType; String senderName; String msg; @@ -45,35 +48,59 @@ public class Ejson { private ReactApplicationContext reactContext; private MMKV mmkv; + + private boolean initializationAttempted = false; private String TOKEN_KEY = "reactnativemeteor_usertoken-"; public Ejson() { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); + // Don't initialize MMKV in constructor - use lazy initialization instead + } + + /** + * Lazily initialize MMKV when first needed. + * + * NOTE: MMKV requires ReactApplicationContext (not regular Context) because SecureKeystore + * needs access to React-specific keystore resources. This means MMKV cannot be initialized + * before React Native starts. + */ + private void ensureMMKVInitialized() { + if (initializationAttempted) { + return; } - - // Only initialize MMKV if we have a valid React context - if (this.reactContext != null) { + + initializationAttempted = true; + + // Try to get ReactApplicationContext from available sources + if (this.reactContext == null) { + AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); + if (facade != null) { + Object runningContext = facade.getRunningReactContext(); + if (runningContext instanceof ReactApplicationContext) { + this.reactContext = (ReactApplicationContext) runningContext; + } + } + + if (this.reactContext == null) { + this.reactContext = CustomPushNotification.reactApplicationContext; + } + } + + // Initialize MMKV if context is available + if (this.reactContext != null && mmkv == null) { try { - // Start MMKV container MMKV.initialize(this.reactContext); SecureKeystore secureKeystore = new SecureKeystore(this.reactContext); - - // https://github.com/ammarahm-ed/react-native-mmkv-storage/blob/master/src/loader.js#L31 + // Alias format from react-native-mmkv-storage String alias = Utils.toHex("com.MMKV.default"); - - // Retrieve container password String password = secureKeystore.getSecureKey(alias); mmkv = MMKV.mmkvWithID("default", MMKV.SINGLE_PROCESS_MODE, password); } catch (Exception e) { - Log.e("Ejson", "Failed to initialize MMKV: " + e.getMessage()); + Log.e(TAG, "Failed to initialize MMKV", e); mmkv = null; } - } else { - Log.w("Ejson", "React context is null, MMKV will not be initialized"); - mmkv = null; + } else if (this.reactContext == null) { + Log.w(TAG, "Cannot initialize MMKV: ReactApplicationContext not available"); } } @@ -85,6 +112,7 @@ public String getAvatarUri() { } public String token() { + ensureMMKVInitialized(); String userId = userId(); if (mmkv != null && userId != null) { return mmkv.decodeString(TOKEN_KEY.concat(userId)); @@ -93,6 +121,7 @@ public String token() { } public String userId() { + ensureMMKVInitialized(); String serverURL = serverURL(); if (mmkv != null && serverURL != null) { return mmkv.decodeString(TOKEN_KEY.concat(serverURL)); @@ -101,6 +130,7 @@ public String userId() { } public String privateKey() { + ensureMMKVInitialized(); String serverURL = serverURL(); if (mmkv != null && serverURL != null) { return mmkv.decodeString(serverURL.concat("-RC_E2E_PRIVATE_KEY")); @@ -116,13 +146,16 @@ public String serverURL() { return url; } - public static class Sender { - String username; + static class Sender { String _id; + String username; + String name; } - public static class Content { - String ciphertext; + static class Content { String algorithm; + String ciphertext; + String kid; + String iv; } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java index c0bf8598561..b9fffc4bba7 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java @@ -1,5 +1,6 @@ package chat.rocket.reactnative.notification; +import android.content.Context; import android.database.Cursor; import android.util.Base64; import android.util.Log; @@ -10,6 +11,7 @@ import com.wix.reactnativenotifications.core.AppLifecycleFacade; import com.wix.reactnativenotifications.core.AppLifecycleFacadeHolder; import com.google.gson.Gson; +import com.google.gson.JsonObject; import chat.rocket.mobilecrypto.algorithms.AESCrypto; import chat.rocket.mobilecrypto.algorithms.RSACrypto; import chat.rocket.mobilecrypto.algorithms.CryptoUtils; @@ -20,22 +22,27 @@ import java.util.Arrays; class Message { + String msg; + + Message(String msg) { + this.msg = msg; + } +} + +class FallbackMessage { String _id; String userId; String text; - - Message(String id, String userId, String text) { - this._id = id; - this.userId = userId; - this.text = text; - } + long ts; } class DecryptedContent { String msg; + String text; - DecryptedContent(String msg) { + DecryptedContent(String msg, String text) { this.msg = msg; + this.text = text; } } @@ -64,19 +71,117 @@ class Room { } } +class PrefixedData { + String prefix; + byte[] data; + + PrefixedData(String prefix, byte[] data) { + this.prefix = prefix; + this.data = data; + } +} + +class ParsedMessage { + String keyId; + byte[] iv; + String ciphertext; + String algorithm; + + ParsedMessage(String keyId, byte[] iv, String ciphertext, String algorithm) { + this.keyId = keyId; + this.iv = iv; + this.ciphertext = ciphertext; + this.algorithm = algorithm; + } +} + +class RoomKeyResult { + String decryptedKey; + String algorithm; + + RoomKeyResult(String decryptedKey, String algorithm) { + this.decryptedKey = decryptedKey; + this.algorithm = algorithm; + } +} + class Encryption { + static class EncryptionContent { + String algorithm; + String ciphertext; + String kid; + String iv; + + EncryptionContent(String algorithm, String ciphertext, String kid, String iv) { + this.algorithm = algorithm; + this.ciphertext = ciphertext; + this.kid = kid; + this.iv = iv; + } + + EncryptionContent(String algorithm, String ciphertext) { + this.algorithm = algorithm; + this.ciphertext = ciphertext; + this.kid = null; + this.iv = null; + } + } + private Gson gson = new Gson(); private String keyId; + private String algorithm; + private static final String TAG = "RocketChat.E2E"; + public static Encryption shared = new Encryption(); private ReactApplicationContext reactContext; - public Room readRoom(final Ejson ejson) { - String dbName = getDatabaseName(ejson.serverURL()); + private PrefixedData decodePrefixedBase64(String input) { + // A 256-byte array always encodes to 344 characters in Base64. + int ENCODED_LENGTH = 344; + + if (input.length() < ENCODED_LENGTH) { + throw new IllegalArgumentException("Invalid input length."); + } + + String prefix = input.substring(0, input.length() - ENCODED_LENGTH); + String base64Data = input.substring(input.length() - ENCODED_LENGTH); + byte[] data = Base64.decode(base64Data, Base64.NO_WRAP); + + if (data.length != 256) { + throw new IllegalArgumentException("Invalid decoded length."); + } + + return new PrefixedData(prefix, data); + } + + private ParsedMessage parseMessage(Ejson.Content content) { + if ("rc.v2.aes-sha2".equals(content.algorithm)) { + // V2 format: Extract kid, iv, ciphertext from content + byte[] iv = Base64.decode(content.iv, Base64.NO_WRAP); + return new ParsedMessage(content.kid, iv, content.ciphertext, "rc.v2.aes-sha2"); + } else { + // V1 format: keyID + base64(iv + ciphertext) embedded in ciphertext + String ciphertext = content.ciphertext; + String keyId = ciphertext.substring(0, 12); + String contentBase64 = ciphertext.substring(12); + byte[] contentBuffer = Base64.decode(contentBase64, Base64.NO_WRAP); + + // Split IV (first 16 bytes) and ciphertext (rest) + byte[] iv = Arrays.copyOfRange(contentBuffer, 0, 16); + byte[] ciphertextBytes = Arrays.copyOfRange(contentBuffer, 16, contentBuffer.length); + String ciphertextWithoutPrefix = Base64.encodeToString(ciphertextBytes, Base64.NO_WRAP); + + return new ParsedMessage(keyId, iv, ciphertextWithoutPrefix, "rc.v1.aes-sha2"); + } + } + + public Room readRoom(final Ejson ejson, Context context) { + String dbName = getDatabaseName(ejson.serverURL(), context); WMDatabase db = null; try { - db = WMDatabase.getInstance(dbName, reactContext); + db = WMDatabase.getInstance(dbName, context); String[] queryArgs = {ejson.rid}; Cursor cursor = db.rawQuery("SELECT * FROM subscriptions WHERE id == ? LIMIT 1", queryArgs); @@ -104,9 +209,9 @@ public Room readRoom(final Ejson ejson) { } } - private String getDatabaseName(String serverUrl) { - int resId = reactContext.getResources().getIdentifier("rn_config_reader_custom_package", "string", reactContext.getPackageName()); - String className = reactContext.getString(resId); + private String getDatabaseName(String serverUrl, Context context) { + int resId = context.getResources().getIdentifier("rn_config_reader_custom_package", "string", context.getPackageName()); + String className = context.getString(resId); Boolean isOfficial = false; try { @@ -150,102 +255,184 @@ public String readUserKey(final Ejson ejson) throws Exception { return RSACrypto.INSTANCE.importJwkKey(jwk); } - public String decryptRoomKey(final String e2eKey, final Ejson ejson) throws Exception { - String key = e2eKey.substring(12); - keyId = e2eKey.substring(0, 12); - + public RoomKeyResult decryptRoomKey(final String e2eKey, final Ejson ejson) throws Exception { + // Parse using prefixed base64 + PrefixedData parsed = decodePrefixedBase64(e2eKey); + keyId = parsed.prefix; + + // Decrypt the session key String userKey = readUserKey(ejson); if (userKey == null) { return null; } - - String decrypted = RSACrypto.INSTANCE.decrypt(key, userKey); - - RoomKey roomKey = gson.fromJson(decrypted, RoomKey.class); - byte[] decoded = Base64.decode(roomKey.k, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE); - - return CryptoUtils.INSTANCE.bytesToHex(decoded); + + String base64EncryptedData = Base64.encodeToString(parsed.data, Base64.NO_WRAP); + String decrypted = RSACrypto.INSTANCE.decrypt(base64EncryptedData, userKey); + + // Parse sessionKey to determine v1 vs v2 from "alg" field + JsonObject sessionKey = gson.fromJson(decrypted, JsonObject.class); + String k = sessionKey.get("k").getAsString(); + byte[] decoded = Base64.decode(k, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE); + String decryptedKey = CryptoUtils.INSTANCE.bytesToHex(decoded); + + // Determine format from "alg" field + if (sessionKey.has("alg") && "A256GCM".equals(sessionKey.get("alg").getAsString())) { + algorithm = "rc.v2.aes-sha2"; + return new RoomKeyResult(decryptedKey, "rc.v2.aes-sha2"); + } else { + algorithm = "rc.v1.aes-sha2"; + return new RoomKeyResult(decryptedKey, "rc.v1.aes-sha2"); + } } - private String decryptText(String text, String e2eKey) throws Exception { - String msg = text.substring(12); - byte[] msgData = Base64.decode(msg, Base64.NO_WRAP); - String b64 = Base64.encodeToString(Arrays.copyOfRange(msgData, 16, msgData.length), Base64.DEFAULT); - String decrypted = AESCrypto.INSTANCE.decryptBase64(b64, e2eKey, CryptoUtils.INSTANCE.bytesToHex(Arrays.copyOfRange(msgData, 0, 16))); + private String decryptContent(Ejson.Content content, String e2eKey) throws Exception { + ParsedMessage parsed = parseMessage(content); + + String ivHex = CryptoUtils.INSTANCE.bytesToHex(parsed.iv); + String decrypted; + + if ("rc.v2.aes-sha2".equals(parsed.algorithm)) { + // Use AES-GCM decryption + decrypted = AESCrypto.INSTANCE.decryptGcmBase64(parsed.ciphertext, e2eKey, ivHex); + } else { + // Use AES-CBC decryption + decrypted = AESCrypto.INSTANCE.decryptBase64(parsed.ciphertext, e2eKey, ivHex); + } + byte[] data = Base64.decode(decrypted, Base64.NO_WRAP); - return new String(data, "UTF-8"); + String decryptedText = new String(data, "UTF-8"); + + // Try to parse as DecryptedContent first + try { + DecryptedContent m = gson.fromJson(decryptedText, DecryptedContent.class); + return m.msg != null ? m.msg : m.text; + } catch (Exception e) { + // Fallback to FallbackMessage format + FallbackMessage m = gson.fromJson(decryptedText, FallbackMessage.class); + return m.text; + } } - public String decryptMessage(final Ejson ejson, final ReactApplicationContext reactContext) { + public String decryptMessage(final Ejson ejson, final Context context) { try { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); + // Get ReactApplicationContext for MMKV access + if (context instanceof ReactApplicationContext) { + this.reactContext = (ReactApplicationContext) context; + } else { + AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); + if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { + this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); + } } - Room room = readRoom(ejson); + if (this.reactContext == null) { + Log.e(TAG, "Cannot decrypt: ReactApplicationContext not available"); + return null; + } + + Room room = readRoom(ejson, this.reactContext); if (room == null || room.e2eKey == null) { + Log.w(TAG, "Cannot decrypt: room or e2eKey not found"); return null; } - String e2eKey = decryptRoomKey(room.e2eKey, ejson); - if (e2eKey == null) { + + RoomKeyResult roomKeyResult = decryptRoomKey(room.e2eKey, ejson); + if (roomKeyResult == null || roomKeyResult.decryptedKey == null) { + Log.w(TAG, "Cannot decrypt: room key decryption failed"); return null; } - + + String e2eKey = roomKeyResult.decryptedKey; + + // Try v2 format (content field) first + if (ejson.content != null && ejson.content.algorithm != null) { + return decryptContent(ejson.content, e2eKey); + } + + // Fallback to v1 format (msg field) if (ejson.msg != null && !ejson.msg.isEmpty()) { - String message = ejson.msg; - String decryptedText = decryptText(message, e2eKey); - Message m = gson.fromJson(decryptedText, Message.class); - return m.text; - } else if (ejson.content != null && "rc.v1.aes-sha2".equals(ejson.content.algorithm)) { - String message = ejson.content.ciphertext; - String decryptedText = decryptText(message, e2eKey); - DecryptedContent m = gson.fromJson(decryptedText, DecryptedContent.class); - return m.msg; - } else { - return null; + Ejson.Content fallbackContent = new Ejson.Content(); + fallbackContent.algorithm = "rc.v1.aes-sha2"; + fallbackContent.ciphertext = ejson.msg; + fallbackContent.kid = null; + fallbackContent.iv = null; + return decryptContent(fallbackContent, e2eKey); } + + Log.w(TAG, "Cannot decrypt: no content or msg field found"); + return null; } catch (Exception e) { - Log.e("[ROCKETCHAT][E2E]", Log.getStackTraceString(e)); + Log.e(TAG, "Decryption failed", e); + return null; } - - return null; } - public String encryptMessage(final String message, final String id, final Ejson ejson) { + public EncryptionContent encryptMessageContent(final String message, final String id, final Ejson ejson) { try { AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); } - Room room = readRoom(ejson); + // Use reactContext for database access + if (this.reactContext == null) { + return null; + } + Room room = readRoom(ejson, this.reactContext); if (room == null || !room.encrypted || room.e2eKey == null) { - return message; + return null; } - String e2eKey = decryptRoomKey(room.e2eKey, ejson); - if (e2eKey == null) { - return message; + RoomKeyResult roomKeyResult = decryptRoomKey(room.e2eKey, ejson); + if (roomKeyResult == null || roomKeyResult.decryptedKey == null) { + return null; } + String e2eKey = roomKeyResult.decryptedKey; - Message m = new Message(id, ejson.userId(), message); + Message m = new Message(message); String cypher = gson.toJson(m); SecureRandom random = new SecureRandom(); - byte[] bytes = new byte[16]; - random.nextBytes(bytes); - - String encrypted = AESCrypto.INSTANCE.encryptBase64(Base64.encodeToString(cypher.getBytes("UTF-8"), Base64.NO_WRAP), e2eKey, CryptoUtils.INSTANCE.bytesToHex(bytes)); - byte[] data = Base64.decode(encrypted, Base64.NO_WRAP); - - return keyId + Base64.encodeToString(concat(bytes, data), Base64.NO_WRAP); + byte[] bytes; + String encryptedData; + + if ("rc.v2.aes-sha2".equals(algorithm)) { + // V2 format: Use AES-GCM with 12-byte IV + bytes = new byte[12]; + random.nextBytes(bytes); + encryptedData = AESCrypto.INSTANCE.encryptGcmBase64( + Base64.encodeToString(cypher.getBytes("UTF-8"), Base64.NO_WRAP), + e2eKey, + CryptoUtils.INSTANCE.bytesToHex(bytes) + ); + + return new EncryptionContent( + algorithm, + encryptedData, + keyId, + Base64.encodeToString(bytes, Base64.NO_WRAP) + ); + } else { + // V1 format: Use AES-CBC with 16-byte IV + bytes = new byte[16]; + random.nextBytes(bytes); + encryptedData = AESCrypto.INSTANCE.encryptBase64( + Base64.encodeToString(cypher.getBytes("UTF-8"), Base64.NO_WRAP), + e2eKey, + CryptoUtils.INSTANCE.bytesToHex(bytes) + ); + byte[] data = Base64.decode(encryptedData, Base64.NO_WRAP); + + // Return full ciphertext for v1 + String fullCiphertext = keyId + Base64.encodeToString(concat(bytes, data), Base64.NO_WRAP); + return new EncryptionContent(algorithm, fullCiphertext); + } } catch (Exception e) { Log.e("[ROCKETCHAT][E2E]", Log.getStackTraceString(e)); } - return message; + return null; } static byte[] concat(byte[]... arrays) { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java index d45c694da12..6b096122e17 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java @@ -114,15 +114,36 @@ protected String buildMessage(String rid, String message, Ejson ejson) { String id = getMessageId(); - String msg = Encryption.shared.encryptMessage(message, id, ejson); + // Use the new content structure approach + Encryption.EncryptionContent content = Encryption.shared.encryptMessageContent(message, id, ejson); Map msgMap = new HashMap(); msgMap.put("_id", id); msgMap.put("rid", rid); - msgMap.put("msg", msg); - if (msg != message) { + + if (content != null) { + // Create content structure similar to TypeScript + Map contentMap = new HashMap(); + contentMap.put("algorithm", content.algorithm); + contentMap.put("ciphertext", content.ciphertext); + if (content.kid != null) { + contentMap.put("kid", content.kid); + } + if (content.iv != null) { + contentMap.put("iv", content.iv); + } + msgMap.put("content", contentMap); msgMap.put("t", "e2e"); + + // For backward compatibility, also set msg field + String msg = "rc.v2.aes-sha2".equals(content.algorithm) + ? "" // Empty for v2 + : content.ciphertext; // Direct ciphertext for v1 + msgMap.put("msg", msg); + } else { + msgMap.put("msg", message); } + if(ejson.tmid != null) { msgMap.put("tmid", ejson.tmid); } diff --git a/app/containers/Header/components/HeaderButton/HeaderButtonItem.tsx b/app/containers/Header/components/HeaderButton/HeaderButtonItem.tsx index 5475aae0dce..8cea082cdb4 100644 --- a/app/containers/Header/components/HeaderButton/HeaderButtonItem.tsx +++ b/app/containers/Header/components/HeaderButton/HeaderButtonItem.tsx @@ -57,18 +57,13 @@ const Item = memo( const { colors } = useTheme(); return ( - + - + }}> {iconName ? ( ) : ( diff --git a/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap b/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap index e4fd490a6cd..ec296cc2f6e 100644 --- a/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap +++ b/app/containers/Header/components/HeaderButton/__snapshots__/HeaderButtons.test.tsx.snap @@ -149,6 +149,11 @@ exports[`Story Snapshots: Badge should match snapshot 1`] = ` > ; - content?: IMessageE2EEContent; + content?: EncryptedContent; } export interface IShareAttachment { diff --git a/app/definitions/IMessage.ts b/app/definitions/IMessage.ts index adeb8718f8d..cd9c652320a 100644 --- a/app/definitions/IMessage.ts +++ b/app/definitions/IMessage.ts @@ -80,10 +80,31 @@ interface IMessageFile { type: string; } -export type IMessageE2EEContent = { +interface IEncryptedContent { + /** + * The encryption algorithm used. + * Currently supported algorithms are: + * - `rc.v1.aes-sha2`: Rocket.Chat E2E Encryption version 1, using AES encryption with SHA-256 hashing. + * - `rc.v2.aes-sha2`: Rocket.Chat E2E Encryption version 2, using AES encryption with SHA-256 hashing and improved key management. + */ + algorithm: string; + ciphertext: string; // base64-encoded encrypted subset JSON of IMessage +} + +interface IEncryptedContentV1 extends IEncryptedContent { + /** + * The encryption algorithm used. + */ algorithm: 'rc.v1.aes-sha2'; - ciphertext: string; // Encrypted subset JSON of IMessage -}; +} + +interface IEncryptedContentV2 extends IEncryptedContent { + algorithm: 'rc.v2.aes-sha2'; + iv: string; // base64-encoded initialization vector + kid: string; // ID of the key used to encrypt the message +} + +export type EncryptedContent = IEncryptedContentV1 | IEncryptedContentV2; export interface IMessageFromServer { _id: string; @@ -112,7 +133,7 @@ export interface IMessageFromServer { username: string; }; score?: number; - content?: IMessageE2EEContent; + content?: EncryptedContent; } export interface ILoadMoreMessage { @@ -153,6 +174,7 @@ export interface IMessage extends IMessageFromServer { subscription?: { id: string }; user?: string; editedAt?: string | Date; + e2eMentions?: any; } export type TMessageModel = IMessage & diff --git a/app/definitions/rest/v1/chat.ts b/app/definitions/rest/v1/chat.ts index 1876f8d8bce..f611c829423 100644 --- a/app/definitions/rest/v1/chat.ts +++ b/app/definitions/rest/v1/chat.ts @@ -1,4 +1,4 @@ -import type { IMessage, IMessageFromServer, IReadReceipts } from '../../IMessage'; +import type { EncryptedContent, IMessage, IMessageFromServer, IReadReceipts } from '../../IMessage'; import type { IServerRoom } from '../../IRoom'; import { type PaginatedResult } from '../helpers/PaginatedResult'; @@ -75,7 +75,7 @@ export type ChatEndpoints = { }; }; 'chat.update': { - POST: (params: { roomId: IServerRoom['_id']; msgId: string; text: string }) => { + POST: (params: { roomId: IServerRoom['_id']; msgId: string; text?: string; content?: EncryptedContent }) => { messages: IMessageFromServer; }; }; diff --git a/app/i18n/locales/ar.json b/app/i18n/locales/ar.json index a0746a5f14e..677ed505017 100644 --- a/app/i18n/locales/ar.json +++ b/app/i18n/locales/ar.json @@ -178,6 +178,7 @@ "End_to_end_encrypted_room": "غرفة مشفرة بين الطرفيات", "Enter_E2EE_Password": "أدخل كلمة المرور E2EE", "Enter_E2EE_Password_description": "للوصول إلى قنواتك المشفرة والرسائل المباشرة، أدخل كلمة المرور الخاصة بالتشفير. هذا لا يتم تخزينه على الخادم، لذا ستحتاج إلى استخدامه على كل جهاز.", + "Enter_manually": "أدخل يدويًا", "Error_incorrect_password": "كلمة المرور غير صحيحة", "Error_prefix": "خطأ: {{message}}", "Error_uploading": "خطأ في الرفع", @@ -221,6 +222,7 @@ "Full_name": "الاسم الكامل", "Full_table": "انقر لرؤية الجدول كاملاً", "Generate_New_Link": "إنشاء رابط جديد", + "Generate_new_password": "إنشاء كلمة مرور جديدة", "Get_help": "احصل على المساعدة", "Glossary_of_simplified_terms": "مسرد المصطلحات المبسطة", "Help": "مساعدة", diff --git a/app/i18n/locales/bn-IN.json b/app/i18n/locales/bn-IN.json index 95b146f28ed..df91b8dd764 100644 --- a/app/i18n/locales/bn-IN.json +++ b/app/i18n/locales/bn-IN.json @@ -280,6 +280,7 @@ "End_to_end_encrypted_room": "শেষ হতে শেষ এনক্রিপ্টেড রুম", "Enter_E2EE_Password": "E2EE পাসওয়ার্ড দিন", "Enter_E2EE_Password_description": "আপনার এনক্রিপ্টেড চ্যানেলগুলি এবং সরাসরি বার্তাগুলি অ্যাক্সেস করতে, আপনার এনক্রিপশন পাসওয়ার্ড লিখুন। এটি সার্ভারে সংরক্ষিত হয় না, তাই আপনাকে প্রতিটি ডিভাইসে এটি ব্যবহার করতে হবে।", + "Enter_manually": "ম্যানুয়ালি লিখুন", "Error_Download_file": "ফাইল ডাউনলোড করতে ত্রুটি", "Error_incorrect_password": "ভুল পাসওয়ার্ড", "Error_prefix": "ত্রুটি: {{message}}", @@ -331,6 +332,7 @@ "Full_name": "সম্পূর্ণ নাম", "Full_table": "পুরো টেবিল দেখতে ক্লিক করুন", "Generate_New_Link": "নতুন লিঙ্ক তৈরি করুন", + "Generate_new_password": "নতুন পাসওয়ার্ড তৈরি করুন", "Get_help": "সাহায্য পান", "Get_link": "লিঙ্ক পান", "Glossary_of_simplified_terms": "সরলীকৃত শর্তগুলির গ্লোসারি", diff --git a/app/i18n/locales/cs.json b/app/i18n/locales/cs.json index 9af2efdfae6..4e4dcd80c86 100644 --- a/app/i18n/locales/cs.json +++ b/app/i18n/locales/cs.json @@ -294,6 +294,7 @@ "Encryption_error_desc": "Nebyla možná dekódovat váš šifrovací klíč.", "Encryption_error_title": "Nesprávné heslo", "End_to_end_encrypted_room": "End to end šifrovaná místnost", + "Enter_manually": "Zadat ručně", "Enter_the_code": "Zadejte kód, který jsme vám právě poslali e-mailem.", "Enter_Your_E2E_Password": "Zadejte heslo E2E", "Enter_Your_Encryption_Password_desc1": "To vám umožní přístup k vašim zašifrovaným soukromým skupinám a přímým zprávám.", @@ -350,6 +351,7 @@ "Full_name": "Celé jméno", "Full_table": "Kliknutím zobrazíte celou tabulku", "Generate_New_Link": "Vygenerovat nový odkaz", + "Generate_new_password": "Vygenerovat nové heslo", "Get_help": "Získat pomoc", "Get_link": "Získat odkaz", "Glossary_of_simplified_terms": "Slovník zjednodušených termínů", diff --git a/app/i18n/locales/de.json b/app/i18n/locales/de.json index c485902adc4..affb0e8ce6e 100644 --- a/app/i18n/locales/de.json +++ b/app/i18n/locales/de.json @@ -274,6 +274,7 @@ "End_to_end_encrypted_room": "Ende-zu-Ende-verschlüsselter Raum", "Enter_E2EE_Password": "E2EE-Passwort eingeben", "Enter_E2EE_Password_description": "Um auf Ihre verschlüsselten Kanäle und Direktnachrichten zuzugreifen, geben Sie Ihr Verschlüsselungspasswort ein. Dies wird nicht auf dem Server gespeichert, daher müssen Sie es auf jedem Gerät verwenden.", + "Enter_manually": "Manuell eingeben", "Error_Download_file": "Fehler beim Herunterladen der Datei", "Error_incorrect_password": "Falsches Passwort", "Error_prefix": "Fehler: {{message}}", @@ -324,6 +325,7 @@ "Full_name": "Vollständiger Name", "Full_table": "Klicken, um die ganze Tabelle anzuzeigen", "Generate_New_Link": "Neuen Link erstellen", + "Generate_new_password": "Neues Passwort generieren", "Get_help": "Hilfe holen", "Get_link": "Link erhalten", "Glossary_of_simplified_terms": "Glossar vereinfachter Begriffe", diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json index 4c36b2afa3b..884eac646d1 100644 --- a/app/i18n/locales/en.json +++ b/app/i18n/locales/en.json @@ -316,6 +316,7 @@ "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.", + "Enter_manually": "Enter manually", "Enter_the_code": "Enter the code we just emailed you.", "Error_Download_file": "Error while downloading file", "Error_incorrect_password": "Incorrect password", @@ -370,6 +371,7 @@ "Full_name": "Full name", "Full_table": "Click to see full table", "Generate_New_Link": "Generate new link", + "Generate_new_password": "Generate new password", "Get_help": "Get help", "Get_link": "Get link", "Glossary_of_simplified_terms": "Glossary of simplified terms", diff --git a/app/i18n/locales/es.json b/app/i18n/locales/es.json index 5ff50fe88f5..6549e347351 100644 --- a/app/i18n/locales/es.json +++ b/app/i18n/locales/es.json @@ -142,6 +142,7 @@ "Enable_Auto_Translate": "Permitir Auto-Translate", "Encryption_error_desc": "No fue posible descifrar tu clave de cifrado.", "Encryption_error_title": "Contraseña incorrecta", + "Enter_manually": "Ingresar manualmente", "Error_incorrect_password": "Contraseña incorrecta", "Error_prefix": "Error: {{mensaje}}", "Error_uploading": "Error en la subida", @@ -176,6 +177,7 @@ "Forgot_password_If_this_email_is_registered": "Si este email está registrado, te enviaremos las instrucciones para resetear tu contraseña. Si no recibes un email en breve, vuelve aquí e inténtalo de nuevo.", "Full_name": "Nombre completo", "Full_table": "Click para ver la tabla completa", + "Generate_new_password": "Generar nueva contraseña", "Get_help": "Obtener ayuda", "Get_link": "Obtener enlace", "Glossary_of_simplified_terms": "Glosario de términos simplificados", diff --git a/app/i18n/locales/fi.json b/app/i18n/locales/fi.json index 02c4b692e3e..d1e6b95c04f 100644 --- a/app/i18n/locales/fi.json +++ b/app/i18n/locales/fi.json @@ -259,6 +259,7 @@ "End_to_end_encrypted_room": "Täysin salattu huone", "Enter_E2EE_Password": "Anna E2EE-salasana", "Enter_E2EE_Password_description": "Päästäksesi käsiksi salattuihin kanaviisi ja suoriin viesteihisi, syötä salaus salasanasi. Tätä ei tallenneta palvelimelle, joten sinun on käytettävä sitä jokaisella laitteella.", + "Enter_manually": "Syötä manuaalisesti", "Error_Download_file": "Virhe ladattaessa tiedostoa", "Error_incorrect_password": "Virheellinen salasana", "Error_prefix": "Virhe: {{message}}", @@ -309,6 +310,7 @@ "Full_name": "Koko nimi", "Full_table": "Näytä koko taulukko napsauttamalla", "Generate_New_Link": "Luo uusi linkki", + "Generate_new_password": "Luo uusi salasana", "Get_help": "Hae apua", "Get_link": "Hanki linkki", "Glossary_of_simplified_terms": "Yksinkertaistetut termit -sanasto", diff --git a/app/i18n/locales/fr.json b/app/i18n/locales/fr.json index 477a3bdca92..c4e150ad8af 100644 --- a/app/i18n/locales/fr.json +++ b/app/i18n/locales/fr.json @@ -226,6 +226,7 @@ "End_to_end_encrypted_room": "Salon crypté de bout en bout", "Enter_E2EE_Password": "Entrez le mot de passe E2EE", "Enter_E2EE_Password_description": "Pour accéder à vos canaux cryptés et à vos messages directs, entrez votre mot de passe de cryptage. Celui-ci n'est pas stocké sur le serveur, vous devrez donc l'utiliser sur chaque appareil.", + "Enter_manually": "Saisir manuellement", "Error_Download_file": "Erreur lors du téléchargement du fichier", "Error_incorrect_password": "Mot de passe incorrect", "Error_prefix": "Erreur : {{message}}", @@ -274,6 +275,7 @@ "Full_name": "Nom complet", "Full_table": "Cliquez pour voir le tableau complet", "Generate_New_Link": "Générer un nouveau lien", + "Generate_new_password": "Générer un nouveau mot de passe", "Get_help": "Obtenez de l'aide", "Get_link": "Obtenir le lien", "Glossary_of_simplified_terms": "Glossaire des termes simplifiés", diff --git a/app/i18n/locales/hi-IN.json b/app/i18n/locales/hi-IN.json index cc2f95777fe..245c9f87902 100644 --- a/app/i18n/locales/hi-IN.json +++ b/app/i18n/locales/hi-IN.json @@ -280,6 +280,7 @@ "End_to_end_encrypted_room": "इंड-टू-इंड एन्क्रिप्टेड कमरा", "Enter_E2EE_Password": "E2EE पासवर्ड डालें", "Enter_E2EE_Password_description": "अपने एन्क्रिप्टेड चैनल्स और डायरेक्ट मैसेजेस तक पहुँचने के लिए, अपना एन्क्रिप्शन पासवर्ड दर्ज करें। यह सर्वर पर संग्रहीत नहीं होता है, इसलिए आपको इसे हर डिवाइस पर उपयोग करना होगा।", + "Enter_manually": "मैन्युअल रूप से दर्ज करें", "Error_Download_file": "फ़ाइल डाउनलोड करते समय त्रुटि", "Error_incorrect_password": "गलत पासवर्ड", "Error_prefix": "त्रुटि: {{संदेश}}", @@ -331,6 +332,7 @@ "Full_name": "पूरा नाम", "Full_table": "पूर्ण तालिका देखने के लिए क्लिक करें", "Generate_New_Link": "नई लिंक बनाएं", + "Generate_new_password": "नया पासवर्ड बनाएँ", "Get_help": "सहायता प्राप्त करें", "Get_link": "लिंक प्राप्त करें", "Glossary_of_simplified_terms": "सरल शब्दावली", diff --git a/app/i18n/locales/hu.json b/app/i18n/locales/hu.json index 678ad46d8de..e10e80df6a8 100644 --- a/app/i18n/locales/hu.json +++ b/app/i18n/locales/hu.json @@ -280,6 +280,7 @@ "End_to_end_encrypted_room": "Végponttól végpontig titkosított szoba", "Enter_E2EE_Password": "Adja meg az E2EE jelszót", "Enter_E2EE_Password_description": "Az titkosított csatornákhoz és közvetlen üzenetekhez való hozzáféréshez írja be a titkosítási jelszavát. Ez nincs tárolva a szerveren, így minden eszközön használnia kell.", + "Enter_manually": "Kézi bevitel", "Error_Download_file": "Hiba a fájl letöltése közben", "Error_incorrect_password": "Helytelen jelszó", "Error_prefix": "Hiba: {{üzenet}}", @@ -331,6 +332,7 @@ "Full_name": "Teljes név", "Full_table": "Kattintson a teljes táblázat megtekintéséhez", "Generate_New_Link": "Új hivatkozás létrehozása", + "Generate_new_password": "Új jelszó generálása", "Get_help": "Kérjen segítséget", "Get_link": "Hivatkozás lekérése", "Glossary_of_simplified_terms": "Egyszerűsített kifejezések szótára", diff --git a/app/i18n/locales/it.json b/app/i18n/locales/it.json index cbbff6eb3b1..9b0a1eac612 100644 --- a/app/i18n/locales/it.json +++ b/app/i18n/locales/it.json @@ -202,6 +202,7 @@ "End_to_end_encrypted_room": "Stanza crittografata end to end", "Enter_E2EE_Password": "Inserisci la password E2EE", "Enter_E2EE_Password_description": "Per accedere ai tuoi canali criptati e ai messaggi diretti, inserisci la tua password di crittografia. Questa non viene memorizzata sul server, quindi dovrai usarla su ogni dispositivo.", + "Enter_manually": "Inserisci manualmente", "Error_incorrect_password": "Password errata", "Error_prefix": "Errore: {{message}}", "Error_uploading": "Errore nel caricamento di", @@ -247,6 +248,7 @@ "Full_name": "Nome completo", "Full_table": "Clicca per la tabella completa", "Generate_New_Link": "Genera nuovo link", + "Generate_new_password": "Genera nuova password", "Get_help": "Ottieni aiuto", "Get_link": "Ottieni link", "Glossary_of_simplified_terms": "Glossario dei termini semplificati", diff --git a/app/i18n/locales/ja.json b/app/i18n/locales/ja.json index 6eac408d43c..c718a3ac002 100644 --- a/app/i18n/locales/ja.json +++ b/app/i18n/locales/ja.json @@ -177,6 +177,7 @@ "End_to_end_encrypted_room": "暗号化されたエンドツーエンドのルーム", "Enter_E2EE_Password": "E2EEパスワードを入力", "Enter_E2EE_Password_description": "暗号化されたチャンネルとダイレクトメッセージにアクセスするには、暗号化パスワードを入力してください。これはサーバーに保存されないため、すべてのデバイスで使用する必要があります。", + "Enter_manually": "手動で入力", "Error_incorrect_password": "パスワードが違います。", "Error_prefix": "エラー: {{message}}", "Error_uploading": "アップロードエラー", @@ -221,6 +222,7 @@ "Full_name": "フルネーム", "Full_table": "クリックしてテーブル全体を見る", "Generate_New_Link": "新しいリンクを生成", + "Generate_new_password": "新しいパスワードを生成", "Get_help": "ヘルプを得る", "Glossary_of_simplified_terms": "簡易用語集", "Has_left_the_team": "チームを退出しました", diff --git a/app/i18n/locales/nl.json b/app/i18n/locales/nl.json index b4f0bbb4c89..dffbc7138c6 100644 --- a/app/i18n/locales/nl.json +++ b/app/i18n/locales/nl.json @@ -226,6 +226,7 @@ "End_to_end_encrypted_room": "End-to-end versleutelde kamer", "Enter_E2EE_Password": "Voer E2EE-wachtwoord in", "Enter_E2EE_Password_description": "Om toegang te krijgen tot uw versleutelde kanalen en directe berichten, voert u uw versleutelingswachtwoord in. Dit wordt niet op de server opgeslagen, dus u moet het op elk apparaat gebruiken.", + "Enter_manually": "Handmatig invoeren", "Error_Download_file": "Fout tijdens het downloaden van bestand", "Error_incorrect_password": "Onjuist wachtwoord", "Error_prefix": "Fout: {{message}}", @@ -274,6 +275,7 @@ "Full_name": "Volledige naam", "Full_table": "Klik om de volledige tabel te zien", "Generate_New_Link": "Nieuwe link genereren", + "Generate_new_password": "Nieuw wachtwoord genereren", "Get_help": "Hulp krijgen", "Get_link": "Link krijgen", "Glossary_of_simplified_terms": "Glossarium van vereenvoudigde termen", diff --git a/app/i18n/locales/nn.json b/app/i18n/locales/nn.json index f89f7c77d64..3a36ab891a8 100644 --- a/app/i18n/locales/nn.json +++ b/app/i18n/locales/nn.json @@ -142,6 +142,7 @@ "Encryption_keys_reset_failed": "Tilbakestilling av krypteringsnøkler mislyktes", "Enter_E2EE_Password": "Skriv inn Ende-Til-Ende-passord", "Enter_E2EE_Password_description": "For å få tilgang til dine krypterte private grupper og direktemeldinger, skriv inn krypteringspassordet ditt. Du må skrive inn dette passordet for å kode/dekode meldingene dine på hver klient du bruker, siden nøkkelen ikke er lagret på serveren.", + "Enter_manually": "Skriv inn manuelt", "Enter_the_code": "Skriv inn koden vi nettopp sendte deg på e-post.", "error-action-not-allowed": "{{action}} er ikke tillatt", "error-avatar-invalid-url": "Ugyldig avatar URL: {{url}}", @@ -169,6 +170,7 @@ "Forward_Chat": "Videresend chat", "Forward_to_department": "Videresend til avdeling", "Forward_to_user": "Videresend til bruker", + "Generate_new_password": "Generer nytt passord", "Glossary_of_simplified_terms": "Ordliste med forenklede termer", "Group_by": "Grupper etter", "Hide": "Skjul rom", diff --git a/app/i18n/locales/no.json b/app/i18n/locales/no.json index 6ba497f838c..0f270246a3f 100644 --- a/app/i18n/locales/no.json +++ b/app/i18n/locales/no.json @@ -303,6 +303,7 @@ "End_to_end_encrypted_room": "Ende til ende kryptert kom", "Enter_E2EE_Password": "Skriv inn E2EE-passord", "Enter_E2EE_Password_description": "Skriv inn krypteringspassordet ditt for å få tilgang til dine krypterte kanaler og direktemeldinger. Dette er ikke lagret på serveren, så du må bruke det på alle enheter.", + "Enter_manually": "Skriv inn manuelt", "Enter_the_code": "Skriv inn koden vi nettopp sendte deg på e-post.", "Error_Download_file": "Feil under nedlasting av fil", "Error_play_video": "Det oppsto en feil under avspilling av denne videoen", @@ -349,6 +350,7 @@ "Full_name": "Fullt navn", "Full_table": "Klikk for å se hele tabellen", "Generate_New_Link": "Generer ny lenke", + "Generate_new_password": "Generer nytt passord", "Get_help": "Få hjelp", "Get_link": "Få lenke", "Glossary_of_simplified_terms": "Ordliste med forenklede termer", diff --git a/app/i18n/locales/pt-BR.json b/app/i18n/locales/pt-BR.json index 541bfa95f2b..40d1c7ea676 100644 --- a/app/i18n/locales/pt-BR.json +++ b/app/i18n/locales/pt-BR.json @@ -307,6 +307,7 @@ "End_to_end_encrypted_room": "Sala criptografada de ponta a ponta", "Enter_E2EE_Password": "Digite a senha E2EE", "Enter_E2EE_Password_description": "Para acessar seus canais criptografados e mensagens diretas, insira sua senha de criptografia. Isso não é armazenado no servidor, então você precisará usá-la em cada dispositivo.", + "Enter_manually": "Inserir manualmente", "Enter_the_code": "Insira o código que acabamos de enviar por e-mail.", "Error_Download_file": "Erro ao baixar o arquivo", "Error_incorrect_password": "Senha incorreta", @@ -361,6 +362,7 @@ "Full_name": "Nome completo", "Full_table": "Clique para ver a tabela completa", "Generate_New_Link": "Gerar novo convite", + "Generate_new_password": "Gerar nova senha", "Get_help": "Obter ajuda", "Get_link": "Obter link", "Glossary_of_simplified_terms": "Glossário de termos simplificados", diff --git a/app/i18n/locales/pt-PT.json b/app/i18n/locales/pt-PT.json index c6200efc916..5b8dcde3fa6 100644 --- a/app/i18n/locales/pt-PT.json +++ b/app/i18n/locales/pt-PT.json @@ -173,6 +173,7 @@ "End_to_end_encrypted_room": "Sala encriptada de ponta a ponta", "Enter_E2EE_Password": "Digite a senha E2EE", "Enter_E2EE_Password_description": "Para aceder aos seus canais encriptados e mensagens diretas, introduza a sua palavra-passe de encriptação. Isto não é armazenado no servidor, pelo que terá de o utilizar em cada dispositivo.", + "Enter_manually": "Inserir manualmente", "Error_incorrect_password": "Palavra-passe incorreta", "Error_prefix": "Erro: {{message}}", "Error_uploading": "Erro ao fazer o envio", @@ -216,6 +217,7 @@ "Full_name": "Nome completo", "Full_table": "Clique para ver a tabela completa", "Generate_New_Link": "Gerar Novo Link", + "Generate_new_password": "Gerar nova palavra-passe", "Get_help": "Obter ajuda", "Get_link": "Obter Ligação", "Glossary_of_simplified_terms": "Glossário de termos simplificados", diff --git a/app/i18n/locales/ru.json b/app/i18n/locales/ru.json index 9f19d89f889..ef87cdad10d 100644 --- a/app/i18n/locales/ru.json +++ b/app/i18n/locales/ru.json @@ -250,6 +250,7 @@ "End_to_end_encrypted_room": "Чат со сквозным шифрованием", "Enter_E2EE_Password": "Введите E2EE Пароль", "Enter_E2EE_Password_description": "Чтобы получить доступ к вашим зашифрованным каналам и прямым сообщениям, введите свой пароль шифрования. Он не хранится на сервере, поэтому вам нужно будет использовать его на каждом устройстве.", + "Enter_manually": "Ввести вручную", "Error_Download_file": "Ошибка при скачивании файла", "Error_incorrect_password": "Неверный пароль", "Error_prefix": "Ошибка: {{сообщение}}", @@ -299,6 +300,7 @@ "Full_name": "Полное имя", "Full_table": "Нажмите, чтобы увидеть полную таблицу", "Generate_New_Link": "Сгенерировать Новую Ссылку", + "Generate_new_password": "Создать новый пароль", "Get_help": "Получить помощь", "Get_link": "Получить ссылку", "Glossary_of_simplified_terms": "Глоссарий упрощенных терминов", diff --git a/app/i18n/locales/sl-SI.json b/app/i18n/locales/sl-SI.json index 31781d2b207..8ad9c078e55 100644 --- a/app/i18n/locales/sl-SI.json +++ b/app/i18n/locales/sl-SI.json @@ -236,6 +236,7 @@ "End_to_end_encrypted_room": "Obojestransko (E2E) šifrirane sobe", "Enter_E2EE_Password": "Vnesite geslo E2EE", "Enter_E2EE_Password_description": "Za dostop do vaših šifriranih kanalov in neposrednih sporočil vnesite svoje šifrirno geslo. To ni shranjeno na strežniku, zato ga boste morali uporabljati na vsaki napravi.", + "Enter_manually": "Vnesi ročno", "Error_Download_file": "Napaka med prenosom datoteke", "Error_incorrect_password": "Napačno geslo", "Error_prefix": "Napaka: {{message}}", @@ -285,6 +286,7 @@ "Full_name": "Polno ime", "Full_table": "Kliknite za ogled celotne tabele", "Generate_New_Link": "Ustvari novo povezavo", + "Generate_new_password": "Ustvari novo geslo", "Get_help": "Pridobite pomoč", "Glossary_of_simplified_terms": "Glosarij poenostavljenih izrazov", "Group_by": "Skupina po", diff --git a/app/i18n/locales/sv.json b/app/i18n/locales/sv.json index 21c289591b7..9740ac047b3 100644 --- a/app/i18n/locales/sv.json +++ b/app/i18n/locales/sv.json @@ -259,6 +259,7 @@ "End_to_end_encrypted_room": "End-to-end-krypterat rum", "Enter_E2EE_Password": "Ange E2EE-lösenord", "Enter_E2EE_Password_description": "För att komma åt dina krypterade kanaler och direkta meddelanden, ange ditt krypteringslösenord. Detta lagras inte på servern, så du måste använda det på varje enhet.", + "Enter_manually": "Ange manuellt", "Error_Download_file": "Fel vid nedladdning av fil", "Error_incorrect_password": "Felaktigt lösenord", "Error_prefix": "Fel: {{message}}", @@ -308,6 +309,7 @@ "Full_name": "Fullständigt namn", "Full_table": "Klicka för att visa hela tabellen", "Generate_New_Link": "Generera ny länk", + "Generate_new_password": "Generera nytt lösenord", "Get_help": "Få hjälp", "Get_link": "Hämta länk", "Glossary_of_simplified_terms": "Ordlista över förenklade termer", diff --git a/app/i18n/locales/ta-IN.json b/app/i18n/locales/ta-IN.json index b97024effdc..62c4ddacfd4 100644 --- a/app/i18n/locales/ta-IN.json +++ b/app/i18n/locales/ta-IN.json @@ -280,6 +280,7 @@ "End_to_end_encrypted_room": "குறிபாக என்று என்கிரிப்டுக்கப்பட்ட அறை", "Enter_E2EE_Password": "E2EE கடவுச்சொல் உள்ளிடவும்", "Enter_E2EE_Password_description": "உங்கள் என்கிரிப்ட் செய்யப்பட்ட சேனல்கள் மற்றும் நேரடி செய்திகளை அணுக, உங்கள் என்கிரிப்ஷன் கடவுச்சொல்லை உள்ளிடவும். இது சர்வரில் சேமிக்கப்படாது, எனவே நீங்கள் ஒவ்வொரு சாதனத்திலும் இதை பயன்படுத்த வேண்டும்.", + "Enter_manually": "கைமுறையாக உள்ளிடவும்", "Error_Download_file": "கோப்பு பதிவிறக்கம் செய்யும் போது பிழை", "Error_incorrect_password": "தவறான கடவுச்சொல்", "Error_prefix": "பிழை: {{message}}", @@ -331,6 +332,7 @@ "Full_name": "முழு பெயர்", "Full_table": "முழு அட்டவணையைக் காண கிளிக் செய்க", "Generate_New_Link": "புதிய இணைப்பை உருவாக்கு", + "Generate_new_password": "புதிய கடவுச்சொல்லை உருவாக்கவும்", "Get_help": "உதவி பெறுங்கள்", "Get_link": "இணைப்பைப் பெறுக", "Glossary_of_simplified_terms": "எளிதாக்கப்பட்ட சொற்கள் குறியீடு", diff --git a/app/i18n/locales/te-IN.json b/app/i18n/locales/te-IN.json index 682d2fa665c..e7b0f074192 100644 --- a/app/i18n/locales/te-IN.json +++ b/app/i18n/locales/te-IN.json @@ -279,6 +279,7 @@ "End_to_end_encrypted_room": "ఎండ్ టు ఎండ్ ఎన్క్రిప్టెడ్ రూం", "Enter_E2EE_Password": "E2EE పాస్‌వర్డ్ నమోదు చేయండి", "Enter_E2EE_Password_description": "మీ ఎన్క్రిప్టెడ్ ఛానెల్స్ మరియు డైరెక్ట్ మెసేజెస్‌ను యాక్సెస్ చేయడానికి, మీ ఎన్క్రిప్షన్ పాస్‌వర్డ్‌ను నమోదు చేయండి. ఇది సర్వర్‌లో నిల్వ చేయబడదు, కాబట్టి మీరు ప్రతి పరికరంపై దీనిని ఉపయోగించాలి.", + "Enter_manually": "మాన్యువల్‌గా నమోదు చేయండి", "Error_Download_file": "ఫైల్ డౌన్‌లోడ్ చేస్తున్నప్పుడు తప్పిబాటు", "Error_incorrect_password": "गलत पासवर्ड", "Error_prefix": "त्रुटि: {{संदेश}}", @@ -330,6 +331,7 @@ "Full_name": "పూర్తి పేరు", "Full_table": "పూర్తి పటం చూడండి కొరకు క్లిక్ చేయండి", "Generate_New_Link": "కొత్త లింక్‌ను రూపొందించండి", + "Generate_new_password": "కొత్త పాస్‌వర్డ్ రూపొందించండి", "Get_help": "సహాయం పొందండి", "Get_link": "లింక్ పొందండి", "Glossary_of_simplified_terms": "సరళీకృత పదాల పదకోశం", diff --git a/app/i18n/locales/tr.json b/app/i18n/locales/tr.json index 54bdecd83be..2a3182f5d90 100644 --- a/app/i18n/locales/tr.json +++ b/app/i18n/locales/tr.json @@ -191,6 +191,7 @@ "End_to_end_encrypted_room": "Uçtan uca şifreli oda", "Enter_E2EE_Password": "E2EE Şifresini Girin", "Enter_E2EE_Password_description": "Şifreli kanallarınıza ve doğrudan mesajlarınıza erişmek için şifreleme şifrenizi girin. Bu, sunucuda saklanmaz, bu nedenle her cihazda kullanmanız gerekir.", + "Enter_manually": "Manuel olarak gir", "Error_incorrect_password": "Yanlış parola", "Error_prefix": "Hata: {{message}}", "Error_uploading": "Yükleme hatası", @@ -234,6 +235,7 @@ "Full_name": "Tam ad", "Full_table": "Tam tabloyu görmek için tıklayın", "Generate_New_Link": "Yeni Bağlantı Oluştur", + "Generate_new_password": "Yeni şifre oluştur", "Get_help": "Yardım alın", "Get_link": "Bağlantıyı Al", "Glossary_of_simplified_terms": "Basitleştirilmiş terimler sözlüğü", diff --git a/app/i18n/locales/zh-CN.json b/app/i18n/locales/zh-CN.json index 987c8fc7eff..ded92610a84 100644 --- a/app/i18n/locales/zh-CN.json +++ b/app/i18n/locales/zh-CN.json @@ -187,6 +187,7 @@ "End_to_end_encrypted_room": "E2E 加密聊天室", "Enter_E2EE_Password": "输入 E2EE 密码", "Enter_E2EE_Password_description": "要访问您的加密频道和直接消息,请输入您的加密密码。这不会存储在服务器上,因此您需要在每个设备上使用它。", + "Enter_manually": "手动输入", "Error_incorrect_password": "密码错误", "Error_prefix": "错误:{{message}}", "Error_uploading": "错误上传", @@ -230,6 +231,7 @@ "Full_name": "全名", "Full_table": "点击以查看完整表格", "Generate_New_Link": "产生新的链接", + "Generate_new_password": "生成新密码", "Get_help": "获取帮助", "Glossary_of_simplified_terms": "简化术语词汇表", "Help": "帮助", diff --git a/app/i18n/locales/zh-TW.json b/app/i18n/locales/zh-TW.json index bc818601579..f8f37447191 100644 --- a/app/i18n/locales/zh-TW.json +++ b/app/i18n/locales/zh-TW.json @@ -193,6 +193,7 @@ "End_to_end_encrypted_room": "E2E 加密聊天室", "Enter_E2EE_Password": "輸入 E2EE 密碼", "Enter_E2EE_Password_description": "要存取您的加密頻道和直接訊息,請輸入您的加密密碼。這不會儲存在伺服器上,因此您需要在每個裝置上使用它。", + "Enter_manually": "手動輸入", "Error_incorrect_password": "密碼錯誤", "Error_prefix": "錯誤:{{message}}", "Error_uploading": "錯誤上傳", @@ -238,6 +239,7 @@ "Full_name": "全名", "Full_table": "點擊以查看完整表格", "Generate_New_Link": "產生新的連結", + "Generate_new_password": "生成新密碼", "Get_help": "獲取幫助", "Get_link": "取得連結", "Glossary_of_simplified_terms": "簡化術語詞彙表", diff --git a/app/lib/constants/keys.ts b/app/lib/constants/keys.ts index e2206bb3a5b..8c6dc1da7d2 100644 --- a/app/lib/constants/keys.ts +++ b/app/lib/constants/keys.ts @@ -5,7 +5,7 @@ export const E2E_RANDOM_PASSWORD_KEY = 'RC_E2E_RANDOM_PASSWORD_KEY'; export const E2E_STATUS = { PENDING: 'pending', DONE: 'done' -}; +} as const; export const E2E_BANNER_TYPE = { REQUEST_PASSWORD: 'REQUEST_PASSWORD', SAVE_PASSWORD: 'SAVE_PASSWORD' diff --git a/app/lib/encryption/definitions.ts b/app/lib/encryption/definitions.ts index faa13e9b23a..ec441b65b17 100644 --- a/app/lib/encryption/definitions.ts +++ b/app/lib/encryption/definitions.ts @@ -1,7 +1,7 @@ import { type TAttachmentEncryption, type TSendFileMessageFileInfo } from '../../definitions'; export type TGetContentResult = { - algorithm: 'rc.v1.aes-sha2'; + algorithm: 'rc.v1.aes-sha2' | 'rc.v2.aes-sha2'; ciphertext: string; }; diff --git a/app/lib/encryption/encryption.ts b/app/lib/encryption/encryption.ts index cb65888a200..8a1e50c5762 100644 --- a/app/lib/encryption/encryption.ts +++ b/app/lib/encryption/encryption.ts @@ -1,5 +1,4 @@ import { type Model, Q } from '@nozbe/watermelondb'; -import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; import EJSON from 'ejson'; import { deleteAsync } from 'expo-file-system'; import { @@ -11,7 +10,9 @@ import { rsaImportKey, rsaExportKey, type JWK, - calculateFileChecksum + calculateFileChecksum, + aesGcmDecrypt, + aesGcmEncrypt } from '@rocket.chat/mobile-crypto'; import { sampleSize } from 'lodash'; @@ -42,26 +43,24 @@ import { compareServerVersion } from '../methods/helpers'; import { e2eSetUserPublicAndPrivateKeys, e2eRequestSubscriptionKeys, - e2eRejectSuggestedGroupKey, - e2eAcceptSuggestedGroupKey, fetchUsersWaitingForGroupKey, provideUsersSuggestedGroupKeys } from '../services/restApi'; import { store } from '../store/auxStore'; import { MAX_CONCURRENT_QUEUE } from './constants'; -import { type IDecryptionFileQueue, type TDecryptFile, type TEncryptFile } from './definitions'; +import type { IDecryptionFileQueue, TDecryptFile } from './definitions'; import Deferred from './helpers/deferred'; import EncryptionRoom from './room'; import { decryptAESCTR, joinVectorData, - randomPassword, - splitVectorData, utf8ToBuffer, bufferToB64, bufferToHex, bufferToUtf8, - b64ToBuffer + b64ToBuffer, + parsePrivateKey, + generatePassphrase } from './utils'; const ROOM_KEY_EXCHANGE_SIZE = 10; @@ -71,23 +70,7 @@ class Encryption { publicKey: string | null; readyPromise: Deferred; userId: string | null; - roomInstances: { - [rid: string]: { - ready: boolean; - provideKeyToUser: Function; - handshake: Function; - decrypt: Function; - decryptFileContent: Function; - encrypt: Function; - encryptText: Function; - encryptFile: TEncryptFile; - encryptUpload: Function; - importRoomKey: Function; - resetRoomKey: Function; - hasSessionKey: () => boolean; - encryptGroupKeyForParticipantsWaitingForTheKeys: (params: any) => Promise; - }; - }; + roomInstances: Record; decryptionFileQueue: IDecryptionFileQueue[]; decryptionFileQueueActiveCount: number; keyDistributionInterval: ReturnType | null; @@ -219,45 +202,60 @@ class Encryption { // Encode a private key before send it to the server encodePrivateKey = async (privateKey: string, password: string, userId: string) => { - const keyBase64 = await this.generateMasterKey(password, userId); + // TODO: get the appropriate server version + const { version } = store.getState().server; + const isV2 = compareServerVersion(version, 'greaterThanOrEqualTo', '7.13.0'); - const ivArrayBuffer = b64ToBuffer(await randomBytes(16)); + const salt = isV2 ? `v2:${userId}:mobile` : userId; + const keyBase64 = await this.generateMasterKey(password, salt, isV2 ? 100000 : 1000); + const ivB64 = isV2 ? await randomBytes(12) : await randomBytes(16); + const ivArrayBuffer = b64ToBuffer(ivB64); const keyHex = bufferToHex(b64ToBuffer(keyBase64)); const ivHex = bufferToHex(ivArrayBuffer); + if (isV2) { + const ciphertextB64 = await aesGcmEncrypt(bufferToB64(utf8ToBuffer(privateKey)), keyHex, ivHex); + return EJSON.stringify({ iv: ivB64, ciphertext: ciphertextB64, salt, iterations: 100000 }); + } + + // v1 const data = b64ToBuffer(await aesEncrypt(bufferToB64(utf8ToBuffer(privateKey)), keyHex, ivHex)); return EJSON.stringify(new Uint8Array(joinVectorData(ivArrayBuffer, data))); }; // Decode a private key fetched from server decodePrivateKey = async (privateKey: string, password: string, userId: string) => { - const keyBase64 = await this.generateMasterKey(password, userId); + const { iv: ivBuffer, ciphertext: ciphertextBuffer, iterations, version, salt } = parsePrivateKey(privateKey, userId); + const ciphertextB64 = bufferToB64(ciphertextBuffer); + const ivHex = bufferToHex(ivBuffer); - const [ivArrayBuffer, dataBuffer] = splitVectorData(EJSON.parse(privateKey)); - const dataBase64 = bufferToB64(dataBuffer); + const keyBase64 = await this.generateMasterKey(password, salt, iterations); const keyHex = bufferToHex(b64ToBuffer(keyBase64)); - const ivHex = bufferToHex(ivArrayBuffer); - const privKeyBase64 = await aesDecrypt(dataBase64, keyHex, ivHex); + let privKeyBase64; + if (version === 'v2') { + privKeyBase64 = await aesGcmDecrypt(ciphertextB64, keyHex, ivHex); + } else { + privKeyBase64 = await aesDecrypt(ciphertextB64, keyHex, ivHex); + } return bufferToUtf8(b64ToBuffer(privKeyBase64)); }; - // Generate a user master key, this is based on userId and a password - generateMasterKey = async (password: string, userId: string): Promise => { - const iterations = 1000; + // Generate a user master key, this is based on salt and a password + generateMasterKey = async (password: string, salt: string, iterations: number): Promise => { const hash = 'SHA256'; const keyLen = 32; const passwordBase64 = bufferToB64(utf8ToBuffer(password)); - const userIdBase64 = bufferToB64(utf8ToBuffer(userId)); + const saltBase64 = bufferToB64(utf8ToBuffer(salt)); - const masterKeyBase64 = await pbkdf2Hash(passwordBase64, userIdBase64, iterations, keyLen, hash); + const masterKeyBase64 = await pbkdf2Hash(passwordBase64, saltBase64, iterations, keyLen, hash); return masterKeyBase64; }; // Create a random password to local created keys createRandomPassword = async (server: string) => { - const password = await randomPassword(); + const password = await generatePassphrase(); UserPreferences.setString(`${server}-${E2E_RANDOM_PASSWORD_KEY}`, password); return password; }; @@ -309,25 +307,8 @@ class Encryption { } }; - evaluateSuggestedKey = async (rid: string, E2ESuggestedKey: string) => { - if (this.privateKey) { - try { - const roomE2E = await this.getRoomInstance(rid); - if (!roomE2E) { - return; - } - - try { - await roomE2E.importRoomKey(E2ESuggestedKey, this.privateKey); - } catch (error) { - await e2eRejectSuggestedGroupKey(rid); - return; - } - await e2eAcceptSuggestedGroupKey(rid); - } catch (e) { - console.error(e); - } - } + deleteRoomInstance = (rid: string) => { + delete this.roomInstances[rid]; }; // Logic to decrypt all pending messages/threads/threadMessages @@ -362,7 +343,7 @@ class Encryption { toDecrypt = (await Promise.all( toDecrypt.map(async message => { const { t, msg, tmsg, attachments, content } = message; - let newMessage: TMessageModel = {} as TMessageModel; + let newMessage: Partial = {}; if (message.subscription) { const { id: rid } = message.subscription; // WM Object -> Plain Object @@ -373,7 +354,7 @@ class Encryption { tmsg, attachments, content - }); + } as IMessage); } try { @@ -405,14 +386,24 @@ class Encryption { // Find all rooms that can have a lastMessage encrypted // If we select only encrypted rooms we can miss some room that changed their encrypted status const subsEncrypted = await subCollection.query(Q.where('e2e_key_id', Q.notEq(null)), Q.where('encrypted', true)).fetch(); + + /** + * Filter out subscriptions that already have their lastMessage decrypted. + * We fetch updated subscriptions from server and decrypt them later. + */ + const subsEncryptedToDecrypt = subsEncrypted.filter( + sub => sub.lastMessage?.t === E2E_MESSAGE_TYPE && sub.lastMessage?.e2e !== E2E_STATUS.DONE + ); + const preparedSubscriptions: (Model | null)[] = await Promise.all( - subsEncrypted.map(async (sub: TSubscriptionModel) => { - const { rid, lastMessage } = sub; - const newSub = await this.decryptSubscription({ rid, lastMessage }); + subsEncryptedToDecrypt.map(async (sub: TSubscriptionModel) => { + const newSub = await this.decryptSubscription(sub); try { return sub.prepareUpdate( protectedFunction((m: TSubscriptionModel) => { - Object.assign(m, newSub); + if (newSub?.lastMessage) { + m.lastMessage = newSub.lastMessage; + } }) ); } catch { @@ -525,94 +516,9 @@ class Encryption { // Decrypt a subscription lastMessage decryptSubscription = async (subscription: Partial) => { - // If the subscription doesn't have a lastMessage just return - if (!subscription?.lastMessage) { - return subscription; - } - - const { lastMessage } = subscription; - const { t, e2e } = lastMessage; - - // If it's not a encrypted message or was decrypted before - if (t !== E2E_MESSAGE_TYPE || e2e === E2E_STATUS.DONE) { - return subscription; - } - - // If the client is not ready - if (!this.ready) { - try { - // Wait for ready status - await this.establishing; - } catch { - // If it can't be initialized (missing password) - // return the encrypted message - return subscription; - } - } - const { rid } = subscription; - 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) { - // Let's create the subscription with the data received - batch.push( - subCollection.prepareCreate((s: TSubscriptionModel) => { - s._raw = sanitizedRaw({ id: rid }, subCollection.schema); - Object.assign(s, subscription); - }) - ); - // If the subscription already exists but doesn't have the E2EKey yet - } else if (!subRecord.E2EKey && subscription.E2EKey) { - try { - // Let's update the subscription with the received E2EKey - batch.push( - subRecord.prepareUpdate((s: TSubscriptionModel) => { - s.E2EKey = subscription.E2EKey; - }) - ); - } catch (e) { - log(e); - } - } - - // If batch has some operation - if (batch.length) { - await db.write(async () => { - await db.batch(batch); - }); - } - } catch { - // Abort the decryption process - // Return as received - return subscription; - } - - // 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, - lastMessage: decryptedMessage - }; - }; - - encryptText = async (rid: string, text: string) => { - const roomE2E = await this.getRoomInstance(rid); - if (!roomE2E || !roomE2E?.hasSessionKey()) { - return; - } - return roomE2E.encryptText(text); + return roomE2E?.decryptSubscription(subscription); }; // Encrypt a message @@ -631,12 +537,6 @@ class Encryption { return message; } - // If the client is not ready - if (!this.ready) { - // Wait for ready status - await this.establishing; - } - const roomE2E = await this.getRoomInstance(rid); if (!roomE2E || !roomE2E?.hasSessionKey()) { return; @@ -652,7 +552,7 @@ class Encryption { }; // Decrypt a message - decryptMessage = async (message: Pick) => { + decryptMessage = async (message: IMessage) => { const { t, e2e } = message; // Prevent create a new instance if this room was encrypted sometime ago @@ -660,18 +560,6 @@ class Encryption { return message; } - // If the client is not ready - if (!this.ready) { - try { - // Wait for ready status - await this.establishing; - } catch { - // If it can't be initialized (missing password) - // return the encrypted message - return message; - } - } - const { rid } = message; const roomE2E = await this.getRoomInstance(rid); if (!roomE2E || !roomE2E?.hasSessionKey()) { @@ -701,12 +589,6 @@ class Encryption { return { file }; } - // If the client is not ready - if (!this.ready) { - // Wait for ready status - await this.establishing; - } - const roomE2E = await this.getRoomInstance(rid); if (!roomE2E || !roomE2E?.hasSessionKey()) { return { file }; diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index 5f505018ccf..ff7dc5f5b35 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -1,6 +1,5 @@ import EJSON from 'ejson'; import { Base64 } from 'js-base64'; -import type ByteBuffer from 'bytebuffer'; import parse from 'url-parse'; import { sha256 } from 'js-sha256'; import { @@ -10,17 +9,21 @@ import { rsaEncrypt, aesEncrypt, calculateFileChecksum, - aesDecrypt + aesDecrypt, + aesGcmEncrypt, + aesGcmDecrypt, + randomUuid } from '@rocket.chat/mobile-crypto'; import getSingleMessage from '../methods/getSingleMessage'; -import { - type IAttachment, - type IMessage, - type IUpload, - type TSendFileMessageFileInfo, - type IServerAttachment, - type TSubscriptionModel +import type { + IAttachment, + IMessage, + IUpload, + TSendFileMessageFileInfo, + IServerAttachment, + TSubscriptionModel, + ISubscription } from '../../definitions'; import Deferred from './helpers/deferred'; import { compareServerVersion, debounce } from '../methods/helpers'; @@ -39,7 +42,9 @@ import { splitVectorData, toString, utf8ToBuffer, - bufferToHex + bufferToHex, + decodePrefixedBase64, + encodePrefixedBase64 } from './utils'; import { Encryption } from './index'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants/keys'; @@ -61,14 +66,17 @@ import { getMessageById } from '../database/services/Message'; import { type TEncryptFileResult, type TGetContent } from './definitions'; import { store } from '../store/auxStore'; +type TAlgorithm = 'A128CBC' | 'A256GCM' | ''; + export default class EncryptionRoom { ready: boolean; roomId: string; userId: string; establishing: boolean; readyPromise: Deferred; - sessionKeyExportedString: string | ByteBuffer; + sessionKeyExportedString: string; keyID: string; + algorithm: TAlgorithm; roomKey: ArrayBuffer; subscription: TSubscriptionModel | null; @@ -78,6 +86,7 @@ export default class EncryptionRoom { this.userId = userId; this.establishing = false; this.keyID = ''; + this.algorithm = ''; this.sessionKeyExportedString = ''; this.roomKey = new ArrayBuffer(0); this.readyPromise = new Deferred(); @@ -105,52 +114,82 @@ export default class EncryptionRoom { if (!this.subscription) { this.subscription = await getSubscriptionByRoomId(this.roomId); - if (!this.subscription) { + if (!this.subscription || !this.subscription.encrypted) { return; } } - // Similar to Encryption.evaluateSuggestedKey + // Redundant check to avoid multiple handshakes, because of the async sub fetch + if (this.establishing) { + return this.readyPromise; + } + + // Check if we have the user's private key to decrypt room keys + if (!Encryption.privateKey) { + // User hasn't entered E2E password yet + // Room will remain not ready until password is entered + return; + } + const { E2EKey, e2eKeyId, E2ESuggestedKey } = this.subscription; - if (E2ESuggestedKey && Encryption.privateKey) { + if (E2ESuggestedKey) { try { + this.establishing = true; + const { keyID, roomKey, sessionKeyExportedString, algorithm } = await this.importRoomKey( + E2ESuggestedKey, + Encryption.privateKey + ); + this.keyID = keyID; + this.roomKey = roomKey; + this.sessionKeyExportedString = sessionKeyExportedString; + this.algorithm = algorithm; try { - this.establishing = true; - const { keyID, roomKey, sessionKeyExportedString } = await this.importRoomKey(E2ESuggestedKey, Encryption.privateKey); - this.keyID = keyID; - this.roomKey = roomKey; - this.sessionKeyExportedString = sessionKeyExportedString; + await e2eAcceptSuggestedGroupKey(this.roomId); + Encryption.deleteRoomInstance(this.roomId); + return; } catch (error) { await e2eRejectSuggestedGroupKey(this.roomId); } - await e2eAcceptSuggestedGroupKey(this.roomId); - this.readyPromise.resolve(); - return; } catch (e) { log(e); } } // 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; + if (E2EKey) { + try { + this.establishing = true; + const { keyID, roomKey, sessionKeyExportedString, algorithm } = await this.importRoomKey(E2EKey, Encryption.privateKey); + this.keyID = keyID; + this.roomKey = roomKey; + this.sessionKeyExportedString = sessionKeyExportedString; + this.algorithm = algorithm; + this.readyPromise.resolve(); + return; + } catch (error) { + this.establishing = false; + log(error); + // Fall through to try other options + } } // 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; + try { + this.establishing = true; + await this.createRoomKey(); + Encryption.deleteRoomInstance(this.roomId); + return; + } catch (error) { + this.establishing = false; + log(error); + // Cannot create room key, room will remain not ready + } } // Request a E2EKey for this room to other users + // Room will remain not ready until the key is received via subscription update + // When E2EKey arrives, next handshake() call will succeed this.requestRoomKey(e2eKeyId); }; @@ -158,32 +197,24 @@ export default class EncryptionRoom { importRoomKey = async ( E2EKey: string, privateKey: string - ): Promise<{ sessionKeyExportedString: string | ByteBuffer; roomKey: ArrayBuffer; keyID: string }> => { + ): Promise<{ sessionKeyExportedString: string; roomKey: ArrayBuffer; keyID: string; algorithm: TAlgorithm }> => { try { - const roomE2EKey = E2EKey.slice(12); + // Parse the encrypted key using prefixed base64 + const [kid, encryptedData] = decodePrefixedBase64(E2EKey); - const decryptedKey = await rsaDecrypt(roomE2EKey, privateKey); + // Decrypt the session key + const decryptedKey = await rsaDecrypt(bufferToB64(encryptedData), privateKey); const sessionKeyExportedString = toString(decryptedKey); - let keyID = ''; - const { version } = store.getState().server; - if (compareServerVersion(version, 'greaterThanOrEqualTo', '7.0.0')) { - keyID = (await sha256(sessionKeyExportedString as string)).slice(0, 12); - } else { - keyID = Base64.encode(sessionKeyExportedString as string).slice(0, 12); - } - - // Extract K from Web Crypto Secret Key - // K is a base64URL encoded array of bytes - // Web Crypto API uses this as a private key to decrypt/encrypt things - // Reference: https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/5.1/com/nimbusds/jose/jwk/OctetSequenceKey.html - const { k } = EJSON.parse(sessionKeyExportedString as string); - const roomKey = b64ToBuffer(k); + const keyID = kid; + const parsedKey = EJSON.parse(sessionKeyExportedString); + const roomKey: ArrayBuffer = b64ToBuffer(parsedKey.k); return { sessionKeyExportedString, roomKey, - keyID + keyID, + algorithm: parsedKey.alg }; } catch (e: any) { throw new Error(e); @@ -193,9 +224,30 @@ export default class EncryptionRoom { hasSessionKey = () => !!this.sessionKeyExportedString; createNewRoomKey = async () => { - const key = b64ToBuffer(await randomBytes(16)); - this.roomKey = key; + const { version } = store.getState().server; + // v2 + if (compareServerVersion(version, 'greaterThanOrEqualTo', '7.13.0')) { + this.roomKey = b64ToBuffer(await randomBytes(32)); + // Web Crypto format of a Secret Key + const sessionKeyExported = { + // Type of Secret Key + kty: 'oct', + // Algorithm + alg: 'A256GCM', + // Base64URI encoded array of bytes + k: bufferToB64URI(this.roomKey), + // Specific Web Crypto properties + ext: true, + key_ops: ['encrypt', 'decrypt'] + }; + this.keyID = await randomUuid(); + this.sessionKeyExportedString = EJSON.stringify(sessionKeyExported); + this.algorithm = 'A256GCM'; + return; + } + // v1 + this.roomKey = b64ToBuffer(await randomBytes(16)); // Web Crypto format of a Secret Key const sessionKeyExported = { // Type of Secret Key @@ -210,8 +262,8 @@ export default class EncryptionRoom { }; this.sessionKeyExportedString = EJSON.stringify(sessionKeyExported); + this.algorithm = 'A128CBC'; - const { version } = store.getState().server; if (compareServerVersion(version, 'greaterThanOrEqualTo', '7.0.0')) { this.keyID = (await sha256(this.sessionKeyExportedString as string)).slice(0, 12); } else { @@ -301,7 +353,8 @@ export default class EncryptionRoom { try { const userKey = await rsaImportKey(EJSON.parse(publicKey)); const encryptedUserKey = await rsaEncrypt(this.sessionKeyExportedString as string, userKey); - return this.keyID + encryptedUserKey; + const encryptedBuffer = b64ToBuffer(encryptedUserKey as string); + return encodePrefixedBase64(this.keyID, encryptedBuffer); } catch (e) { log(e); } @@ -391,44 +444,54 @@ export default class EncryptionRoom { return usersWithKeys; } - // Encrypt text - encryptText = async (text: string | ArrayBuffer) => { - text = utf8ToBuffer(text as string); - const vector = b64ToBuffer(await randomBytes(16)); - const data = b64ToBuffer(await aesEncrypt(bufferToB64(text), bufferToHex(this.roomKey), bufferToHex(vector))); + // Encrypt text - returns structured object with algorithm + encryptText = async ( + text: string + ): Promise< + | { algorithm: 'rc.v2.aes-sha2'; kid: string; iv: string; ciphertext: string } + | { algorithm: 'rc.v1.aes-sha2'; ciphertext: string } + > => { + const textBuffer = utf8ToBuffer(text); + if (this.algorithm === 'A256GCM') { + const vectorBase64 = await randomBytes(12); + const vector = b64ToBuffer(vectorBase64); + const data = await aesGcmEncrypt(bufferToB64(textBuffer), bufferToHex(this.roomKey), bufferToHex(vector)); + return { + algorithm: 'rc.v2.aes-sha2' as const, + kid: this.keyID, + iv: vectorBase64, + ciphertext: data + }; + } - return this.keyID + bufferToB64(joinVectorData(vector, data)); + const vectorBase64 = await randomBytes(16); + const vector = b64ToBuffer(vectorBase64); + const data = b64ToBuffer(await aesEncrypt(bufferToB64(textBuffer), bufferToHex(this.roomKey), bufferToHex(vector))); + return { + algorithm: 'rc.v1.aes-sha2' as const, + ciphertext: this.keyID + bufferToB64(joinVectorData(vector, data)) + }; }; // Encrypt messages - encrypt = async (message: IMessage) => { + encrypt = async (message: IMessage): Promise => { if (!this.ready) { return message; } try { - const msg = await this.encryptText( - EJSON.stringify({ - _id: message._id, - text: message.msg, - userId: this.userId, - ts: new Date() - }) - ); + const content = await this.encryptText(EJSON.stringify({ msg: message.msg || '' })); return { ...message, t: E2E_MESSAGE_TYPE, e2e: E2E_STATUS.PENDING, - msg, e2eMentions: getE2EEMentions(message.msg), - content: { - algorithm: 'rc.v1.aes-sha2' as const, - ciphertext: msg - } + content }; - } catch { + } catch (e) { // Do nothing + console.error(e); } return message; @@ -444,7 +507,11 @@ export default class EncryptionRoom { let description = ''; if (message.description) { - description = await this.encryptText(EJSON.stringify({ text: message.description })); + const encryptedResult = await this.encryptText(EJSON.stringify({ msg: message.description })); + description = + encryptedResult.algorithm === 'rc.v1.aes-sha2' + ? encryptedResult.ciphertext + : EJSON.stringify({ kid: encryptedResult.kid, iv: encryptedResult.iv, ciphertext: encryptedResult.ciphertext }); } return { @@ -537,10 +604,8 @@ export default class EncryptionRoom { file: files[0] }); - return { - algorithm: 'rc.v1.aes-sha2', - ciphertext: await Encryption.encryptText(rid, data) - }; + const encryptedResult = await this.encryptText(data); + return encryptedResult; }; const fileContentData = { @@ -556,10 +621,7 @@ export default class EncryptionRoom { } }; - const fileContent = { - algorithm: 'rc.v1.aes-sha2' as const, - ciphertext: await Encryption.encryptText(rid, EJSON.stringify(fileContentData)) - }; + const fileContent = await this.encryptText(EJSON.stringify(fileContentData)); return { file: { @@ -573,44 +635,70 @@ export default class EncryptionRoom { }; }; - // Decrypt text - decryptText = async (msg: string | ArrayBuffer) => { - if (!msg) { - return null; - } - - const m = await this.decryptContent(msg as string); - return m.text; - }; - decryptFileContent = async (data: IServerAttachment) => { - if (data.content?.algorithm === 'rc.v1.aes-sha2') { - const content = await this.decryptContent(data.content.ciphertext); + if (data.content?.algorithm === 'rc.v1.aes-sha2' || data.content?.algorithm === 'rc.v2.aes-sha2') { + const content = await this.decryptContent(data.content); Object.assign(data, content); } return data; }; - decryptContent = async (contentBase64: string) => { - if (!contentBase64) { + parse = ( + payload: string | IMessage['content'] + ): { + kid: string; + iv: ArrayBuffer; + ciphertext: string; + } => { + // v2: {"kid":"...", "iv": "...", "ciphertext":"..."} + if (typeof payload !== 'string' && payload?.algorithm === 'rc.v2.aes-sha2') { + return { kid: payload.kid, iv: b64ToBuffer(payload.iv), ciphertext: payload.ciphertext }; + } + // v1: kid + base64(vector + ciphertext) + const message = typeof payload === 'string' ? payload : payload?.ciphertext || ''; + const kid = message.slice(0, 12); + const contentBuffer = b64ToBuffer(message.slice(12) as string); + const [iv, ciphertext] = splitVectorData(contentBuffer); + return { kid, iv, ciphertext: bufferToB64(ciphertext) }; + }; + + doDecrypt = async (ciphertext: string, key: ArrayBuffer, iv: ArrayBuffer, algorithm: TAlgorithm) => { + const keyHex = bufferToHex(key); + const ivHex = bufferToHex(iv); + let decrypted; + if (algorithm === 'A256GCM') { + decrypted = await aesGcmDecrypt(ciphertext, keyHex, ivHex); + } else { + decrypted = await aesDecrypt(ciphertext, keyHex, ivHex); + } + if (!decrypted) { return null; } + return EJSON.parse(bufferToUtf8(b64ToBuffer(decrypted))); + }; + + decryptContent = async (content: IMessage['content'] | string) => { + try { + if (!content) { + return null; + } - const keyID = contentBase64.slice(0, 12); - const contentBuffer = b64ToBuffer(contentBase64.slice(12) as string); - const [vector, cipherText] = splitVectorData(contentBuffer); + const { kid, iv, ciphertext } = this.parse(content); - 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; + if (kid !== this.keyID) { + const oldRoomKey = this.subscription?.oldRoomKeys?.find((key: any) => key.e2eKeyId === kid); + if (oldRoomKey?.E2EKey && Encryption.privateKey) { + const { roomKey, algorithm } = await this.importRoomKey(oldRoomKey.E2EKey, Encryption.privateKey); + return this.doDecrypt(ciphertext, roomKey, iv, algorithm); + } + return null; } - } - const decrypted = await aesDecrypt(bufferToB64(cipherText), bufferToHex(oldKey || this.roomKey), bufferToHex(vector)); - return EJSON.parse(bufferToUtf8(b64ToBuffer(decrypted))); + return this.doDecrypt(ciphertext, this.roomKey, iv, this.algorithm); + } catch (error) { + console.error(error); + return null; + } }; // Decrypt messages @@ -624,35 +712,25 @@ export default class EncryptionRoom { // If message type is e2e and it's encrypted still if (t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE) { - const { msg, tmsg } = message; - // Decrypt msg - if (msg) { - message.msg = await this.decryptText(msg); - } - - // Decrypt tmsg - if (tmsg) { - message.tmsg = await this.decryptText(tmsg); - } - - if (message.content?.algorithm === 'rc.v1.aes-sha2') { - const content = await this.decryptContent(message.content.ciphertext); - message = { - ...message, - ...content, - attachments: content.attachments?.map((att: IAttachment) => ({ - ...att, - e2e: 'pending' - })) - }; + const content = await this.decryptContent(message.content || message.msg); + message = { + ...message, + ...content, + attachments: content.attachments?.map((att: IAttachment) => ({ + ...att, + e2e: 'pending' + })) + }; + if (content.text) { + message.msg = content.text; } - const decryptedMessage: IMessage = { + const decryptedMessage = { ...message, e2e: 'done' }; - const decryptedMessageWithQuote = await this.decryptQuoteAttachment(decryptedMessage); + const decryptedMessageWithQuote = await this.decryptQuoteAttachment(decryptedMessage as IMessage); return decryptedMessageWithQuote; } } catch { @@ -688,10 +766,57 @@ export default class EncryptionRoom { } const decryptedQuoteMessage = await this.decrypt(mapMessageFromAPI(quotedMessageObject)); message.attachments = message.attachments || []; - const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage, url); + const quoteAttachment = createQuoteAttachment(decryptedQuoteMessage as IMessage, url); return message.attachments.push(quoteAttachment); }) ); return message; } + + decryptSubscription = async (subscription: Partial) => { + if (!this.ready) { + return subscription; + } + + // If the subscription doesn't have a lastMessage just return + const { rid, lastMessage } = subscription; + if (!lastMessage) { + return subscription; + } + + const { t, e2e } = lastMessage; + + // If it's not an encrypted message + if (t !== E2E_MESSAGE_TYPE) { + return subscription; + } + + // If already marked as decrypted in the incoming data + if (e2e === E2E_STATUS.DONE) { + return subscription; + } + + if (!rid) { + return subscription; + } + + if ( + this.subscription?.lastMessage?._updatedAt && + lastMessage?._updatedAt && + new Date(this.subscription.lastMessage._updatedAt).getTime() === new Date(lastMessage._updatedAt).getTime() && + this.subscription?.lastMessage?.e2e === E2E_STATUS.DONE + ) { + // Same message already decrypted in DB, return subscription with DB's decrypted version + return { + ...subscription, + lastMessage: this.subscription?.lastMessage + }; + } + + const decryptedMessage = await this.decrypt(lastMessage as IMessage); + return { + ...subscription, + lastMessage: decryptedMessage + }; + }; } diff --git a/app/lib/encryption/utils.ts b/app/lib/encryption/utils.ts index 4f3eb8ccf95..3905816ac2a 100644 --- a/app/lib/encryption/utils.ts +++ b/app/lib/encryption/utils.ts @@ -1,5 +1,5 @@ import ByteBuffer from 'bytebuffer'; -import { aesDecryptFile, aesEncryptFile, getRandomValues, randomBytes } from '@rocket.chat/mobile-crypto'; +import { aesDecryptFile, aesEncryptFile, randomBytes } from '@rocket.chat/mobile-crypto'; import { compareServerVersion } from '../methods/helpers'; import { fromByteArray, toByteArray } from './helpers/base64-js'; @@ -77,7 +77,7 @@ export const joinVectorData = (vector: ArrayBuffer, data: ArrayBuffer): ArrayBuf return output.buffer; }; -export const toString = (thing: string | ByteBuffer | Buffer | ArrayBuffer | Uint8Array): string | ByteBuffer => { +export const toString = (thing: string | ByteBuffer | Buffer | ArrayBuffer | Uint8Array): string => { if (typeof thing === 'string') { return thing; } @@ -102,11 +102,6 @@ export const getE2EEMentions = (message?: string) => { }; }; -export const randomPassword = async (): Promise => { - const random = await Promise.all(Array.from({ length: 4 }, () => getRandomValues(3))); - return `${random[0]}-${random[1]}-${random[2]}-${random[3]}`; -}; - export const generateAESCTRKey = () => randomBytes(32); interface IExportedKey { @@ -197,6 +192,89 @@ export const hasE2EEWarning = ({ }; // https://github.com/RocketChat/Rocket.Chat/blob/7a57f3452fd26a603948b70af8f728953afee53f/apps/meteor/lib/utils/getFileExtension.ts#L1 +// A 256-byte array always encodes to 344 characters in Base64. +const DECODED_LENGTH = 256; +// ((4 * 256 / 3) + 3) & ~3 = 344 +const ENCODED_LENGTH = 344; + +export const decodePrefixedBase64 = (input: string): [prefix: string, data: ArrayBuffer] => { + // 1. Validate the input string length + if (input.length < ENCODED_LENGTH) { + throw new RangeError('Invalid input length.'); + } + + // 2. Split the string into its two parts + const prefix = input.slice(0, -ENCODED_LENGTH); + const base64Data = input.slice(-ENCODED_LENGTH); + + // 3. Decode the Base64 string + const bytes = b64ToBuffer(base64Data); + + if (bytes.byteLength !== DECODED_LENGTH) { + // This is a sanity check in case the Base64 string was valid but didn't decode to 256 bytes. + throw new RangeError('Decoded data length is too short.'); + } + + return [prefix, bytes]; +}; + +export const encodePrefixedBase64 = (prefix: string, data: ArrayBuffer): string => { + // 1. Validate the input data length + if (data.byteLength !== DECODED_LENGTH) { + throw new RangeError(`Input data length is ${data.byteLength}, but expected ${DECODED_LENGTH} bytes.`); + } + + // 2. Convert the byte array to base64 + const base64Data = bufferToB64(data); + + if (base64Data.length !== ENCODED_LENGTH) { + // This is a sanity check in case something went wrong during encoding. + throw new RangeError(`Encoded Base64 length is ${base64Data.length}, but expected ${ENCODED_LENGTH} characters.`); + } + + // 3. Concatenate the prefix and the Base64 string + return prefix + base64Data; +}; + +export const parsePrivateKey = ( + privateKey: string, + userId: string +): { iv: ArrayBuffer; ciphertext: ArrayBuffer; salt: string; iterations: number; version: 'v1' | 'v2' } => { + const json: unknown = JSON.parse(privateKey); + if (typeof json !== 'object' || json === null) { + throw new TypeError('Invalid private key format'); + } + + const salt = 'salt' in json && typeof json.salt === 'string' ? json.salt : userId; + + if ( + 'iv' in json && + 'ciphertext' in json && + typeof json.iv === 'string' && + typeof json.ciphertext === 'string' && + 'iterations' in json && + typeof json.iterations === 'number' + ) { + // v2: { iv: base64, ciphertext: base64, salt: string } + return { + iv: b64ToBuffer(json.iv), + ciphertext: b64ToBuffer(json.ciphertext), + salt, + iterations: json.iterations, + version: 'v2' + }; + } + + if ('$binary' in json && typeof json.$binary === 'string') { + // v1: { $binary: base64(iv[16] + ciphertext) } + const binary = b64ToBuffer(json.$binary); + const [iv, ciphertext] = splitVectorData(binary); + return { iv, ciphertext, salt, iterations: 1000, version: 'v1' }; + } + + throw new TypeError('Invalid private key format'); +}; + export const getFileExtension = (fileName?: string): string => { if (!fileName) { return 'file'; @@ -210,3 +288,42 @@ export const getFileExtension = (fileName?: string): string => { return arr.pop()?.toLocaleUpperCase() || 'file'; }; + +/** + * Generates 12 uniformly random words from the word list. + * + * @remarks + * Uses {@link https://en.wikipedia.org/wiki/Rejection_sampling | rejection sampling} to ensure uniform distribution. + * + * @returns A space-separated passphrase. + */ +export async function generatePassphrase() { + const { wordlist } = await import('./wordList'); + + const WORD_COUNT = 12; + const MAX_UINT32 = 0xffffffff; + const range = wordlist.length; // 2048 + const rejectionThreshold = Math.floor(MAX_UINT32 / range) * range; + + const words: string[] = []; + + for (let i = 0; i < WORD_COUNT; i++) { + let v: number; + do { + // eslint-disable-next-line no-await-in-loop + const randomBase64 = await randomBytes(4); + const randomBuffer = b64ToBuffer(randomBase64); + const randomArray = new Uint8Array(randomBuffer); + + // Combine 4 bytes into 32-bit big-endian integer + v = (randomArray[0] << 24) | (randomArray[1] << 16) | (randomArray[2] << 8) | randomArray[3]; + + // Convert to unsigned 32-bit (JavaScript numbers are signed) + v >>>= 0; + } while (v >= rejectionThreshold); + + words.push(wordlist[v % range]); + } + + return words.join(' '); +} diff --git a/app/lib/encryption/wordList.ts b/app/lib/encryption/wordList.ts new file mode 100644 index 00000000000..85918802502 --- /dev/null +++ b/app/lib/encryption/wordList.ts @@ -0,0 +1,2054 @@ +/** + * The BIP-39 English word list. + * {@link https://raw.githubusercontent.com/bitcoin/bips/master/bip-0039/english.txt} + */ +export const wordlist = [ + 'abandon', + 'ability', + 'able', + 'about', + 'above', + 'absent', + 'absorb', + 'abstract', + 'absurd', + 'abuse', + 'access', + 'accident', + 'account', + 'accuse', + 'achieve', + 'acid', + 'acoustic', + 'acquire', + 'across', + 'act', + 'action', + 'actor', + 'actress', + 'actual', + 'adapt', + 'add', + 'addict', + 'address', + 'adjust', + 'admit', + 'adult', + 'advance', + 'advice', + 'aerobic', + 'affair', + 'afford', + 'afraid', + 'again', + 'age', + 'agent', + 'agree', + 'ahead', + 'aim', + 'air', + 'airport', + 'aisle', + 'alarm', + 'album', + 'alcohol', + 'alert', + 'alien', + 'all', + 'alley', + 'allow', + 'almost', + 'alone', + 'alpha', + 'already', + 'also', + 'alter', + 'always', + 'amateur', + 'amazing', + 'among', + 'amount', + 'amused', + 'analyst', + 'anchor', + 'ancient', + 'anger', + 'angle', + 'angry', + 'animal', + 'ankle', + 'announce', + 'annual', + 'another', + 'answer', + 'antenna', + 'antique', + 'anxiety', + 'any', + 'apart', + 'apology', + 'appear', + 'apple', + 'approve', + 'april', + 'arch', + 'arctic', + 'area', + 'arena', + 'argue', + 'arm', + 'armed', + 'armor', + 'army', + 'around', + 'arrange', + 'arrest', + 'arrive', + 'arrow', + 'art', + 'artefact', + 'artist', + 'artwork', + 'ask', + 'aspect', + 'assault', + 'asset', + 'assist', + 'assume', + 'asthma', + 'athlete', + 'atom', + 'attack', + 'attend', + 'attitude', + 'attract', + 'auction', + 'audit', + 'august', + 'aunt', + 'author', + 'auto', + 'autumn', + 'average', + 'avocado', + 'avoid', + 'awake', + 'aware', + 'away', + 'awesome', + 'awful', + 'awkward', + 'axis', + 'baby', + 'bachelor', + 'bacon', + 'badge', + 'bag', + 'balance', + 'balcony', + 'ball', + 'bamboo', + 'banana', + 'banner', + 'bar', + 'barely', + 'bargain', + 'barrel', + 'base', + 'basic', + 'basket', + 'battle', + 'beach', + 'bean', + 'beauty', + 'because', + 'become', + 'beef', + 'before', + 'begin', + 'behave', + 'behind', + 'believe', + 'below', + 'belt', + 'bench', + 'benefit', + 'best', + 'betray', + 'better', + 'between', + 'beyond', + 'bicycle', + 'bid', + 'bike', + 'bind', + 'biology', + 'bird', + 'birth', + 'bitter', + 'black', + 'blade', + 'blame', + 'blanket', + 'blast', + 'bleak', + 'bless', + 'blind', + 'blood', + 'blossom', + 'blouse', + 'blue', + 'blur', + 'blush', + 'board', + 'boat', + 'body', + 'boil', + 'bomb', + 'bone', + 'bonus', + 'book', + 'boost', + 'border', + 'boring', + 'borrow', + 'boss', + 'bottom', + 'bounce', + 'box', + 'boy', + 'bracket', + 'brain', + 'brand', + 'brass', + 'brave', + 'bread', + 'breeze', + 'brick', + 'bridge', + 'brief', + 'bright', + 'bring', + 'brisk', + 'broccoli', + 'broken', + 'bronze', + 'broom', + 'brother', + 'brown', + 'brush', + 'bubble', + 'buddy', + 'budget', + 'buffalo', + 'build', + 'bulb', + 'bulk', + 'bullet', + 'bundle', + 'bunker', + 'burden', + 'burger', + 'burst', + 'bus', + 'business', + 'busy', + 'butter', + 'buyer', + 'buzz', + 'cabbage', + 'cabin', + 'cable', + 'cactus', + 'cage', + 'cake', + 'call', + 'calm', + 'camera', + 'camp', + 'can', + 'canal', + 'cancel', + 'candy', + 'cannon', + 'canoe', + 'canvas', + 'canyon', + 'capable', + 'capital', + 'captain', + 'car', + 'carbon', + 'card', + 'cargo', + 'carpet', + 'carry', + 'cart', + 'case', + 'cash', + 'casino', + 'castle', + 'casual', + 'cat', + 'catalog', + 'catch', + 'category', + 'cattle', + 'caught', + 'cause', + 'caution', + 'cave', + 'ceiling', + 'celery', + 'cement', + 'census', + 'century', + 'cereal', + 'certain', + 'chair', + 'chalk', + 'champion', + 'change', + 'chaos', + 'chapter', + 'charge', + 'chase', + 'chat', + 'cheap', + 'check', + 'cheese', + 'chef', + 'cherry', + 'chest', + 'chicken', + 'chief', + 'child', + 'chimney', + 'choice', + 'choose', + 'chronic', + 'chuckle', + 'chunk', + 'churn', + 'cigar', + 'cinnamon', + 'circle', + 'citizen', + 'city', + 'civil', + 'claim', + 'clap', + 'clarify', + 'claw', + 'clay', + 'clean', + 'clerk', + 'clever', + 'click', + 'client', + 'cliff', + 'climb', + 'clinic', + 'clip', + 'clock', + 'clog', + 'close', + 'cloth', + 'cloud', + 'clown', + 'club', + 'clump', + 'cluster', + 'clutch', + 'coach', + 'coast', + 'coconut', + 'code', + 'coffee', + 'coil', + 'coin', + 'collect', + 'color', + 'column', + 'combine', + 'come', + 'comfort', + 'comic', + 'common', + 'company', + 'concert', + 'conduct', + 'confirm', + 'congress', + 'connect', + 'consider', + 'control', + 'convince', + 'cook', + 'cool', + 'copper', + 'copy', + 'coral', + 'core', + 'corn', + 'correct', + 'cost', + 'cotton', + 'couch', + 'country', + 'couple', + 'course', + 'cousin', + 'cover', + 'coyote', + 'crack', + 'cradle', + 'craft', + 'cram', + 'crane', + 'crash', + 'crater', + 'crawl', + 'crazy', + 'cream', + 'credit', + 'creek', + 'crew', + 'cricket', + 'crime', + 'crisp', + 'critic', + 'crop', + 'cross', + 'crouch', + 'crowd', + 'crucial', + 'cruel', + 'cruise', + 'crumble', + 'crunch', + 'crush', + 'cry', + 'crystal', + 'cube', + 'culture', + 'cup', + 'cupboard', + 'curious', + 'current', + 'curtain', + 'curve', + 'cushion', + 'custom', + 'cute', + 'cycle', + 'dad', + 'damage', + 'damp', + 'dance', + 'danger', + 'daring', + 'dash', + 'daughter', + 'dawn', + 'day', + 'deal', + 'debate', + 'debris', + 'decade', + 'december', + 'decide', + 'decline', + 'decorate', + 'decrease', + 'deer', + 'defense', + 'define', + 'defy', + 'degree', + 'delay', + 'deliver', + 'demand', + 'demise', + 'denial', + 'dentist', + 'deny', + 'depart', + 'depend', + 'deposit', + 'depth', + 'deputy', + 'derive', + 'describe', + 'desert', + 'design', + 'desk', + 'despair', + 'destroy', + 'detail', + 'detect', + 'develop', + 'device', + 'devote', + 'diagram', + 'dial', + 'diamond', + 'diary', + 'dice', + 'diesel', + 'diet', + 'differ', + 'digital', + 'dignity', + 'dilemma', + 'dinner', + 'dinosaur', + 'direct', + 'dirt', + 'disagree', + 'discover', + 'disease', + 'dish', + 'dismiss', + 'disorder', + 'display', + 'distance', + 'divert', + 'divide', + 'divorce', + 'dizzy', + 'doctor', + 'document', + 'dog', + 'doll', + 'dolphin', + 'domain', + 'donate', + 'donkey', + 'donor', + 'door', + 'dose', + 'double', + 'dove', + 'draft', + 'dragon', + 'drama', + 'drastic', + 'draw', + 'dream', + 'dress', + 'drift', + 'drill', + 'drink', + 'drip', + 'drive', + 'drop', + 'drum', + 'dry', + 'duck', + 'dumb', + 'dune', + 'during', + 'dust', + 'dutch', + 'duty', + 'dwarf', + 'dynamic', + 'eager', + 'eagle', + 'early', + 'earn', + 'earth', + 'easily', + 'east', + 'easy', + 'echo', + 'ecology', + 'economy', + 'edge', + 'edit', + 'educate', + 'effort', + 'egg', + 'eight', + 'either', + 'elbow', + 'elder', + 'electric', + 'elegant', + 'element', + 'elephant', + 'elevator', + 'elite', + 'else', + 'embark', + 'embody', + 'embrace', + 'emerge', + 'emotion', + 'employ', + 'empower', + 'empty', + 'enable', + 'enact', + 'end', + 'endless', + 'endorse', + 'enemy', + 'energy', + 'enforce', + 'engage', + 'engine', + 'enhance', + 'enjoy', + 'enlist', + 'enough', + 'enrich', + 'enroll', + 'ensure', + 'enter', + 'entire', + 'entry', + 'envelope', + 'episode', + 'equal', + 'equip', + 'era', + 'erase', + 'erode', + 'erosion', + 'error', + 'erupt', + 'escape', + 'essay', + 'essence', + 'estate', + 'eternal', + 'ethics', + 'evidence', + 'evil', + 'evoke', + 'evolve', + 'exact', + 'example', + 'excess', + 'exchange', + 'excite', + 'exclude', + 'excuse', + 'execute', + 'exercise', + 'exhaust', + 'exhibit', + 'exile', + 'exist', + 'exit', + 'exotic', + 'expand', + 'expect', + 'expire', + 'explain', + 'expose', + 'express', + 'extend', + 'extra', + 'eye', + 'eyebrow', + 'fabric', + 'face', + 'faculty', + 'fade', + 'faint', + 'faith', + 'fall', + 'false', + 'fame', + 'family', + 'famous', + 'fan', + 'fancy', + 'fantasy', + 'farm', + 'fashion', + 'fat', + 'fatal', + 'father', + 'fatigue', + 'fault', + 'favorite', + 'feature', + 'february', + 'federal', + 'fee', + 'feed', + 'feel', + 'female', + 'fence', + 'festival', + 'fetch', + 'fever', + 'few', + 'fiber', + 'fiction', + 'field', + 'figure', + 'file', + 'film', + 'filter', + 'final', + 'find', + 'fine', + 'finger', + 'finish', + 'fire', + 'firm', + 'first', + 'fiscal', + 'fish', + 'fit', + 'fitness', + 'fix', + 'flag', + 'flame', + 'flash', + 'flat', + 'flavor', + 'flee', + 'flight', + 'flip', + 'float', + 'flock', + 'floor', + 'flower', + 'fluid', + 'flush', + 'fly', + 'foam', + 'focus', + 'fog', + 'foil', + 'fold', + 'follow', + 'food', + 'foot', + 'force', + 'forest', + 'forget', + 'fork', + 'fortune', + 'forum', + 'forward', + 'fossil', + 'foster', + 'found', + 'fox', + 'fragile', + 'frame', + 'frequent', + 'fresh', + 'friend', + 'fringe', + 'frog', + 'front', + 'frost', + 'frown', + 'frozen', + 'fruit', + 'fuel', + 'fun', + 'funny', + 'furnace', + 'fury', + 'future', + 'gadget', + 'gain', + 'galaxy', + 'gallery', + 'game', + 'gap', + 'garage', + 'garbage', + 'garden', + 'garlic', + 'garment', + 'gas', + 'gasp', + 'gate', + 'gather', + 'gauge', + 'gaze', + 'general', + 'genius', + 'genre', + 'gentle', + 'genuine', + 'gesture', + 'ghost', + 'giant', + 'gift', + 'giggle', + 'ginger', + 'giraffe', + 'girl', + 'give', + 'glad', + 'glance', + 'glare', + 'glass', + 'glide', + 'glimpse', + 'globe', + 'gloom', + 'glory', + 'glove', + 'glow', + 'glue', + 'goat', + 'goddess', + 'gold', + 'good', + 'goose', + 'gorilla', + 'gospel', + 'gossip', + 'govern', + 'gown', + 'grab', + 'grace', + 'grain', + 'grant', + 'grape', + 'grass', + 'gravity', + 'great', + 'green', + 'grid', + 'grief', + 'grit', + 'grocery', + 'group', + 'grow', + 'grunt', + 'guard', + 'guess', + 'guide', + 'guilt', + 'guitar', + 'gun', + 'gym', + 'habit', + 'hair', + 'half', + 'hammer', + 'hamster', + 'hand', + 'happy', + 'harbor', + 'hard', + 'harsh', + 'harvest', + 'hat', + 'have', + 'hawk', + 'hazard', + 'head', + 'health', + 'heart', + 'heavy', + 'hedgehog', + 'height', + 'hello', + 'helmet', + 'help', + 'hen', + 'hero', + 'hidden', + 'high', + 'hill', + 'hint', + 'hip', + 'hire', + 'history', + 'hobby', + 'hockey', + 'hold', + 'hole', + 'holiday', + 'hollow', + 'home', + 'honey', + 'hood', + 'hope', + 'horn', + 'horror', + 'horse', + 'hospital', + 'host', + 'hotel', + 'hour', + 'hover', + 'hub', + 'huge', + 'human', + 'humble', + 'humor', + 'hundred', + 'hungry', + 'hunt', + 'hurdle', + 'hurry', + 'hurt', + 'husband', + 'hybrid', + 'ice', + 'icon', + 'idea', + 'identify', + 'idle', + 'ignore', + 'ill', + 'illegal', + 'illness', + 'image', + 'imitate', + 'immense', + 'immune', + 'impact', + 'impose', + 'improve', + 'impulse', + 'inch', + 'include', + 'income', + 'increase', + 'index', + 'indicate', + 'indoor', + 'industry', + 'infant', + 'inflict', + 'inform', + 'inhale', + 'inherit', + 'initial', + 'inject', + 'injury', + 'inmate', + 'inner', + 'innocent', + 'input', + 'inquiry', + 'insane', + 'insect', + 'inside', + 'inspire', + 'install', + 'intact', + 'interest', + 'into', + 'invest', + 'invite', + 'involve', + 'iron', + 'island', + 'isolate', + 'issue', + 'item', + 'ivory', + 'jacket', + 'jaguar', + 'jar', + 'jazz', + 'jealous', + 'jeans', + 'jelly', + 'jewel', + 'job', + 'join', + 'joke', + 'journey', + 'joy', + 'judge', + 'juice', + 'jump', + 'jungle', + 'junior', + 'junk', + 'just', + 'kangaroo', + 'keen', + 'keep', + 'ketchup', + 'key', + 'kick', + 'kid', + 'kidney', + 'kind', + 'kingdom', + 'kiss', + 'kit', + 'kitchen', + 'kite', + 'kitten', + 'kiwi', + 'knee', + 'knife', + 'knock', + 'know', + 'lab', + 'label', + 'labor', + 'ladder', + 'lady', + 'lake', + 'lamp', + 'language', + 'laptop', + 'large', + 'later', + 'latin', + 'laugh', + 'laundry', + 'lava', + 'law', + 'lawn', + 'lawsuit', + 'layer', + 'lazy', + 'leader', + 'leaf', + 'learn', + 'leave', + 'lecture', + 'left', + 'leg', + 'legal', + 'legend', + 'leisure', + 'lemon', + 'lend', + 'length', + 'lens', + 'leopard', + 'lesson', + 'letter', + 'level', + 'liar', + 'liberty', + 'library', + 'license', + 'life', + 'lift', + 'light', + 'like', + 'limb', + 'limit', + 'link', + 'lion', + 'liquid', + 'list', + 'little', + 'live', + 'lizard', + 'load', + 'loan', + 'lobster', + 'local', + 'lock', + 'logic', + 'lonely', + 'long', + 'loop', + 'lottery', + 'loud', + 'lounge', + 'love', + 'loyal', + 'lucky', + 'luggage', + 'lumber', + 'lunar', + 'lunch', + 'luxury', + 'lyrics', + 'machine', + 'mad', + 'magic', + 'magnet', + 'maid', + 'mail', + 'main', + 'major', + 'make', + 'mammal', + 'man', + 'manage', + 'mandate', + 'mango', + 'mansion', + 'manual', + 'maple', + 'marble', + 'march', + 'margin', + 'marine', + 'market', + 'marriage', + 'mask', + 'mass', + 'master', + 'match', + 'material', + 'math', + 'matrix', + 'matter', + 'maximum', + 'maze', + 'meadow', + 'mean', + 'measure', + 'meat', + 'mechanic', + 'medal', + 'media', + 'melody', + 'melt', + 'member', + 'memory', + 'mention', + 'menu', + 'mercy', + 'merge', + 'merit', + 'merry', + 'mesh', + 'message', + 'metal', + 'method', + 'middle', + 'midnight', + 'milk', + 'million', + 'mimic', + 'mind', + 'minimum', + 'minor', + 'minute', + 'miracle', + 'mirror', + 'misery', + 'miss', + 'mistake', + 'mix', + 'mixed', + 'mixture', + 'mobile', + 'model', + 'modify', + 'mom', + 'moment', + 'monitor', + 'monkey', + 'monster', + 'month', + 'moon', + 'moral', + 'more', + 'morning', + 'mosquito', + 'mother', + 'motion', + 'motor', + 'mountain', + 'mouse', + 'move', + 'movie', + 'much', + 'muffin', + 'mule', + 'multiply', + 'muscle', + 'museum', + 'mushroom', + 'music', + 'must', + 'mutual', + 'myself', + 'mystery', + 'myth', + 'naive', + 'name', + 'napkin', + 'narrow', + 'nasty', + 'nation', + 'nature', + 'near', + 'neck', + 'need', + 'negative', + 'neglect', + 'neither', + 'nephew', + 'nerve', + 'nest', + 'net', + 'network', + 'neutral', + 'never', + 'news', + 'next', + 'nice', + 'night', + 'noble', + 'noise', + 'nominee', + 'noodle', + 'normal', + 'north', + 'nose', + 'notable', + 'note', + 'nothing', + 'notice', + 'novel', + 'now', + 'nuclear', + 'number', + 'nurse', + 'nut', + 'oak', + 'obey', + 'object', + 'oblige', + 'obscure', + 'observe', + 'obtain', + 'obvious', + 'occur', + 'ocean', + 'october', + 'odor', + 'off', + 'offer', + 'office', + 'often', + 'oil', + 'okay', + 'old', + 'olive', + 'olympic', + 'omit', + 'once', + 'one', + 'onion', + 'online', + 'only', + 'open', + 'opera', + 'opinion', + 'oppose', + 'option', + 'orange', + 'orbit', + 'orchard', + 'order', + 'ordinary', + 'organ', + 'orient', + 'original', + 'orphan', + 'ostrich', + 'other', + 'outdoor', + 'outer', + 'output', + 'outside', + 'oval', + 'oven', + 'over', + 'own', + 'owner', + 'oxygen', + 'oyster', + 'ozone', + 'pact', + 'paddle', + 'page', + 'pair', + 'palace', + 'palm', + 'panda', + 'panel', + 'panic', + 'panther', + 'paper', + 'parade', + 'parent', + 'park', + 'parrot', + 'party', + 'pass', + 'patch', + 'path', + 'patient', + 'patrol', + 'pattern', + 'pause', + 'pave', + 'payment', + 'peace', + 'peanut', + 'pear', + 'peasant', + 'pelican', + 'pen', + 'penalty', + 'pencil', + 'people', + 'pepper', + 'perfect', + 'permit', + 'person', + 'pet', + 'phone', + 'photo', + 'phrase', + 'physical', + 'piano', + 'picnic', + 'picture', + 'piece', + 'pig', + 'pigeon', + 'pill', + 'pilot', + 'pink', + 'pioneer', + 'pipe', + 'pistol', + 'pitch', + 'pizza', + 'place', + 'planet', + 'plastic', + 'plate', + 'play', + 'please', + 'pledge', + 'pluck', + 'plug', + 'plunge', + 'poem', + 'poet', + 'point', + 'polar', + 'pole', + 'police', + 'pond', + 'pony', + 'pool', + 'popular', + 'portion', + 'position', + 'possible', + 'post', + 'potato', + 'pottery', + 'poverty', + 'powder', + 'power', + 'practice', + 'praise', + 'predict', + 'prefer', + 'prepare', + 'present', + 'pretty', + 'prevent', + 'price', + 'pride', + 'primary', + 'print', + 'priority', + 'prison', + 'private', + 'prize', + 'problem', + 'process', + 'produce', + 'profit', + 'program', + 'project', + 'promote', + 'proof', + 'property', + 'prosper', + 'protect', + 'proud', + 'provide', + 'public', + 'pudding', + 'pull', + 'pulp', + 'pulse', + 'pumpkin', + 'punch', + 'pupil', + 'puppy', + 'purchase', + 'purity', + 'purpose', + 'purse', + 'push', + 'put', + 'puzzle', + 'pyramid', + 'quality', + 'quantum', + 'quarter', + 'question', + 'quick', + 'quit', + 'quiz', + 'quote', + 'rabbit', + 'raccoon', + 'race', + 'rack', + 'radar', + 'radio', + 'rail', + 'rain', + 'raise', + 'rally', + 'ramp', + 'ranch', + 'random', + 'range', + 'rapid', + 'rare', + 'rate', + 'rather', + 'raven', + 'raw', + 'razor', + 'ready', + 'real', + 'reason', + 'rebel', + 'rebuild', + 'recall', + 'receive', + 'recipe', + 'record', + 'recycle', + 'reduce', + 'reflect', + 'reform', + 'refuse', + 'region', + 'regret', + 'regular', + 'reject', + 'relax', + 'release', + 'relief', + 'rely', + 'remain', + 'remember', + 'remind', + 'remove', + 'render', + 'renew', + 'rent', + 'reopen', + 'repair', + 'repeat', + 'replace', + 'report', + 'require', + 'rescue', + 'resemble', + 'resist', + 'resource', + 'response', + 'result', + 'retire', + 'retreat', + 'return', + 'reunion', + 'reveal', + 'review', + 'reward', + 'rhythm', + 'rib', + 'ribbon', + 'rice', + 'rich', + 'ride', + 'ridge', + 'rifle', + 'right', + 'rigid', + 'ring', + 'riot', + 'ripple', + 'risk', + 'ritual', + 'rival', + 'river', + 'road', + 'roast', + 'robot', + 'robust', + 'rocket', + 'romance', + 'roof', + 'rookie', + 'room', + 'rose', + 'rotate', + 'rough', + 'round', + 'route', + 'royal', + 'rubber', + 'rude', + 'rug', + 'rule', + 'run', + 'runway', + 'rural', + 'sad', + 'saddle', + 'sadness', + 'safe', + 'sail', + 'salad', + 'salmon', + 'salon', + 'salt', + 'salute', + 'same', + 'sample', + 'sand', + 'satisfy', + 'satoshi', + 'sauce', + 'sausage', + 'save', + 'say', + 'scale', + 'scan', + 'scare', + 'scatter', + 'scene', + 'scheme', + 'school', + 'science', + 'scissors', + 'scorpion', + 'scout', + 'scrap', + 'screen', + 'script', + 'scrub', + 'sea', + 'search', + 'season', + 'seat', + 'second', + 'secret', + 'section', + 'security', + 'seed', + 'seek', + 'segment', + 'select', + 'sell', + 'seminar', + 'senior', + 'sense', + 'sentence', + 'series', + 'service', + 'session', + 'settle', + 'setup', + 'seven', + 'shadow', + 'shaft', + 'shallow', + 'share', + 'shed', + 'shell', + 'sheriff', + 'shield', + 'shift', + 'shine', + 'ship', + 'shiver', + 'shock', + 'shoe', + 'shoot', + 'shop', + 'short', + 'shoulder', + 'shove', + 'shrimp', + 'shrug', + 'shuffle', + 'shy', + 'sibling', + 'sick', + 'side', + 'siege', + 'sight', + 'sign', + 'silent', + 'silk', + 'silly', + 'silver', + 'similar', + 'simple', + 'since', + 'sing', + 'siren', + 'sister', + 'situate', + 'six', + 'size', + 'skate', + 'sketch', + 'ski', + 'skill', + 'skin', + 'skirt', + 'skull', + 'slab', + 'slam', + 'sleep', + 'slender', + 'slice', + 'slide', + 'slight', + 'slim', + 'slogan', + 'slot', + 'slow', + 'slush', + 'small', + 'smart', + 'smile', + 'smoke', + 'smooth', + 'snack', + 'snake', + 'snap', + 'sniff', + 'snow', + 'soap', + 'soccer', + 'social', + 'sock', + 'soda', + 'soft', + 'solar', + 'soldier', + 'solid', + 'solution', + 'solve', + 'someone', + 'song', + 'soon', + 'sorry', + 'sort', + 'soul', + 'sound', + 'soup', + 'source', + 'south', + 'space', + 'spare', + 'spatial', + 'spawn', + 'speak', + 'special', + 'speed', + 'spell', + 'spend', + 'sphere', + 'spice', + 'spider', + 'spike', + 'spin', + 'spirit', + 'split', + 'spoil', + 'sponsor', + 'spoon', + 'sport', + 'spot', + 'spray', + 'spread', + 'spring', + 'spy', + 'square', + 'squeeze', + 'squirrel', + 'stable', + 'stadium', + 'staff', + 'stage', + 'stairs', + 'stamp', + 'stand', + 'start', + 'state', + 'stay', + 'steak', + 'steel', + 'stem', + 'step', + 'stereo', + 'stick', + 'still', + 'sting', + 'stock', + 'stomach', + 'stone', + 'stool', + 'story', + 'stove', + 'strategy', + 'street', + 'strike', + 'strong', + 'struggle', + 'student', + 'stuff', + 'stumble', + 'style', + 'subject', + 'submit', + 'subway', + 'success', + 'such', + 'sudden', + 'suffer', + 'sugar', + 'suggest', + 'suit', + 'summer', + 'sun', + 'sunny', + 'sunset', + 'super', + 'supply', + 'supreme', + 'sure', + 'surface', + 'surge', + 'surprise', + 'surround', + 'survey', + 'suspect', + 'sustain', + 'swallow', + 'swamp', + 'swap', + 'swarm', + 'swear', + 'sweet', + 'swift', + 'swim', + 'swing', + 'switch', + 'sword', + 'symbol', + 'symptom', + 'syrup', + 'system', + 'table', + 'tackle', + 'tag', + 'tail', + 'talent', + 'talk', + 'tank', + 'tape', + 'target', + 'task', + 'taste', + 'tattoo', + 'taxi', + 'teach', + 'team', + 'tell', + 'ten', + 'tenant', + 'tennis', + 'tent', + 'term', + 'test', + 'text', + 'thank', + 'that', + 'theme', + 'then', + 'theory', + 'there', + 'they', + 'thing', + 'this', + 'thought', + 'three', + 'thrive', + 'throw', + 'thumb', + 'thunder', + 'ticket', + 'tide', + 'tiger', + 'tilt', + 'timber', + 'time', + 'tiny', + 'tip', + 'tired', + 'tissue', + 'title', + 'toast', + 'tobacco', + 'today', + 'toddler', + 'toe', + 'together', + 'toilet', + 'token', + 'tomato', + 'tomorrow', + 'tone', + 'tongue', + 'tonight', + 'tool', + 'tooth', + 'top', + 'topic', + 'topple', + 'torch', + 'tornado', + 'tortoise', + 'toss', + 'total', + 'tourist', + 'toward', + 'tower', + 'town', + 'toy', + 'track', + 'trade', + 'traffic', + 'tragic', + 'train', + 'transfer', + 'trap', + 'trash', + 'travel', + 'tray', + 'treat', + 'tree', + 'trend', + 'trial', + 'tribe', + 'trick', + 'trigger', + 'trim', + 'trip', + 'trophy', + 'trouble', + 'truck', + 'true', + 'truly', + 'trumpet', + 'trust', + 'truth', + 'try', + 'tube', + 'tuition', + 'tumble', + 'tuna', + 'tunnel', + 'turkey', + 'turn', + 'turtle', + 'twelve', + 'twenty', + 'twice', + 'twin', + 'twist', + 'two', + 'type', + 'typical', + 'ugly', + 'umbrella', + 'unable', + 'unaware', + 'uncle', + 'uncover', + 'under', + 'undo', + 'unfair', + 'unfold', + 'unhappy', + 'uniform', + 'unique', + 'unit', + 'universe', + 'unknown', + 'unlock', + 'until', + 'unusual', + 'unveil', + 'update', + 'upgrade', + 'uphold', + 'upon', + 'upper', + 'upset', + 'urban', + 'urge', + 'usage', + 'use', + 'used', + 'useful', + 'useless', + 'usual', + 'utility', + 'vacant', + 'vacuum', + 'vague', + 'valid', + 'valley', + 'valve', + 'van', + 'vanish', + 'vapor', + 'various', + 'vast', + 'vault', + 'vehicle', + 'velvet', + 'vendor', + 'venture', + 'venue', + 'verb', + 'verify', + 'version', + 'very', + 'vessel', + 'veteran', + 'viable', + 'vibrant', + 'vicious', + 'victory', + 'video', + 'view', + 'village', + 'vintage', + 'violin', + 'virtual', + 'virus', + 'visa', + 'visit', + 'visual', + 'vital', + 'vivid', + 'vocal', + 'voice', + 'void', + 'volcano', + 'volume', + 'vote', + 'voyage', + 'wage', + 'wagon', + 'wait', + 'walk', + 'wall', + 'walnut', + 'want', + 'warfare', + 'warm', + 'warrior', + 'wash', + 'wasp', + 'waste', + 'water', + 'wave', + 'way', + 'wealth', + 'weapon', + 'wear', + 'weasel', + 'weather', + 'web', + 'wedding', + 'weekend', + 'weird', + 'welcome', + 'west', + 'wet', + 'whale', + 'what', + 'wheat', + 'wheel', + 'when', + 'where', + 'whip', + 'whisper', + 'wide', + 'width', + 'wife', + 'wild', + 'will', + 'win', + 'window', + 'wine', + 'wing', + 'wink', + 'winner', + 'winter', + 'wire', + 'wisdom', + 'wise', + 'wish', + 'witness', + 'wolf', + 'woman', + 'wonder', + 'wood', + 'wool', + 'word', + 'work', + 'world', + 'worry', + 'worth', + 'wrap', + 'wreck', + 'wrestle', + 'wrist', + 'write', + 'wrong', + 'yard', + 'year', + 'yellow', + 'you', + 'young', + 'youth', + 'zebra', + 'zero', + 'zone', + 'zoo' +]; diff --git a/app/lib/methods/getThreadName.ts b/app/lib/methods/getThreadName.ts index 112d1212013..04ed4ac4f24 100644 --- a/app/lib/methods/getThreadName.ts +++ b/app/lib/methods/getThreadName.ts @@ -27,9 +27,9 @@ const getThreadName = async (rid: string, tmid: string, messageId: string): Prom }); } } else { - let thread = await getSingleMessage(tmid); - thread = await Encryption.decryptMessage(thread); - tmsg = buildThreadName(thread); + const thread = await getSingleMessage(tmid); + const decryptedThread = await Encryption.decryptMessage(thread); + tmsg = buildThreadName(decryptedThread as IMessage); // check it again to avoid race condition threadRecord = await getThreadById(tmid); if (!threadRecord) { @@ -38,7 +38,7 @@ const getThreadName = async (rid: string, tmid: string, messageId: string): Prom threadCollection?.prepareCreate((t: TThreadModel) => { t._raw = sanitizedRaw({ id: thread._id }, threadCollection.schema); if (t.subscription) t.subscription.id = rid; - Object.assign(t, thread); + Object.assign(t, { ...thread, ...decryptedThread }); }), messageRecord?.prepareUpdate(m => { m.tmsg = tmsg; diff --git a/app/lib/methods/helpers/log/events.ts b/app/lib/methods/helpers/log/events.ts index 08a721784c3..7817983f2db 100644 --- a/app/lib/methods/helpers/log/events.ts +++ b/app/lib/methods/helpers/log/events.ts @@ -349,6 +349,7 @@ export default { // E2E ENCRYPTION SECURITY VIEW E2E_SEC_CHANGE_PASSWORD: 'e2e_sec_change_password', E2E_SEC_RESET_OWN_KEY: 'e2e_sec_reset_own_key', + E2E_SEC_COPY_PASSWORD: 'e2e_sec_copy_password', // TEAM CHANNELS VIEW TC_SEARCH: 'tc_search', diff --git a/app/lib/methods/sendMessage.ts b/app/lib/methods/sendMessage.ts index 7a4eaae40dd..c7f4942f0db 100644 --- a/app/lib/methods/sendMessage.ts +++ b/app/lib/methods/sendMessage.ts @@ -5,7 +5,7 @@ import database from '../database'; import log from './helpers/log'; import { random } from './helpers'; import { Encryption } from '../encryption'; -import { type E2EType, type IMessage, type IUser, type TMessageModel } from '../../definitions'; +import type { E2EType, IMessage, IUser, MessageType, TMessageModel } from '../../definitions'; import sdk from '../services/sdk'; import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../constants/keys'; import { messagesStatus } from '../constants/messagesStatus'; @@ -143,9 +143,9 @@ export async function sendMessage( tm._updatedAt = messageDate; tm.status = messagesStatus.SENT; // Original message was sent already tm.u = tMessageRecord.u; - tm.t = message.t; + tm.t = message?.t as MessageType; tm.attachments = tMessageRecord.attachments; - if (message.t === E2E_MESSAGE_TYPE) { + if (message?.t === E2E_MESSAGE_TYPE) { tm.e2e = E2E_STATUS.DONE as E2EType; } }) @@ -169,8 +169,8 @@ export async function sendMessage( username: user.username, name: user.name }; - tm.t = message.t; - if (message.t === E2E_MESSAGE_TYPE) { + tm.t = message?.t as MessageType; + if (message?.t === E2E_MESSAGE_TYPE) { tm.e2e = E2E_STATUS.DONE as E2EType; } }) @@ -202,8 +202,8 @@ export async function sendMessage( m.tmsg = tMessageRecord.msg; m.tshow = tshow; } - m.t = message.t; - if (message.t === E2E_MESSAGE_TYPE) { + m.t = message?.t as MessageType; + if (message?.t === E2E_MESSAGE_TYPE) { m.e2e = E2E_STATUS.DONE as E2EType; } }) diff --git a/app/lib/methods/subscriptions/room.ts b/app/lib/methods/subscriptions/room.ts index 6fb1b8a2f88..004d55cecdc 100644 --- a/app/lib/methods/subscriptions/room.ts +++ b/app/lib/methods/subscriptions/room.ts @@ -255,7 +255,7 @@ export default class RoomSubscription { const threadMessagesCollection = db.get('thread_messages'); // Decrypt the message if necessary - message = await Encryption.decryptMessage(message); + message = (await Encryption.decryptMessage(message)) as IMessage; // Create or update message try { diff --git a/app/lib/methods/subscriptions/rooms.ts b/app/lib/methods/subscriptions/rooms.ts index eaf231f7542..78aeb9ca674 100644 --- a/app/lib/methods/subscriptions/rooms.ts +++ b/app/lib/methods/subscriptions/rooms.ts @@ -149,29 +149,9 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe } } - let tmp = merge(subscription, room); - tmp = (await Encryption.decryptSubscription(tmp)) as ISubscription; + const tmp = merge(subscription, room); const sub = await getSubscriptionByRoomId(tmp.rid); - // If we're receiving a E2EKey of a room - if (sub && !sub.E2EKey && subscription?.E2EKey) { - // Assing info from database subscription to tmp - // It should be a plain object - tmp = Object.assign(tmp, { - rid: sub.rid, - encrypted: sub.encrypted, - lastMessage: sub.lastMessage, - E2EKey: subscription.E2EKey, - e2eKeyId: sub.e2eKeyId - }); - // Decrypt lastMessage using the received E2EKey - tmp = (await Encryption.decryptSubscription(tmp)) as ISubscription; - // Decrypt all pending messages of this room in parallel - Encryption.decryptPendingMessages(tmp.rid); - } else if (sub && subscription.E2ESuggestedKey) { - await Encryption.evaluateSuggestedKey(sub.rid, subscription.E2ESuggestedKey); - } - const batch: Model[] = []; if (sub) { try { @@ -240,6 +220,10 @@ const createOrUpdateSubscription = async (subscription: ISubscription, room: ISe await db.write(async () => { await db.batch(batch); }); + + Encryption.decryptPendingSubscriptions(); + Encryption.decryptPendingMessages(tmp.rid); + Encryption.getRoomInstance(tmp.rid); } catch (e) { log(e); } @@ -403,7 +387,7 @@ export default function subscribeRooms() { // If it's from a encrypted room if (message?.t === E2E_MESSAGE_TYPE) { - if (message.msg) { + if (message.msg || message.content) { // Decrypt this message content const { msg } = await Encryption.decryptMessage({ ...message, rid }); // If it's a direct the content is the message decrypted diff --git a/app/lib/methods/updateMessages.ts b/app/lib/methods/updateMessages.ts index fbec3557d04..82ff479d5f1 100644 --- a/app/lib/methods/updateMessages.ts +++ b/app/lib/methods/updateMessages.ts @@ -43,7 +43,7 @@ export default async function updateMessages({ const db = database.active; return db.write(async () => { // Decrypt these messages - update = await Encryption.decryptMessages(update); + update = (await Encryption.decryptMessages(update)) as IMessage[]; const messagesIds: string[] = [...update.map(m => m._id as string), ...remove.map(m => m._id as string)]; const msgCollection = db.get('messages'); diff --git a/app/lib/services/restApi.ts b/app/lib/services/restApi.ts index 9d1a63ab12c..2aaaebd7734 100644 --- a/app/lib/services/restApi.ts +++ b/app/lib/services/restApi.ts @@ -963,10 +963,27 @@ export function e2eResetRoomKey(rid: string, e2eKey: string, e2eKeyId: string): return sdk.post('e2e.resetRoomKey', { rid, e2eKey, e2eKeyId }); } -export const editMessage = async (message: Pick) => { - const { rid, msg } = await Encryption.encryptMessage(message as IMessage); +export const editMessage = async (message: Pick) => { + const result = await Encryption.encryptMessage(message as IMessage); + if (!result) { + throw new Error('Failed to encrypt message'); + } + + if (result.content) { + // RC 0.49.0 + return sdk.post('chat.update', { + roomId: message.rid, + msgId: message.id, + content: result.content + }); + } + // RC 0.49.0 - return sdk.post('chat.update', { roomId: rid, msgId: message.id, text: msg }); + return sdk.post('chat.update', { + roomId: message.rid, + msgId: message.id, + text: message.msg || '' + }); }; export const registerPushToken = () => diff --git a/app/reducers/encryption.test.ts b/app/reducers/encryption.test.ts index 478743faa8a..a3c3c4814f2 100644 --- a/app/reducers/encryption.test.ts +++ b/app/reducers/encryption.test.ts @@ -1,10 +1,4 @@ -import { - encryptionSet, - encryptionInit, - encryptionSetBanner, - encryptionDecodeKey, - encryptionDecodeKeyFailure -} from '../actions/encryption'; +import { encryptionSet, encryptionSetBanner, encryptionDecodeKey, encryptionDecodeKeyFailure } from '../actions/encryption'; import { mockedStore } from './mockedStore'; import { initialState } from './encryption'; @@ -20,20 +14,14 @@ describe('test encryption reducer', () => { expect(state).toEqual({ banner: 'BANNER', enabled: true, failure: false }); }); - it('should return empty store after encryptionInit', () => { - mockedStore.dispatch(encryptionInit()); - const state = mockedStore.getState().encryption; - expect(state).toEqual({ banner: '', enabled: false, failure: false }); - }); - it('should return initial state after encryptionSetBanner', () => { mockedStore.dispatch(encryptionSetBanner('BANNER_NEW')); const state = mockedStore.getState().encryption; - expect(state).toEqual({ banner: 'BANNER_NEW', enabled: false, failure: false }); + expect(state).toEqual({ banner: 'BANNER_NEW', enabled: true, failure: false }); }); it('should return decode key state changes', () => { - mockedStore.dispatch(encryptionInit()); + mockedStore.dispatch(encryptionSet(false, '')); mockedStore.dispatch(encryptionDecodeKey('asd')); const state = mockedStore.getState().encryption; expect(state).toEqual({ ...initialState, failure: false }); diff --git a/app/reducers/encryption.ts b/app/reducers/encryption.ts index 9d65528166e..aa436beae71 100644 --- a/app/reducers/encryption.ts +++ b/app/reducers/encryption.ts @@ -38,8 +38,6 @@ export default function encryption(state = initialState, action: TApplicationAct enabled: false, failure: true }; - case ENCRYPTION.INIT: - return initialState; default: return state; } diff --git a/app/views/E2EEncryptionSecurityView/ChangePassword.tsx b/app/views/E2EEncryptionSecurityView/ChangePassword.tsx index dcfa310c83b..237336ea878 100644 --- a/app/views/E2EEncryptionSecurityView/ChangePassword.tsx +++ b/app/views/E2EEncryptionSecurityView/ChangePassword.tsx @@ -1,9 +1,8 @@ import React, { useRef, useState } from 'react'; -import { StyleSheet, Text, type TextInput as RNTextInput } from 'react-native'; +import { Text, type TextInput, View } from 'react-native'; +import Clipboard from '@react-native-clipboard/clipboard'; -import { textInputDebounceTime } from '../../lib/constants/debounceConfig'; import { useTheme } from '../../theme'; -import * as List from '../../containers/List'; import I18n from '../../i18n'; import log, { events, logEvent } from '../../lib/methods/helpers/log'; import { FormTextInput } from '../../containers/TextInput'; @@ -12,38 +11,26 @@ import { Encryption } from '../../lib/encryption'; import { showConfirmationAlert, showErrorAlert } from '../../lib/methods/helpers/info'; import EventEmitter from '../../lib/methods/helpers/events'; import { LISTENER } from '../../containers/Toast'; -import { useDebounce } from '../../lib/methods/helpers'; -import sharedStyles from '../Styles'; import { useAppSelector } from '../../lib/hooks/useAppSelector'; - -const styles = StyleSheet.create({ - title: { - fontSize: 16, - ...sharedStyles.textMedium - }, - description: { - fontSize: 14, - paddingVertical: 12, - ...sharedStyles.textRegular - }, - changePasswordButton: { - marginBottom: 4 - }, - separator: { - marginBottom: 16 - } -}); +import { styles } from './styles'; +import { generatePassphrase } from '../../lib/encryption/utils'; +import * as List from '../../containers/List'; +import PasswordPolicies from '../../containers/PasswordPolicies'; +import { E2E_PASSWORD_POLICIES, validateE2EPassword } from './utils'; const ChangePassword = () => { const [newPassword, setNewPassword] = useState(''); + const [manualPasswordEnabled, setManualPasswordEnabled] = useState(false); const { colors } = useTheme(); const { encryptionEnabled, server } = useAppSelector(state => ({ encryptionEnabled: state.encryption.enabled, server: state.server.server })); - const newPasswordInputRef = useRef(null); + const newPasswordInputRef = useRef(null); + + const onChangePasswordText = (text: string) => setNewPassword(text); - const onChangePasswordText = useDebounce((text: string) => setNewPassword(text), textInputDebounceTime); + const isPasswordValid = manualPasswordEnabled ? validateE2EPassword(newPassword) : !!newPassword.trim(); const changePassword = () => { if (!newPassword.trim()) { @@ -68,36 +55,80 @@ const ChangePassword = () => { }); }; + const enterManually = () => { + setManualPasswordEnabled(true); + setNewPassword(''); + setTimeout(() => { + newPasswordInputRef?.current?.focus(); + }, 100); + }; + + const generateNewPassword = async () => { + setManualPasswordEnabled(false); + const password = await generatePassphrase(); + setNewPassword(password); + }; + + const copy = () => { + logEvent(events.E2E_SEC_COPY_PASSWORD); + if (newPassword) { + Clipboard.setString(newPassword); + EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') }); + } + }; + if (!encryptionEnabled) { return null; } return ( - <> - - {I18n.t('E2E_encryption_change_password_title')} - - {I18n.t('E2E_encryption_change_password_description')} - - + {I18n.t('E2E_encryption_change_password_title')} + + {I18n.t('E2E_encryption_change_password_description')} + + + {manualPasswordEnabled ? ( + + ) : null} + + {!manualPasswordEnabled && newPassword ? ( +