diff --git a/__tests__/__snapshots__/Storyshots.test.js.snap b/__tests__/__snapshots__/Storyshots.test.js.snap index b9d4dc1ee..20dab567c 100644 --- a/__tests__/__snapshots__/Storyshots.test.js.snap +++ b/__tests__/__snapshots__/Storyshots.test.js.snap @@ -3333,6 +3333,165 @@ exports[`Storyshots Message list message 1`] = ` > Edited + + Encrypted + + + + + + + + + 10:00 AM + + + + + + + + This message has error and is encrypted + + + + + + + + + { if (props.isInfo) { @@ -22,10 +24,13 @@ const Content = React.memo((props) => { ); } + const isPreview = props.tmid && !props.isThreadRoom; let content = null; if (props.tmid && !props.msg) { content = {I18n.t('Sent_an_attachment')}; + } else if (props.isEncrypted) { + content = {I18n.t('Encrypted_message')}; } else { const { baseUrl, user } = useContext(MessageContext); content = ( @@ -35,8 +40,8 @@ const Content = React.memo((props) => { getCustomEmoji={props.getCustomEmoji} username={user.username} isEdited={props.isEdited} - numberOfLines={(props.tmid && !props.isThreadRoom) ? 1 : 0} - preview={props.tmid && !props.isThreadRoom} + numberOfLines={isPreview ? 1 : 0} + preview={isPreview} channels={props.channels} mentions={props.mentions} navToRoomInfo={props.navToRoomInfo} @@ -47,6 +52,21 @@ const Content = React.memo((props) => { ); } + // If this is a encrypted message and is not a preview + if (props.type === E2E_MESSAGE_TYPE && !isPreview) { + content = ( + + + {content} + + + + ); + } + return ( {content} @@ -59,9 +79,15 @@ const Content = React.memo((props) => { if (prevProps.msg !== nextProps.msg) { return false; } + if (prevProps.type !== nextProps.type) { + return false; + } if (prevProps.theme !== nextProps.theme) { return false; } + if (prevProps.isEncrypted !== nextProps.isEncrypted) { + return false; + } if (!equal(prevProps.mentions, nextProps.mentions)) { return false; } @@ -79,11 +105,13 @@ Content.propTypes = { msg: PropTypes.string, theme: PropTypes.string, isEdited: PropTypes.bool, + isEncrypted: PropTypes.bool, getCustomEmoji: PropTypes.func, channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), navToRoomInfo: PropTypes.func, - useRealName: PropTypes.bool + useRealName: PropTypes.bool, + type: PropTypes.string }; Content.displayName = 'MessageContent'; diff --git a/app/containers/message/Encrypted.js b/app/containers/message/Encrypted.js new file mode 100644 index 000000000..38d3bf16b --- /dev/null +++ b/app/containers/message/Encrypted.js @@ -0,0 +1,29 @@ +import React, { useContext } from 'react'; +import PropTypes from 'prop-types'; + +import Touchable from './Touchable'; +import { E2E_MESSAGE_TYPE } from '../../lib/encryption/constants'; +import { CustomIcon } from '../../lib/Icons'; +import { themes } from '../../constants/colors'; +import { BUTTON_HIT_SLOP } from './utils'; +import MessageContext from './Context'; +import styles from './styles'; + +const Encrypted = React.memo(({ type, theme }) => { + if (type !== E2E_MESSAGE_TYPE) { + return null; + } + + const { onEncryptedPress } = useContext(MessageContext); + return ( + + + + ); +}); +Encrypted.propTypes = { + type: PropTypes.string, + theme: PropTypes.string +}; + +export default Encrypted; diff --git a/app/containers/message/RepliedThread.js b/app/containers/message/RepliedThread.js index e4e06f038..7d02d7494 100644 --- a/app/containers/message/RepliedThread.js +++ b/app/containers/message/RepliedThread.js @@ -8,9 +8,10 @@ import { CustomIcon } from '../../lib/Icons'; import DisclosureIndicator from '../DisclosureIndicator'; import styles from './styles'; import { themes } from '../../constants/colors'; +import I18n from '../../i18n'; const RepliedThread = React.memo(({ - tmid, tmsg, isHeader, fetchThreadName, id, theme + tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme }) => { if (!tmid || !isHeader) { return null; @@ -24,6 +25,10 @@ const RepliedThread = React.memo(({ let msg = shortnameToUnicode(tmsg); msg = removeMarkdown(msg); + if (isEncrypted) { + msg = I18n.t('Encrypted_message'); + } + return ( @@ -38,6 +43,9 @@ const RepliedThread = React.memo(({ if (prevProps.tmsg !== nextProps.tmsg) { return false; } + if (prevProps.isEncrypted !== nextProps.isEncrypted) { + return false; + } if (prevProps.isHeader !== nextProps.isHeader) { return false; } @@ -53,7 +61,8 @@ RepliedThread.propTypes = { id: PropTypes.string, isHeader: PropTypes.bool, theme: PropTypes.string, - fetchThreadName: PropTypes.func + fetchThreadName: PropTypes.func, + isEncrypted: PropTypes.bool }; RepliedThread.displayName = 'MessageRepliedThread'; diff --git a/app/containers/message/index.js b/app/containers/message/index.js index 207e5e70c..cd1ee0859 100644 --- a/app/containers/message/index.js +++ b/app/containers/message/index.js @@ -8,6 +8,7 @@ import debounce from '../../utils/debounce'; import { SYSTEM_MESSAGES, getMessageTranslation } from './utils'; import messagesStatus from '../../constants/messagesStatus'; import { withTheme } from '../../theme'; +import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants'; class MessageContainer extends React.Component { static propTypes = { @@ -35,6 +36,7 @@ class MessageContainer extends React.Component { getCustomEmoji: PropTypes.func, onLongPress: PropTypes.func, onReactionPress: PropTypes.func, + onEncryptedPress: PropTypes.func, onDiscussionPress: PropTypes.func, onThreadPress: PropTypes.func, errorActionsShow: PropTypes.func, @@ -53,6 +55,7 @@ class MessageContainer extends React.Component { getCustomEmoji: () => {}, onLongPress: () => {}, onReactionPress: () => {}, + onEncryptedPress: () => {}, onDiscussionPress: () => {}, onThreadPress: () => {}, errorActionsShow: () => {}, @@ -104,7 +107,7 @@ class MessageContainer extends React.Component { onLongPress = () => { const { archived, onLongPress, item } = this.props; - if (this.isInfo || this.hasError || archived) { + if (this.isInfo || this.hasError || this.isEncrypted || archived) { return; } if (onLongPress) { @@ -133,6 +136,13 @@ class MessageContainer extends React.Component { } } + onEncryptedPress = () => { + const { onEncryptedPress } = this.props; + if (onEncryptedPress) { + onEncryptedPress(); + } + } + onDiscussionPress = () => { const { onDiscussionPress, item } = this.props; if (onDiscussionPress) { @@ -196,6 +206,12 @@ class MessageContainer extends React.Component { return false; } + get isEncrypted() { + const { item } = this.props; + const { t, e2e } = item; + return t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE; + } + get isInfo() { const { item } = this.props; return SYSTEM_MESSAGES.includes(item.t); @@ -251,6 +267,7 @@ class MessageContainer extends React.Component { onErrorPress: this.onErrorPress, replyBroadcast: this.replyBroadcast, onReactionPress: this.onReactionPress, + onEncryptedPress: this.onEncryptedPress, onDiscussionPress: this.onDiscussionPress, onReactionLongPress: this.onReactionLongPress }} @@ -295,6 +312,7 @@ class MessageContainer extends React.Component { isThreadRoom={isThreadRoom} isInfo={this.isInfo} isTemp={this.isTemp} + isEncrypted={this.isEncrypted} hasError={this.hasError} showAttachment={showAttachment} getCustomEmoji={getCustomEmoji} diff --git a/app/containers/message/styles.js b/app/containers/message/styles.js index 4ee6af08a..f83c64f0c 100644 --- a/app/containers/message/styles.js +++ b/app/containers/message/styles.js @@ -13,6 +13,9 @@ export default StyleSheet.create({ paddingHorizontal: 14, flexDirection: 'column' }, + contentContainer: { + flex: 1 + }, messageContent: { flex: 1, marginLeft: 46 @@ -163,5 +166,8 @@ export default StyleSheet.create({ }, readReceipt: { lineHeight: 20 + }, + encrypted: { + justifyContent: 'center' } }); diff --git a/app/i18n/locales/en.js b/app/i18n/locales/en.js index 50f2d2f6b..5de60343e 100644 --- a/app/i18n/locales/en.js +++ b/app/i18n/locales/en.js @@ -198,11 +198,17 @@ export default { Do_you_have_an_account: 'Do you have an account?', Do_you_have_a_certificate: 'Do you have a certificate?', Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?', + E2E_How_It_Works_info1: 'You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted.', + E2E_How_It_Works_info2: 'This is *end to end encryption* so the key to encode/decode your messages and they will not be saved on the server. For that reason *you need to store this password somewhere safe* which you can access later if you may need.', + E2E_How_It_Works_info3: 'If you proceed, it will be auto generated an E2E password.', + E2E_How_It_Works_info4: 'You can also setup a new password for your encryption key any time from any browser you have entered the existing E2E password.', edit: 'edit', edited: 'edited', Edit: 'Edit', Edit_Status: 'Edit Status', Edit_Invite: 'Edit Invite', + End_to_end_encrypted_room: 'End to end encrypted room', + end_to_end_encryption: 'end to end encryption', Email_Notification_Mode_All: 'Every Mention/DM', Email_Notification_Mode_Disabled: 'Disabled', Email_or_password_field_is_empty: 'Email or password field is empty', @@ -212,6 +218,13 @@ export default { Empty_title: 'Empty title', Enable_Auto_Translate: 'Enable Auto-Translate', Enable_notifications: 'Enable notifications', + Encrypted: 'Encrypted', + Encrypted_message: 'Encrypted message', + Enter_Your_E2E_Password: 'Enter Your E2E Password', + Enter_Your_Encryption_Password_desc1: 'This will allow you to access your encrypted private groups and direct messages.', + Enter_Your_Encryption_Password_desc2: 'You need to enter the password to encode/decode messages every place you use the chat.', + Encryption_error_title: 'Your encryption password seems wrong', + Encryption_error_desc: 'Wasn\'t possible to decode your encryption key to be imported.', Everyone_can_access_this_channel: 'Everyone can access this channel', Error_uploading: 'Error uploading', Expiration_Days: 'Expiration (Days)', @@ -240,6 +253,7 @@ export default { Has_left_the_channel: 'Has left the channel', Hide_System_Messages: 'Hide System Messages', Hide_type_messages: 'Hide "{{type}}" messages', + How_It_Works: 'How It Works', Message_HideType_uj: 'User Join', Message_HideType_ul: 'User Leave', Message_HideType_ru: 'User Removed', @@ -253,6 +267,7 @@ export default { Message_HideType_subscription_role_removed: 'Role No Longer Defined', Message_HideType_room_archived: 'Room Archived', Message_HideType_room_unarchived: 'Room Unarchived', + I_Saved_My_E2E_Password: 'I Saved My E2E Password', IP: 'IP', In_app: 'In-app', IN_APP_AND_DESKTOP: 'IN-APP AND DESKTOP', @@ -438,6 +453,10 @@ export default { saving_profile: 'saving profile', saving_settings: 'saving settings', saved_to_gallery: 'Saved to gallery', + Save_Your_E2E_Password: 'Save Your E2E Password', + Save_Your_Encryption_Password: 'Save Your Encryption Password', + Save_Your_Encryption_Password_warning: 'This password is not stored anywhere so save it carefully somewhere else.', + Save_Your_Encryption_Password_info: 'Notice that you lose your password, there is no way to recover it and you will lose access to your messages.', Search_Messages: 'Search Messages', Search: 'Search', Search_by: 'Search by', @@ -585,6 +604,7 @@ export default { Your_invite_link_will_expire_on__date__: 'Your invite link will expire on {{date}}.', Your_invite_link_will_never_expire: 'Your invite link will never expire.', Your_workspace: 'Your workspace', + Your_password_is: 'Your password is', Version_no: 'Version: {{version}}', You_will_not_be_able_to_recover_this_message: 'You will not be able to recover this message!', You_will_unset_a_certificate_for_this_server: 'You will unset a certificate for this server', diff --git a/app/i18n/locales/pt-BR.js b/app/i18n/locales/pt-BR.js index e289f6abb..95d461162 100644 --- a/app/i18n/locales/pt-BR.js +++ b/app/i18n/locales/pt-BR.js @@ -192,11 +192,17 @@ export default { Dont_Have_An_Account: 'Não tem uma conta?', Do_you_have_an_account: 'Você tem uma conta?', Do_you_really_want_to_key_this_room_question_mark: 'Você quer realmente {{key}} esta sala?', + E2E_How_It_Works_info1: 'Agora você pode criar grupos privados criptografados e mensagens diretas. Você também pode alterar grupos privados existentes ou DMs para criptografados.', + E2E_How_It_Works_info2: 'Esta é a criptografia *ponta a ponta*, portanto, a chave para codificar/decodificar suas mensagens e elas não serão salvas no servidor. Por esse motivo *você precisa armazenar esta senha em algum lugar seguro* que você pode acessar mais tarde se precisar.', + E2E_How_It_Works_info3: 'Se você continuar, será gerada automaticamente uma senha E2E.', + E2E_How_It_Works_info4: 'Você também pode configurar uma nova senha para sua chave de criptografia a qualquer momento em qualquer navegador em que tenha inserido a senha E2E existente.', edit: 'editar', edited: 'editado', Edit: 'Editar', Edit_Invite: 'Editar convite', Edit_Status: 'Editar Status', + End_to_end_encrypted_room: 'Sala criptografada de ponta a ponta', + end_to_end_encryption: 'criptografia de ponta a ponta', Email_or_password_field_is_empty: 'Email ou senha estão vazios', Email: 'Email', email: 'e-mail', @@ -205,6 +211,13 @@ export default { Email_Notification_Mode_Disabled: 'Desativado', Enable_Auto_Translate: 'Ativar a tradução automática', Enable_notifications: 'Habilitar notificações', + Encrypted: 'Criptografado', + Encrypted_message: 'Mensagem criptografada', + Enter_Your_E2E_Password: 'Digite Sua Senha E2E', + Enter_Your_Encryption_Password_desc1: 'Isso permitirá que você acesse seus grupos privados e mensagens diretas criptografadas.', + Enter_Your_Encryption_Password_desc2: 'Você precisa inserir a senha para codificar/decodificar mensagens em todos os lugares em que usar o chat.', + Encryption_error_title: 'Sua senha de criptografia parece errada', + Encryption_error_desc: 'Não foi possível decodificar sua chave de criptografia para ser importada.', Everyone_can_access_this_channel: 'Todos podem acessar este canal', Error_uploading: 'Erro subindo', Expiration_Days: 'Expira em (dias)', @@ -410,6 +423,10 @@ export default { saving_profile: 'salvando perfil', saving_settings: 'salvando configurações', saved_to_gallery: 'Salvo na galeria', + Save_Your_E2E_Password: 'Salve sua senha E2E', + Save_Your_Encryption_Password: 'Salve Sua Senha de Criptografia', + Save_Your_Encryption_Password_warning: 'Esta senha não é armazenada em nenhum lugar, portanto, salve-a com cuidado em outro lugar.', + Save_Your_Encryption_Password_info: 'Observe que se você perder sua senha, não há como recuperá-la e você perderá o acesso às suas mensagens.', Search_Messages: 'Buscar Mensagens', Search: 'Buscar', Search_by: 'Buscar por', diff --git a/app/lib/database/model/Message.js b/app/lib/database/model/Message.js index 22dfec731..c41bad26b 100644 --- a/app/lib/database/model/Message.js +++ b/app/lib/database/model/Message.js @@ -77,4 +77,6 @@ export default class Message extends Model { @field('tmsg') tmsg; @json('blocks', sanitizer) blocks; + + @field('e2e') e2e; } diff --git a/app/lib/database/model/Room.js b/app/lib/database/model/Room.js index 0a8beab12..32a3a5777 100644 --- a/app/lib/database/model/Room.js +++ b/app/lib/database/model/Room.js @@ -12,6 +12,8 @@ export default class Room extends Model { @field('encrypted') encrypted; + @field('e2e_key_id') e2eKeyId; + @field('ro') ro; @json('v', sanitizer) v; diff --git a/app/lib/database/model/Server.js b/app/lib/database/model/Server.js index 5e98ea954..df770a32e 100644 --- a/app/lib/database/model/Server.js +++ b/app/lib/database/model/Server.js @@ -29,4 +29,6 @@ export default class Server extends Model { @field('unique_id') uniqueID; @field('enterprise_modules') enterpriseModules; + + @field('e2e_enable') E2E_Enable; } diff --git a/app/lib/database/model/Subscription.js b/app/lib/database/model/Subscription.js index a94e09423..afdd8391f 100644 --- a/app/lib/database/model/Subscription.js +++ b/app/lib/database/model/Subscription.js @@ -109,4 +109,10 @@ export default class Subscription extends Model { @json('livechat_data', sanitizer) livechatData; @json('tags', sanitizer) tags; + + @field('e2e_key') E2EKey; + + @field('encrypted') encrypted; + + @field('e2e_key_id') e2eKeyId; } diff --git a/app/lib/database/model/Thread.js b/app/lib/database/model/Thread.js index fca0122ff..e0179fc35 100644 --- a/app/lib/database/model/Thread.js +++ b/app/lib/database/model/Thread.js @@ -73,4 +73,6 @@ export default class Thread extends Model { @field('auto_translate') autoTranslate; @json('translations', sanitizer) translations; + + @field('e2e') e2e; } diff --git a/app/lib/database/model/ThreadMessage.js b/app/lib/database/model/ThreadMessage.js index 842f22285..b3b4216b5 100644 --- a/app/lib/database/model/ThreadMessage.js +++ b/app/lib/database/model/ThreadMessage.js @@ -75,4 +75,6 @@ export default class ThreadMessage extends Model { @json('translations', sanitizer) translations; @field('draft_message') draftMessage; + + @field('e2e') e2e; } diff --git a/app/lib/database/model/migrations.js b/app/lib/database/model/migrations.js index db3b92dc3..9dd851790 100644 --- a/app/lib/database/model/migrations.js +++ b/app/lib/database/model/migrations.js @@ -129,6 +129,43 @@ export default schemaMigrations({ ] }) ] + }, + { + toVersion: 10, + steps: [ + addColumns({ + table: 'subscriptions', + columns: [ + { name: 'e2e_key', type: 'string', isOptional: true }, + { name: 'encrypted', type: 'boolean', isOptional: true }, + { name: 'e2e_key_id', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'messages', + columns: [ + { name: 'e2e', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'thread_messages', + columns: [ + { name: 'e2e', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'threads', + columns: [ + { name: 'e2e', type: 'string', isOptional: true } + ] + }), + addColumns({ + table: 'rooms', + columns: [ + { name: 'e2e_key_id', type: 'string', isOptional: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/model/serversMigrations.js b/app/lib/database/model/serversMigrations.js index 0e26fb632..f6a80bed2 100644 --- a/app/lib/database/model/serversMigrations.js +++ b/app/lib/database/model/serversMigrations.js @@ -59,6 +59,17 @@ export default schemaMigrations({ ] }) ] + }, + { + toVersion: 8, + steps: [ + addColumns({ + table: 'servers', + columns: [ + { name: 'e2e_enable', type: 'boolean', isOptional: true } + ] + }) + ] } ] }); diff --git a/app/lib/database/schema/app.js b/app/lib/database/schema/app.js index 59336e3f0..3a1935cac 100644 --- a/app/lib/database/schema/app.js +++ b/app/lib/database/schema/app.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 9, + version: 10, tables: [ tableSchema({ name: 'subscriptions', @@ -49,7 +49,10 @@ export default appSchema({ { name: 'department_id', type: 'string', isOptional: true }, { name: 'served_by', type: 'string', isOptional: true }, { name: 'livechat_data', type: 'string', isOptional: true }, - { name: 'tags', type: 'string', isOptional: true } + { name: 'tags', type: 'string', isOptional: true }, + { name: 'e2e_key', type: 'string', isOptional: true }, + { name: 'encrypted', type: 'boolean', isOptional: true }, + { name: 'e2e_key_id', type: 'string', isOptional: true } ] }), tableSchema({ @@ -63,7 +66,8 @@ export default appSchema({ { name: 'department_id', type: 'string', isOptional: true }, { name: 'served_by', type: 'string', isOptional: true }, { name: 'livechat_data', type: 'string', isOptional: true }, - { name: 'tags', type: 'string', isOptional: true } + { name: 'tags', type: 'string', isOptional: true }, + { name: 'e2e_key_id', type: 'string', isOptional: true } ] }), tableSchema({ @@ -101,7 +105,8 @@ export default appSchema({ { name: 'auto_translate', type: 'boolean', isOptional: true }, { name: 'translations', type: 'string', isOptional: true }, { name: 'tmsg', type: 'string', isOptional: true }, - { name: 'blocks', type: 'string', isOptional: true } + { name: 'blocks', type: 'string', isOptional: true }, + { name: 'e2e', type: 'string', isOptional: true } ] }), tableSchema({ @@ -137,7 +142,8 @@ export default appSchema({ { name: 'channels', type: 'string', isOptional: true }, { name: 'unread', type: 'boolean', isOptional: true }, { name: 'auto_translate', type: 'boolean', isOptional: true }, - { name: 'translations', type: 'string', isOptional: true } + { name: 'translations', type: 'string', isOptional: true }, + { name: 'e2e', type: 'string', isOptional: true } ] }), tableSchema({ @@ -173,7 +179,8 @@ export default appSchema({ { name: 'channels', type: 'string', isOptional: true }, { name: 'unread', type: 'boolean', isOptional: true }, { name: 'auto_translate', type: 'boolean', isOptional: true }, - { name: 'translations', type: 'string', isOptional: true } + { name: 'translations', type: 'string', isOptional: true }, + { name: 'e2e', type: 'string', isOptional: true } ] }), tableSchema({ diff --git a/app/lib/database/schema/servers.js b/app/lib/database/schema/servers.js index 9ff0baafa..bee3f8da3 100644 --- a/app/lib/database/schema/servers.js +++ b/app/lib/database/schema/servers.js @@ -1,7 +1,7 @@ import { appSchema, tableSchema } from '@nozbe/watermelondb'; export default appSchema({ - version: 7, + version: 8, tables: [ tableSchema({ name: 'users', @@ -31,7 +31,8 @@ export default appSchema({ { name: 'auto_lock_time', type: 'number', isOptional: true }, { name: 'biometry', type: 'boolean', isOptional: true }, { name: 'unique_id', type: 'string', isOptional: true }, - { name: 'enterprise_modules', type: 'string', isOptional: true } + { name: 'enterprise_modules', type: 'string', isOptional: true }, + { name: 'e2e_enable', type: 'boolean', isOptional: true } ] }) ] diff --git a/app/lib/encryption/README.md b/app/lib/encryption/README.md new file mode 100644 index 000000000..8b44ddadb --- /dev/null +++ b/app/lib/encryption/README.md @@ -0,0 +1,28 @@ +# Rocket.Chat Mobile + +## E2E Encryption + +> Note: This feature is currently in beta. Uploads will not be encrypted in this version. +You can check [this documentation](https://docs.rocket.chat/guides/user-guides/end-to-end-encryption) for further information about the web client. + +### How it works + +- Each user has a public and private key (asymmetric cryptography). +- The user private key is stored encrypted on the server and it can be decrypted on clients only using the user E2E encryption password. +- A room key is generated using the public key of each room member (symmetric cryptography). +- Users can decrypt the room key using their private key. +- Each room has a unique identifier which make users able to request a room key. +- The room unique identifier is called `e2eKeyId` and it's a property of the `room` collection. +- The room key is called `E2EKey` and it's a property of the `subscription` collection. +- After the room key is decrypted, the user is able to encrypt and decrypt messages of the room. + +### User keys + +* If the user doesn't have keys neither locally nor on the server, we create and encrypt them using a random password. These encrypted keys are sent to the server (so other clients can fetch) and saved locally. +* If the user have keys stored on server, but doesn't have them stored locally, we fetch them from the server and request a password to decrypt the keys. + +### Room keys + +* If the room has a `E2EKey`, we decrypt it using the user key. +* If the room doesn't have a `E2EKey`, but has a `e2eKeyId`, we *emit an event* on _stream-notify-room-users_ sending the `roomId` and the `e2eKeyId` requesting the `E2EKey` from any online room member. +* If the room have none of them, we create new ones and send them back to the server. diff --git a/app/lib/encryption/constants.js b/app/lib/encryption/constants.js new file mode 100644 index 000000000..216746a7b --- /dev/null +++ b/app/lib/encryption/constants.js @@ -0,0 +1,17 @@ +export const E2E_MESSAGE_TYPE = 'e2e'; +export const E2E_PUBLIC_KEY = 'RC_E2E_PUBLIC_KEY'; +export const E2E_PRIVATE_KEY = 'RC_E2E_PRIVATE_KEY'; +export const E2E_RANDOM_PASSWORD_KEY = 'RC_E2E_RANDOM_PASSWORD_KEY'; +export const E2E_REFRESH_MESSAGES_KEY = 'E2E_REFRESH_MESSAGES_KEY'; +export const E2E_STATUS = { + PENDING: 'pending', + DONE: 'done' +}; +export const E2E_BANNER_TYPE = { + REQUEST_PASSWORD: 'REQUEST_PASSWORD', + SAVE_PASSWORD: 'SAVE_PASSWORD' +}; +export const E2E_ROOM_TYPES = { + d: 'd', + p: 'p' +}; diff --git a/app/lib/encryption/encryption.js b/app/lib/encryption/encryption.js new file mode 100644 index 000000000..34c9b8a14 --- /dev/null +++ b/app/lib/encryption/encryption.js @@ -0,0 +1,452 @@ +import EJSON from 'ejson'; +import SimpleCrypto from 'react-native-simple-crypto'; +import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord'; +import { Q } from '@nozbe/watermelondb'; + +import { + toString, + utf8ToBuffer, + splitVectorData, + joinVectorData, + randomPassword +} from './utils'; +import { + E2E_PUBLIC_KEY, + E2E_PRIVATE_KEY, + E2E_RANDOM_PASSWORD_KEY, + E2E_STATUS, + E2E_MESSAGE_TYPE, + E2E_BANNER_TYPE +} from './constants'; +import RocketChat from '../rocketchat'; +import { EncryptionRoom } from './index'; +import UserPreferences from '../userPreferences'; +import database from '../database'; +import protectedFunction from '../methods/helpers/protectedFunction'; +import Deferred from '../../utils/deferred'; +import log from '../../utils/log'; +import store from '../createStore'; + +class Encryption { + constructor() { + this.ready = false; + this.privateKey = null; + this.roomInstances = {}; + this.readyPromise = new Deferred(); + this.readyPromise + .then(() => { + this.ready = true; + }) + .catch(() => { + this.ready = false; + }); + } + + // Initialize Encryption client + initialize = () => { + this.roomInstances = {}; + + // Don't await these promises + // so they can run parallelized + this.decryptPendingSubscriptions(); + this.decryptPendingMessages(); + + // Mark Encryption client as ready + this.readyPromise.resolve(); + } + + get establishing() { + const { banner } = store.getState().encryption; + // If the password was not inserted yet + if (banner === E2E_BANNER_TYPE.REQUEST_PASSWORD) { + // We can't decrypt/encrypt, so, reject this try + return Promise.reject(); + } + + // Wait the client ready state + return this.readyPromise; + } + + // Stop Encryption client + stop = () => { + this.privateKey = null; + this.roomInstances = {}; + // Cancel ongoing encryption/decryption requests + this.readyPromise.reject(); + // Reset Deferred + this.ready = false; + this.readyPromise = new Deferred(); + this.readyPromise + .then(() => { + this.ready = true; + }) + .catch(() => { + this.ready = false; + }); + } + + // When a new participant join and request a new room encryption key + provideRoomKeyToUser = async(keyId, rid) => { + // 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 and don't provide a key + return; + } + } + + const roomE2E = await this.getRoomInstance(rid); + return roomE2E.provideKeyToUser(keyId); + } + + // Persist keys on UserPreferences + persistKeys = async(server, publicKey, privateKey) => { + this.privateKey = await SimpleCrypto.RSA.importKey(EJSON.parse(privateKey)); + await UserPreferences.setStringAsync(`${ server }-${ E2E_PUBLIC_KEY }`, EJSON.stringify(publicKey)); + await UserPreferences.setStringAsync(`${ server }-${ E2E_PRIVATE_KEY }`, privateKey); + } + + // Could not obtain public-private keypair from server. + createKeys = async(userId, server) => { + // Generate new keys + const key = await SimpleCrypto.RSA.generateKeys(2048); + + // Cast these keys to the properly server format + const publicKey = await SimpleCrypto.RSA.exportKey(key.public); + const privateKey = await SimpleCrypto.RSA.exportKey(key.private); + + // Persist these new keys + this.persistKeys(server, publicKey, EJSON.stringify(privateKey)); + + // Create a password to encode the private key + const password = await this.createRandomPassword(server); + + // Encode the private key + const encodedPrivateKey = await this.encodePrivateKey(EJSON.stringify(privateKey), password, userId); + + // Send the new keys to the server + await RocketChat.e2eSetUserPublicAndPrivateKeys(EJSON.stringify(publicKey), encodedPrivateKey); + + // Request e2e keys of all encrypted rooms + await RocketChat.e2eRequestSubscriptionKeys(); + } + + // Encode a private key before send it to the server + encodePrivateKey = async(privateKey, password, userId) => { + const masterKey = await this.generateMasterKey(password, userId); + + const vector = await SimpleCrypto.utils.randomBytes(16); + const data = await SimpleCrypto.AES.encrypt( + utf8ToBuffer(privateKey), + masterKey, + vector + ); + + return EJSON.stringify(new Uint8Array(joinVectorData(vector, data))); + } + + // Decode a private key fetched from server + decodePrivateKey = async(privateKey, password, userId) => { + const masterKey = await this.generateMasterKey(password, userId); + const [vector, cipherText] = splitVectorData(EJSON.parse(privateKey)); + + const privKey = await SimpleCrypto.AES.decrypt( + cipherText, + masterKey, + vector + ); + + return toString(privKey); + } + + // Generate a user master key, this is based on userId and a password + generateMasterKey = async(password, userId) => { + const iterations = 1000; + const hash = 'SHA256'; + const keyLen = 32; + + const passwordBuffer = utf8ToBuffer(password); + const saltBuffer = utf8ToBuffer(userId); + + const masterKey = await SimpleCrypto.PBKDF2.hash( + passwordBuffer, + saltBuffer, + iterations, + keyLen, + hash + ); + + return masterKey; + } + + // Create a random password to local created keys + createRandomPassword = async(server) => { + const password = randomPassword(); + await UserPreferences.setStringAsync(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`, password); + return password; + } + + // get a encryption room instance + getRoomInstance = async(rid) => { + // Prevent handshake again + if (this.roomInstances[rid]?.ready) { + return this.roomInstances[rid]; + } + + // If doesn't have a instance of this room + if (!this.roomInstances[rid]) { + this.roomInstances[rid] = new EncryptionRoom(rid); + } + + const roomE2E = this.roomInstances[rid]; + + // Start Encryption Room instance handshake + await roomE2E.handshake(); + + return roomE2E; + } + + // Logic to decrypt all pending messages/threads/threadMessages + // after initialize the encryption client + decryptPendingMessages = async(roomId) => { + const db = database.active; + + const messagesCollection = db.collections.get('messages'); + const threadsCollection = db.collections.get('threads'); + const threadMessagesCollection = db.collections.get('thread_messages'); + + // e2e status is null or 'pending' and message type is 'e2e' + const whereClause = [ + Q.where('t', E2E_MESSAGE_TYPE), + Q.or( + Q.where('e2e', null), + Q.where('e2e', E2E_STATUS.PENDING) + ) + ]; + + // decrypt messages of a room + if (roomId) { + whereClause.push(Q.where('rid', roomId)); + } + + try { + // Find all messages/threads/threadsMessages that have pending e2e status + const messagesToDecrypt = await messagesCollection.query(...whereClause).fetch(); + const threadsToDecrypt = await threadsCollection.query(...whereClause).fetch(); + const threadMessagesToDecrypt = await threadMessagesCollection.query(...whereClause).fetch(); + + // Concat messages/threads/threadMessages + let toDecrypt = [...messagesToDecrypt, ...threadsToDecrypt, ...threadMessagesToDecrypt]; + toDecrypt = await Promise.all(toDecrypt.map(async(message) => { + const { t, msg, tmsg } = message; + const { id: rid } = message.subscription; + // WM Object -> Plain Object + const newMessage = await this.decryptMessage({ + t, + rid, + msg, + tmsg + }); + if (message._hasPendingUpdate) { + console.log(message); + return; + } + return message.prepareUpdate(protectedFunction((m) => { + Object.assign(m, newMessage); + })); + })); + + await db.action(async() => { + await db.batch(...toDecrypt); + }); + } catch (e) { + log(e); + } + } + + // Logic to decrypt all pending subscriptions + // after initialize the encryption client + decryptPendingSubscriptions = async() => { + const db = database.active; + const subCollection = db.collections.get('subscriptions'); + try { + // 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))).fetch(); + // We can't do this on database level since lastMessage is not a database object + const subsToDecrypt = subsEncrypted.filter(sub => ( + // Encrypted message + sub?.lastMessage?.t === E2E_MESSAGE_TYPE + // Message pending decrypt + && sub?.lastMessage?.e2e === E2E_STATUS.PENDING + )); + await Promise.all(subsToDecrypt.map(async(sub) => { + const { rid, lastMessage } = sub; + const newSub = await this.decryptSubscription({ rid, lastMessage }); + if (sub._hasPendingUpdate) { + console.log(sub); + return; + } + return sub.prepareUpdate(protectedFunction((m) => { + Object.assign(m, newSub); + })); + })); + + await db.action(async() => { + await db.batch(...subsToDecrypt); + }); + } catch (e) { + log(e); + } + } + + // Decrypt a subscription lastMessage + decryptSubscription = async(subscription) => { + // 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; + const db = database.active; + const subCollection = db.collections.get('subscriptions'); + + let subRecord; + try { + subRecord = await subCollection.find(rid); + } catch { + // Do nothing + } + + try { + const batch = []; + // If the subscription doesn't exists yet + if (!subRecord) { + // Let's create the subscription with the data received + batch.push(subCollection.prepareCreate((s) => { + 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) { + if (!subRecord._hasPendingUpdate) { + // Let's update the subscription with the received E2EKey + batch.push(subRecord.prepareUpdate((s) => { + s.E2EKey = subscription.E2EKey; + })); + } + } + + // If batch has some operation + if (batch.length) { + await db.action(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); + const decryptedMessage = await roomE2E.decrypt(lastMessage); + return { + ...subscription, + lastMessage: decryptedMessage + }; + } + + // Encrypt a message + encryptMessage = async(message) => { + const { rid } = message; + const db = database.active; + const subCollection = db.collections.get('subscriptions'); + + try { + // Find the subscription + const subRecord = await subCollection.find(rid); + + // Subscription is not encrypted at the moment + if (!subRecord.encrypted) { + // Send a non encrypted message + return message; + } + + // If the client is not ready + if (!this.ready) { + // Wait for ready status + await this.establishing; + } + + const roomE2E = await this.getRoomInstance(rid); + return roomE2E.encrypt(message); + } catch { + // Subscription not found + // or client can't be initialized (missing password) + } + + // Send a non encrypted message + return message; + } + + // Decrypt a message + decryptMessage = async(message) => { + const { t, e2e } = message; + + // Prevent create a new instance if this room was encrypted sometime ago + if (t !== E2E_MESSAGE_TYPE || e2e === E2E_STATUS.DONE) { + 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); + return roomE2E.decrypt(message); + } + + // Decrypt multiple messages + decryptMessages = messages => Promise.all(messages.map(m => this.decryptMessage(m))) + + // Decrypt multiple subscriptions + decryptSubscriptions = subscriptions => Promise.all(subscriptions.map(s => this.decryptSubscription(s))) +} + +const encryption = new Encryption(); +export default encryption; diff --git a/app/lib/encryption/index.js b/app/lib/encryption/index.js new file mode 100644 index 000000000..9033aa251 --- /dev/null +++ b/app/lib/encryption/index.js @@ -0,0 +1,4 @@ +import Encryption from './encryption'; +import EncryptionRoom from './room'; + +export { Encryption, EncryptionRoom }; diff --git a/app/lib/encryption/room.js b/app/lib/encryption/room.js new file mode 100644 index 000000000..59f5f6394 --- /dev/null +++ b/app/lib/encryption/room.js @@ -0,0 +1,255 @@ +import EJSON from 'ejson'; +import { Base64 } from 'js-base64'; +import SimpleCrypto from 'react-native-simple-crypto'; + +import { + toString, + b64ToBuffer, + bufferToUtf8, + bufferToB64, + bufferToB64URI, + utf8ToBuffer, + splitVectorData, + joinVectorData +} from './utils'; +import { E2E_MESSAGE_TYPE, E2E_STATUS } from './constants'; +import RocketChat from '../rocketchat'; +import Deferred from '../../utils/deferred'; +import debounce from '../../utils/debounce'; +import { Encryption } from './index'; +import database from '../database'; +import log from '../../utils/log'; + +export default class EncryptionRoom { + constructor(roomId) { + this.ready = false; + this.roomId = roomId; + this.establishing = false; + this.readyPromise = new Deferred(); + this.readyPromise.then(() => { + // Mark as ready + this.ready = true; + // Mark as established + this.establishing = false; + }); + } + + // Initialize the E2E room + handshake = async() => { + // If it's already ready we don't need to handshake again + if (this.ready) { + return; + } + + // If it's already establishing + if (this.establishing) { + // Return the ready promise to wait this client ready + return this.readyPromise; + } + + const db = database.active; + const subCollection = db.collections.get('subscriptions'); + try { + // Find the subscription + const subscription = await subCollection.find(this.roomId); + + const { E2EKey, e2eKeyId } = subscription; + + // If this room has a E2EKey, we import it + if (E2EKey) { + // We're establishing a new room encryption client + this.establishing = true; + await this.importRoomKey(E2EKey, Encryption.privateKey); + this.readyPromise.resolve(); + return; + } + + // If it doesn't have a e2eKeyId, we need to create keys to the room + if (!e2eKeyId) { + // We're establishing a new room encryption client + this.establishing = true; + await this.createRoomKey(); + this.readyPromise.resolve(); + return; + } + + // Request a E2EKey for this room to other users + await this.requestRoomKey(e2eKeyId); + } catch (e) { + log(e); + } + } + + // Import roomKey as an AES Decrypt key + importRoomKey = async(E2EKey, privateKey) => { + const roomE2EKey = E2EKey.slice(12); + + const decryptedKey = await SimpleCrypto.RSA.decrypt(roomE2EKey, privateKey); + this.sessionKeyExportedString = toString(decryptedKey); + + this.keyID = Base64.encode(this.sessionKeyExportedString).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(this.sessionKeyExportedString); + this.roomKey = b64ToBuffer(k); + } + + // Create a key to a room + createRoomKey = async() => { + const key = await SimpleCrypto.utils.randomBytes(16); + this.roomKey = key; + + // Web Crypto format of a Secret Key + const sessionKeyExported = { + // Type of Secret Key + kty: 'oct', + // Algorithm + alg: 'A128CBC', + // Base64URI encoded array of bytes + k: bufferToB64URI(this.roomKey), + // Specific Web Crypto properties + ext: true, + key_ops: ['encrypt', 'decrypt'] + }; + + this.sessionKeyExportedString = EJSON.stringify(sessionKeyExported); + this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12); + + await RocketChat.e2eSetRoomKeyID(this.roomId, this.keyID); + + await this.encryptRoomKey(); + } + + // Request a key to this room + // We're debouncing this function to avoid multiple calls + // when you join a room with a lot of messages and nobody + // can send the encryption key at the moment. + // Each time you see a encrypted message of a room that you don't have a key + // this will be called again and run once in 5 seconds + requestRoomKey = debounce(async(e2eKeyId) => { + await RocketChat.e2eRequestRoomKey(this.roomId, e2eKeyId); + }, 5000, true) + + // Create an encrypted key for this room based on users + encryptRoomKey = async() => { + const result = await RocketChat.e2eGetUsersOfRoomWithoutKey(this.roomId); + if (result.success) { + const { users } = result; + await Promise.all(users.map(user => this.encryptRoomKeyForUser(user))); + } + } + + // Encrypt the room key to each user in + encryptRoomKeyForUser = async(user) => { + if (user?.e2e?.public_key) { + const { public_key: publicKey } = user.e2e; + const userKey = await SimpleCrypto.RSA.importKey(EJSON.parse(publicKey)); + const encryptedUserKey = await SimpleCrypto.RSA.encrypt(this.sessionKeyExportedString, userKey); + await RocketChat.e2eUpdateGroupKey(user?._id, this.roomId, this.keyID + encryptedUserKey); + } + } + + // Provide this room key to a user + provideKeyToUser = async(keyId) => { + // Don't provide a key if the keyId received + // is different than the current one + if (this.keyID !== keyId) { + return; + } + + await this.encryptRoomKey(); + } + + // Encrypt text + encryptText = async(text) => { + text = utf8ToBuffer(text); + const vector = await SimpleCrypto.utils.randomBytes(16); + const data = await SimpleCrypto.AES.encrypt( + text, + this.roomKey, + vector + ); + + return this.keyID + bufferToB64(joinVectorData(vector, data)); + } + + // Encrypt messages + encrypt = async(message) => { + 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() + })); + + return { + ...message, + t: E2E_MESSAGE_TYPE, + e2e: E2E_STATUS.PENDING, + msg + }; + } catch { + // Do nothing + } + + return message; + } + + // Decrypt text + decryptText = async(msg) => { + msg = b64ToBuffer(msg.slice(12)); + const [vector, cipherText] = splitVectorData(msg); + + const decrypted = await SimpleCrypto.AES.decrypt( + cipherText, + this.roomKey, + vector + ); + + const m = EJSON.parse(bufferToUtf8(decrypted)); + + return m.text; + } + + // Decrypt messages + decrypt = async(message) => { + if (!this.ready) { + return message; + } + + try { + const { t, e2e } = message; + + // If message type is e2e and it's encrypted still + if (t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE) { + let { msg, tmsg } = message; + // Decrypt msg + msg = await this.decryptText(msg); + + // Decrypt tmsg + if (tmsg) { + tmsg = await this.decryptText(tmsg); + } + + return { + ...message, + tmsg, + msg, + e2e: E2E_STATUS.DONE + }; + } + } catch { + // Do nothing + } + + return message; + } +} diff --git a/app/lib/encryption/utils.js b/app/lib/encryption/utils.js new file mode 100644 index 000000000..492ea0066 --- /dev/null +++ b/app/lib/encryption/utils.js @@ -0,0 +1,60 @@ +/* eslint-disable no-bitwise */ +import ByteBuffer from 'bytebuffer'; +import SimpleCrypto from 'react-native-simple-crypto'; + +import random from '../../utils/random'; +import { fromByteArray, toByteArray } from '../../utils/base64-js'; + +const BASE64URI = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +export const b64ToBuffer = base64 => toByteArray(base64).buffer; +export const utf8ToBuffer = SimpleCrypto.utils.convertUtf8ToArrayBuffer; +export const bufferToB64 = arrayBuffer => fromByteArray(new Uint8Array(arrayBuffer)); +// ArrayBuffer -> Base64 URI Safe +// https://github.com/herrjemand/Base64URL-ArrayBuffer/blob/master/lib/base64url-arraybuffer.js +export const bufferToB64URI = (buffer) => { + const uintArray = new Uint8Array(buffer); + const len = uintArray.length; + let base64 = ''; + + for (let i = 0; i < len; i += 3) { + base64 += BASE64URI[uintArray[i] >> 2]; + base64 += BASE64URI[((uintArray[i] & 3) << 4) | (uintArray[i + 1] >> 4)]; + base64 += BASE64URI[((uintArray[i + 1] & 15) << 2) | (uintArray[i + 2] >> 6)]; + base64 += BASE64URI[uintArray[i + 2] & 63]; + } + + if ((len % 3) === 2) { + base64 = base64.substring(0, base64.length - 1); + } else if (len % 3 === 1) { + base64 = base64.substring(0, base64.length - 2); + } + + return base64; +}; +// SimpleCrypto.utils.convertArrayBufferToUtf8 is not working with unicode emoji +export const bufferToUtf8 = (buffer) => { + const uintArray = new Uint8Array(buffer); + const encodedString = String.fromCharCode.apply(null, uintArray); + const decodedString = decodeURIComponent(escape(encodedString)); + return decodedString; +}; +export const splitVectorData = (text) => { + const vector = text.slice(0, 16); + const data = text.slice(16); + return [vector, data]; +}; +export const joinVectorData = (vector, data) => { + const output = new Uint8Array(vector.byteLength + data.byteLength); + output.set(new Uint8Array(vector), 0); + output.set(new Uint8Array(data), vector.byteLength); + return output.buffer; +}; +export const toString = (thing) => { + if (typeof thing === 'string') { + return thing; + } + // eslint-disable-next-line new-cap + return new ByteBuffer.wrap(thing).toString('binary'); +}; +export const randomPassword = () => `${ random(3) }-${ random(3) }-${ random(3) }`.toLowerCase(); diff --git a/app/lib/methods/getSettings.js b/app/lib/methods/getSettings.js index 2ebad1ed2..2cec925b5 100644 --- a/app/lib/methods/getSettings.js +++ b/app/lib/methods/getSettings.js @@ -11,7 +11,16 @@ import protectedFunction from './helpers/protectedFunction'; import fetch from '../../utils/fetch'; import { DEFAULT_AUTO_LOCK } from '../../constants/localAuthentication'; -const serverInfoKeys = ['Site_Name', 'UI_Use_Real_Name', 'FileUpload_MediaTypeWhiteList', 'FileUpload_MaxFileSize', 'Force_Screen_Lock', 'Force_Screen_Lock_After', 'uniqueID']; +const serverInfoKeys = [ + 'Site_Name', + 'UI_Use_Real_Name', + 'FileUpload_MediaTypeWhiteList', + 'FileUpload_MaxFileSize', + 'Force_Screen_Lock', + 'Force_Screen_Lock_After', + 'uniqueID', + 'E2E_Enable' +]; // these settings are used only on onboarding process const loginSettings = [ @@ -71,6 +80,9 @@ const serverInfoUpdate = async(serverInfo, iconSetting) => { if (setting._id === 'uniqueID') { return { ...allSettings, uniqueID: setting.valueAsString }; } + if (setting._id === 'E2E_Enable') { + return { ...allSettings, E2E_Enable: setting.valueAsBoolean }; + } return allSettings; }, {}); diff --git a/app/lib/methods/helpers/findSubscriptionsRooms.js b/app/lib/methods/helpers/findSubscriptionsRooms.js index 457fc3b5f..fb3bc6d33 100644 --- a/app/lib/methods/helpers/findSubscriptionsRooms.js +++ b/app/lib/methods/helpers/findSubscriptionsRooms.js @@ -49,7 +49,10 @@ export default async(subscriptions = [], rooms = []) => { departmentId: s.departmentId, servedBy: s.servedBy, livechatData: s.livechatData, - tags: s.tags + tags: s.tags, + encrypted: s.encrypted, + e2eKeyId: s.e2eKeyId, + E2EKey: s.E2EKey })); subscriptions = subscriptions.concat(existingSubs); @@ -75,7 +78,9 @@ export default async(subscriptions = [], rooms = []) => { departmentId: r.departmentId, servedBy: r.servedBy, livechatData: r.livechatData, - tags: r.tags + tags: r.tags, + encrypted: r.encrypted, + e2eKeyId: r.e2eKeyId })); rooms = rooms.concat(existingRooms); } catch { diff --git a/app/lib/methods/helpers/mergeSubscriptionsRooms.js b/app/lib/methods/helpers/mergeSubscriptionsRooms.js index c4d3acd40..c529102f2 100644 --- a/app/lib/methods/helpers/mergeSubscriptionsRooms.js +++ b/app/lib/methods/helpers/mergeSubscriptionsRooms.js @@ -2,6 +2,7 @@ import EJSON from 'ejson'; import normalizeMessage from './normalizeMessage'; import findSubscriptionsRooms from './findSubscriptionsRooms'; +import { Encryption } from '../../encryption'; // TODO: delete and update export const merge = (subscription, room) => { @@ -27,6 +28,8 @@ export const merge = (subscription, room) => { } subscription.ro = room.ro; subscription.broadcast = room.broadcast; + subscription.encrypted = room.encrypted; + subscription.e2eKeyId = room.e2eKeyId; if (!subscription.roles || !subscription.roles.length) { subscription.roles = []; } @@ -72,17 +75,23 @@ export default async(subscriptions = [], rooms = []) => { rooms = rooms.update; } + // Find missing rooms/subscriptions on local database ({ subscriptions, rooms } = await findSubscriptionsRooms(subscriptions, rooms)); + // Merge each subscription into a room + subscriptions = subscriptions.map((s) => { + const index = rooms.findIndex(({ _id }) => _id === s.rid); + // Room not found + if (index < 0) { + return merge(s); + } + const [room] = rooms.splice(index, 1); + return merge(s, room); + }); + // Decrypt all subscriptions missing decryption + subscriptions = await Encryption.decryptSubscriptions(subscriptions); return { - subscriptions: subscriptions.map((s) => { - const index = rooms.findIndex(({ _id }) => _id === s.rid); - if (index < 0) { - return merge(s); - } - const [room] = rooms.splice(index, 1); - return merge(s, room); - }), + subscriptions, rooms }; }; diff --git a/app/lib/methods/loadThreadMessages.js b/app/lib/methods/loadThreadMessages.js index 91369fa8b..4dc8a05fd 100644 --- a/app/lib/methods/loadThreadMessages.js +++ b/app/lib/methods/loadThreadMessages.js @@ -6,6 +6,7 @@ import buildMessage from './helpers/buildMessage'; import database from '../database'; import log from '../../utils/log'; import protectedFunction from './helpers/protectedFunction'; +import { Encryption } from '../encryption'; async function load({ tmid, offset }) { try { @@ -32,6 +33,7 @@ export default function loadThreadMessages({ tmid, rid, offset = 0 }) { InteractionManager.runAfterInteractions(async() => { try { data = data.map(m => buildMessage(m)); + data = await Encryption.decryptMessages(data); const db = database.active; const threadMessagesCollection = db.collections.get('thread_messages'); const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch(); diff --git a/app/lib/methods/logout.js b/app/lib/methods/logout.js index a7c691462..0a1923de0 100644 --- a/app/lib/methods/logout.js +++ b/app/lib/methods/logout.js @@ -7,12 +7,20 @@ import { BASIC_AUTH_KEY } from '../../utils/fetch'; import database, { getDatabase } from '../database'; import RocketChat from '../rocketchat'; import { useSsl } from '../../utils/url'; +import { + E2E_PUBLIC_KEY, + E2E_PRIVATE_KEY, + E2E_RANDOM_PASSWORD_KEY +} from '../encryption/constants'; import UserPreferences from '../userPreferences'; async function removeServerKeys({ server, userId }) { await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ server }`); await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ userId }`); await UserPreferences.removeItem(`${ BASIC_AUTH_KEY }-${ server }`); + await UserPreferences.removeItem(`${ server }-${ E2E_PUBLIC_KEY }`); + await UserPreferences.removeItem(`${ server }-${ E2E_PRIVATE_KEY }`); + await UserPreferences.removeItem(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`); } async function removeSharedCredentials({ server }) { diff --git a/app/lib/methods/sendMessage.js b/app/lib/methods/sendMessage.js index 2a91637a9..14def098b 100644 --- a/app/lib/methods/sendMessage.js +++ b/app/lib/methods/sendMessage.js @@ -4,6 +4,8 @@ import messagesStatus from '../../constants/messagesStatus'; import database from '../database'; import log from '../../utils/log'; import random from '../../utils/random'; +import { Encryption } from '../encryption'; +import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../encryption/constants'; const changeMessageStatus = async(id, tmid, status, message) => { const db = database.active; @@ -44,17 +46,11 @@ const changeMessageStatus = async(id, tmid, status, message) => { }; export async function sendMessageCall(message) { - const { - id: _id, subscription: { id: rid }, msg, tmid - } = message; + const { _id, tmid } = message; try { const sdk = this.shareSDK || this.sdk; // RC 0.60.0 - const result = await sdk.post('chat.sendMessage', { - message: { - _id, rid, msg, tmid - } - }); + const result = await sdk.post('chat.sendMessage', { message }); if (result.success) { return changeMessageStatus(_id, tmid, messagesStatus.SENT, result.message); } @@ -64,6 +60,32 @@ export async function sendMessageCall(message) { return changeMessageStatus(_id, tmid, messagesStatus.ERROR); } +export async function resendMessage(message, tmid) { + const db = database.active; + try { + await db.action(async() => { + await message.update((m) => { + m.status = messagesStatus.TEMP; + }); + }); + let m = { + _id: message.id, + rid: message.subscription.id, + msg: message.msg + }; + if (tmid) { + m = { + ...m, + tmid + }; + } + m = await Encryption.encryptMessage(m); + await sendMessageCall.call(this, m); + } catch (e) { + log(e); + } +} + export default async function(rid, msg, tmid, user) { try { const db = database.active; @@ -73,9 +95,12 @@ export default async function(rid, msg, tmid, user) { const threadMessagesCollection = db.collections.get('thread_messages'); const messageId = random(17); const batch = []; - const message = { - id: messageId, subscription: { id: rid }, msg, tmid + + let message = { + _id: messageId, rid, msg, tmid }; + message = await Encryption.encryptMessage(message); + const messageDate = new Date(); let tMessageRecord; @@ -106,6 +131,10 @@ export default async function(rid, msg, tmid, user) { tm._updatedAt = messageDate; tm.status = messagesStatus.SENT; // Original message was sent already tm.u = tMessageRecord.u; + tm.t = message.t; + if (message.t === E2E_MESSAGE_TYPE) { + tm.e2e = E2E_STATUS.DONE; + } }) ); } @@ -124,6 +153,10 @@ export default async function(rid, msg, tmid, user) { _id: user.id || '1', username: user.username }; + tm.t = message.t; + if (message.t === E2E_MESSAGE_TYPE) { + tm.e2e = E2E_STATUS.DONE; + } }) ); } catch (e) { @@ -149,6 +182,10 @@ export default async function(rid, msg, tmid, user) { m.tlm = messageDate; m.tmsg = tMessageRecord.msg; } + m.t = message.t; + if (message.t === E2E_MESSAGE_TYPE) { + m.e2e = E2E_STATUS.DONE; + } }) ); diff --git a/app/lib/methods/subscriptions/room.js b/app/lib/methods/subscriptions/room.js index a7a8471de..71ac3dfee 100644 --- a/app/lib/methods/subscriptions/room.js +++ b/app/lib/methods/subscriptions/room.js @@ -11,6 +11,7 @@ import { addUserTyping, removeUserTyping, clearUserTyping } from '../../../actio import debounce from '../../../utils/debounce'; import RocketChat from '../../rocketchat'; import { subscribeRoom, unsubscribeRoom } from '../../../actions/room'; +import { Encryption } from '../../encryption'; const WINDOW_TIME = 1000; @@ -162,6 +163,9 @@ export default class RoomSubscription { const threadsCollection = db.collections.get('threads'); const threadMessagesCollection = db.collections.get('thread_messages'); + // Decrypt the message if necessary + message = await Encryption.decryptMessage(message); + // Create or update message try { const messageRecord = await msgCollection.find(message._id); diff --git a/app/lib/methods/subscriptions/rooms.js b/app/lib/methods/subscriptions/rooms.js index 700a713b4..eee596e86 100644 --- a/app/lib/methods/subscriptions/rooms.js +++ b/app/lib/methods/subscriptions/rooms.js @@ -16,6 +16,8 @@ import EventEmitter from '../../../utils/events'; import { removedRoom } from '../../../actions/room'; import { setUser } from '../../../actions/login'; import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification'; +import { Encryption } from '../../encryption'; +import { E2E_MESSAGE_TYPE } from '../../encryption/constants'; const removeListener = listener => listener.stop(); @@ -79,7 +81,10 @@ const createOrUpdateSubscription = async(subscription, room) => { departmentId: s.departmentId, servedBy: s.servedBy, livechatData: s.livechatData, - tags: s.tags + tags: s.tags, + encrypted: s.encrypted, + e2eKeyId: s.e2eKeyId, + E2EKey: s.E2EKey }; } catch (error) { try { @@ -107,6 +112,7 @@ const createOrUpdateSubscription = async(subscription, room) => { tags: r.tags, servedBy: r.servedBy, encrypted: r.encrypted, + e2eKeyId: r.e2eKeyId, broadcast: r.broadcast, customFields: r.customFields, departmentId: r.departmentId, @@ -117,73 +123,91 @@ const createOrUpdateSubscription = async(subscription, room) => { } } - const tmp = merge(subscription, room); - await db.action(async() => { - let sub; + let tmp = merge(subscription, room); + tmp = await Encryption.decryptSubscription(tmp); + let sub; + try { + sub = await subCollection.find(tmp.rid); + } catch (error) { + // Do nothing + } + + // 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); + // Decrypt all pending messages of this room in parallel + Encryption.decryptPendingMessages(tmp.rid); + } + + const batch = []; + if (sub) { try { - sub = await subCollection.find(tmp.rid); + const update = sub.prepareUpdate((s) => { + Object.assign(s, tmp); + if (subscription.announcement) { + if (subscription.announcement !== sub.announcement) { + s.bannerClosed = false; + } + } + }); + batch.push(update); + } catch (e) { + console.log(e); + } + } else { + try { + const create = subCollection.prepareCreate((s) => { + s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema); + Object.assign(s, tmp); + if (s.roomUpdatedAt) { + s.roomUpdatedAt = new Date(); + } + }); + batch.push(create); + } catch (e) { + console.log(e); + } + } + + const { rooms } = store.getState().room; + if (tmp.lastMessage && !rooms.includes(tmp.rid)) { + const lastMessage = buildMessage(tmp.lastMessage); + const messagesCollection = db.collections.get('messages'); + let messageRecord; + try { + messageRecord = await messagesCollection.find(lastMessage._id); } catch (error) { // Do nothing } - const batch = []; - if (sub) { - try { - const update = sub.prepareUpdate((s) => { - Object.assign(s, tmp); - if (subscription.announcement) { - if (subscription.announcement !== sub.announcement) { - s.bannerClosed = false; - } - } - }); - batch.push(update); - } catch (e) { - console.log(e); - } + if (messageRecord) { + batch.push( + messageRecord.prepareUpdate(() => { + Object.assign(messageRecord, lastMessage); + }) + ); } else { - try { - const create = subCollection.prepareCreate((s) => { - s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema); - Object.assign(s, tmp); - if (s.roomUpdatedAt) { - s.roomUpdatedAt = new Date(); - } - }); - batch.push(create); - } catch (e) { - console.log(e); - } - } - - const { rooms } = store.getState().room; - if (tmp.lastMessage && !rooms.includes(tmp.rid)) { - const lastMessage = buildMessage(tmp.lastMessage); - const messagesCollection = db.collections.get('messages'); - let messageRecord; - try { - messageRecord = await messagesCollection.find(lastMessage._id); - } catch (error) { - // Do nothing - } - - if (messageRecord) { - batch.push( - messageRecord.prepareUpdate(() => { - Object.assign(messageRecord, lastMessage); - }) - ); - } else { - batch.push( - messagesCollection.prepareCreate((m) => { - m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema); - m.subscription.id = lastMessage.rid; - return Object.assign(m, lastMessage); - }) - ); - } + batch.push( + messagesCollection.prepareCreate((m) => { + m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema); + m.subscription.id = lastMessage.rid; + return Object.assign(m, lastMessage); + }) + ); } + } + await db.action(async() => { await db.batch(...batch); }); } catch (e) { @@ -320,12 +344,25 @@ export default function subscribeRooms() { if (/notification/.test(ev)) { const [notification] = ddpMessage.fields.args; try { - const { payload: { rid } } = notification; + const { payload: { rid, message, sender } } = notification; const room = await RocketChat.getRoom(rid); notification.title = RocketChat.getRoomTitle(room); notification.avatar = RocketChat.getRoomAvatar(room); + + // If it's from a encrypted room + if (message.t === E2E_MESSAGE_TYPE) { + // Decrypt this message content + const { msg } = await Encryption.decryptMessage({ ...message, rid }); + // If it's a direct the content is the message decrypted + if (room.t === 'd') { + notification.text = msg; + // If it's a private group we should add the sender name + } else { + notification.text = `${ RocketChat.getSenderName(sender) }: ${ msg }`; + } + } } catch (e) { - // do nothing + log(e); } EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification); } @@ -333,6 +370,14 @@ export default function subscribeRooms() { const { type: eventType, ...args } = type; handlePayloadUserInteraction(eventType, args); } + if (/e2ekeyRequest/.test(ev)) { + const [roomId, keyId] = ddpMessage.fields.args; + try { + await Encryption.provideRoomKeyToUser(keyId, roomId); + } catch (e) { + log(e); + } + } }); const stop = () => { diff --git a/app/lib/methods/updateMessages.js b/app/lib/methods/updateMessages.js index 350ece501..0dc63e9ba 100644 --- a/app/lib/methods/updateMessages.js +++ b/app/lib/methods/updateMessages.js @@ -5,6 +5,7 @@ import buildMessage from './helpers/buildMessage'; import log from '../../utils/log'; import database from '../database'; import protectedFunction from './helpers/protectedFunction'; +import { Encryption } from '../encryption'; export default function updateMessages({ rid, update = [], remove = [] }) { try { @@ -13,6 +14,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) { } const db = database.active; return db.action(async() => { + // Decrypt these messages + update = await Encryption.decryptMessages(update); const subCollection = db.collections.get('subscriptions'); let sub; try { diff --git a/app/lib/rocketchat.js b/app/lib/rocketchat.js index 2ccd1073a..5a69939ac 100644 --- a/app/lib/rocketchat.js +++ b/app/lib/rocketchat.js @@ -6,12 +6,12 @@ import AsyncStorage from '@react-native-community/async-storage'; import reduxStore from './createStore'; import defaultSettings from '../constants/settings'; -import messagesStatus from '../constants/messagesStatus'; import database from './database'; import log from '../utils/log'; import { isIOS, getBundleId } from '../utils/deviceInfo'; import fetch from '../utils/fetch'; +import { encryptionInit } from '../actions/encryption'; import { setUser, setLoginServices, loginRequest } from '../actions/login'; import { disconnect, connectSuccess, connectRequest } from '../actions/connect'; import { @@ -40,7 +40,7 @@ import loadMessagesForRoom from './methods/loadMessagesForRoom'; import loadMissedMessages from './methods/loadMissedMessages'; import loadThreadMessages from './methods/loadThreadMessages'; -import sendMessage, { sendMessageCall } from './methods/sendMessage'; +import sendMessage, { resendMessage } from './methods/sendMessage'; import { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage'; import callJitsi from './methods/callJitsi'; @@ -53,6 +53,7 @@ import { twoFactor } from '../utils/twoFactor'; import { selectServerFailure } from '../actions/server'; import { useSsl } from '../utils/url'; import UserPreferences from './userPreferences'; +import { Encryption } from './encryption'; import EventEmitter from '../utils/events'; const TOKEN_KEY = 'reactnativemeteor_usertoken'; @@ -80,10 +81,10 @@ const RocketChat = { }, canOpenRoom, createChannel({ - name, users, type, readOnly, broadcast + name, users, type, readOnly, broadcast, encrypted }) { // RC 0.51.0 - return this.methodCallWrapper(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast }); + return this.methodCallWrapper(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast, encrypted }); }, async getWebsocketInfo({ server }) { const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: useSsl(server) }); @@ -307,6 +308,7 @@ const RocketChat = { } reduxStore.dispatch(shareSetUser(user)); await RocketChat.login({ resume: user.token }); + reduxStore.dispatch(encryptionInit()); } catch (e) { log(e); } @@ -321,6 +323,45 @@ const RocketChat = { reduxStore.dispatch(shareSetUser({})); }, + async e2eFetchMyKeys() { + // RC 0.70.0 + const sdk = this.shareSDK || this.sdk; + const result = await sdk.get('e2e.fetchMyKeys'); + // snake_case -> camelCase + if (result.success) { + return { + success: result.success, + publicKey: result.public_key, + privateKey: result.private_key + }; + } + return result; + }, + e2eSetUserPublicAndPrivateKeys(public_key, private_key) { + // RC 2.2.0 + return this.post('e2e.setUserPublicAndPrivateKeys', { public_key, private_key }); + }, + e2eRequestSubscriptionKeys() { + // RC 0.72.0 + return this.methodCallWrapper('e2e.requestSubscriptionKeys'); + }, + e2eGetUsersOfRoomWithoutKey(rid) { + // RC 0.70.0 + return this.sdk.get('e2e.getUsersOfRoomWithoutKey', { rid }); + }, + e2eSetRoomKeyID(rid, keyID) { + // RC 0.70.0 + return this.post('e2e.setRoomKeyID', { rid, keyID }); + }, + e2eUpdateGroupKey(uid, rid, key) { + // RC 0.70.0 + return this.post('e2e.updateGroupKey', { uid, rid, key }); + }, + e2eRequestRoomKey(rid, e2eKeyId) { + // RC 0.70.0 + return this.methodCallWrapper('stream-notify-room-users', `${ rid }/e2ekeyRequest`, rid, e2eKeyId); + }, + updateJitsiTimeout(roomId) { // RC 0.74.0 return this.post('video-conference/jitsi.update-timeout', { roomId }); @@ -468,30 +509,7 @@ const RocketChat = { sendMessage, getRooms, readMessages, - async resendMessage(message, tmid) { - const db = database.active; - try { - await db.action(async() => { - await message.update((m) => { - m.status = messagesStatus.TEMP; - }); - }); - let m = { - id: message.id, - msg: message.msg, - subscription: { id: message.subscription.id } - }; - if (tmid) { - m = { - ...m, - tmid - }; - } - await sendMessageCall.call(this, m); - } catch (e) { - log(e); - } - }, + resendMessage, async search({ text, filterUsers = true, filterRooms = true }) { const searchText = text.trim(); @@ -641,10 +659,10 @@ const RocketChat = { // RC 0.48.0 return this.post('chat.delete', { msgId: messageId, roomId: rid }); }, - editMessage(message) { - const { id, msg, rid } = message; + async editMessage(message) { + const { rid, msg } = await Encryption.encryptMessage(message); // RC 0.49.0 - return this.post('chat.update', { roomId: rid, msgId: id, text: msg }); + return this.post('chat.update', { roomId: rid, msgId: message.id, text: msg }); }, markAsUnread({ messageId }) { return this.post('subscriptions.unread', { firstUnreadMessage: { _id: messageId } }); @@ -1270,6 +1288,10 @@ const RocketChat = { translateMessage(message, targetLanguage) { return this.methodCallWrapper('autoTranslate.translateMessage', message, targetLanguage); }, + getSenderName(sender) { + const { UI_Use_Real_Name: useRealName } = reduxStore.getState().settings; + return useRealName ? sender.name : sender.username; + }, getRoomTitle(room) { const { UI_Use_Real_Name: useRealName, UI_Allow_room_names_with_special_chars: allowSpecialChars } = reduxStore.getState().settings; const { username } = reduxStore.getState().login.user; diff --git a/app/presentation/RoomItem/LastMessage.js b/app/presentation/RoomItem/LastMessage.js index 54ee09f11..f89881644 100644 --- a/app/presentation/RoomItem/LastMessage.js +++ b/app/presentation/RoomItem/LastMessage.js @@ -6,6 +6,7 @@ import I18n from '../../i18n'; import styles from './styles'; import Markdown from '../../containers/markdown'; import { themes } from '../../constants/colors'; +import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants'; const formatMsg = ({ lastMessage, type, showLastMessage, username, useRealName @@ -29,6 +30,11 @@ const formatMsg = ({ return I18n.t('User_sent_an_attachment', { user }); } + // Encrypted message pending decrypt + if (lastMessage.t === E2E_MESSAGE_TYPE && lastMessage.e2e !== E2E_STATUS.DONE) { + lastMessage.msg = I18n.t('Encrypted_message'); + } + if (isLastMessageSentByMe) { prefix = I18n.t('You_colon'); } else if (type !== 'd') { diff --git a/app/reducers/encryption.js b/app/reducers/encryption.js new file mode 100644 index 000000000..729c8be41 --- /dev/null +++ b/app/reducers/encryption.js @@ -0,0 +1,19 @@ +import { ENCRYPTION } from '../actions/actionsTypes'; + +const initialState = { + banner: null +}; + +export default function encryption(state = initialState, action) { + switch (action.type) { + case ENCRYPTION.SET_BANNER: + return { + ...state, + banner: action.banner + }; + case ENCRYPTION.INIT: + return initialState; + default: + return state; + } +} diff --git a/app/reducers/index.js b/app/reducers/index.js index d047450a6..6211ccb6f 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -17,6 +17,7 @@ import usersTyping from './usersTyping'; import inviteLinks from './inviteLinks'; import createDiscussion from './createDiscussion'; import enterpriseModules from './enterpriseModules'; +import encryption from './encryption'; import inquiry from '../ee/omnichannel/reducers/inquiry'; @@ -39,5 +40,6 @@ export default combineReducers({ inviteLinks, createDiscussion, inquiry, - enterpriseModules + enterpriseModules, + encryption }); diff --git a/app/sagas/createChannel.js b/app/sagas/createChannel.js index 2c642006c..ca4bcb9fe 100644 --- a/app/sagas/createChannel.js +++ b/app/sagas/createChannel.js @@ -36,8 +36,18 @@ const handleRequest = function* handleRequest({ data }) { ({ room: sub } = result); } } else { - const { type, readOnly, broadcast } = data; - logEvent(events.CREATE_CHANNEL_CREATE, { type: type ? 'private' : 'public', readOnly, broadcast }); + const { + type, + readOnly, + broadcast, + encrypted + } = data; + logEvent(events.CREATE_CHANNEL_CREATE, { + type: type ? 'private' : 'public', + readOnly, + broadcast, + encrypted + }); sub = yield call(createChannel, data); } diff --git a/app/sagas/encryption.js b/app/sagas/encryption.js new file mode 100644 index 000000000..4201e6bcd --- /dev/null +++ b/app/sagas/encryption.js @@ -0,0 +1,122 @@ +import EJSON from 'ejson'; +import { takeLatest, select, put } from 'redux-saga/effects'; + +import { ENCRYPTION } from '../actions/actionsTypes'; +import { encryptionSetBanner } from '../actions/encryption'; +import { Encryption } from '../lib/encryption'; +import Navigation from '../lib/Navigation'; +import { + E2E_PUBLIC_KEY, + E2E_PRIVATE_KEY, + E2E_BANNER_TYPE, + E2E_RANDOM_PASSWORD_KEY +} from '../lib/encryption/constants'; +import database from '../lib/database'; +import RocketChat from '../lib/rocketchat'; +import UserPreferences from '../lib/userPreferences'; +import { getUserSelector } from '../selectors/login'; +import { showErrorAlert } from '../utils/info'; +import I18n from '../i18n'; +import log from '../utils/log'; + +const getServer = state => state.share.server || state.server.server; + +const handleEncryptionInit = function* handleEncryptionInit() { + try { + const server = yield select(getServer); + const user = yield select(getUserSelector); + + // Fetch server info to check E2E enable + const serversDB = database.servers; + const serversCollection = serversDB.collections.get('servers'); + const serverInfo = yield serversCollection.find(server); + + // If E2E is disabled on server, skip + if (!serverInfo?.E2E_Enable) { + return; + } + + // Fetch stored private e2e key for this server + const storedPrivateKey = yield UserPreferences.getStringAsync(`${ server }-${ E2E_PRIVATE_KEY }`); + + // Fetch server stored e2e keys + const keys = yield RocketChat.e2eFetchMyKeys(); + + // A private key was received from the server, but it's not saved locally yet + // Show the banner asking for the password + if (!storedPrivateKey && keys?.privateKey) { + yield put(encryptionSetBanner(E2E_BANNER_TYPE.REQUEST_PASSWORD)); + return; + } + + // If the user has a private key stored, but never entered the password + const storedRandomPassword = yield UserPreferences.getStringAsync(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`); + if (storedRandomPassword) { + yield put(encryptionSetBanner(E2E_BANNER_TYPE.SAVE_PASSWORD)); + } + + // Fetch stored public e2e key for this server + let storedPublicKey = yield UserPreferences.getStringAsync(`${ server }-${ E2E_PUBLIC_KEY }`); + // Prevent parse undefined + if (storedPublicKey) { + storedPublicKey = EJSON.parse(storedPublicKey); + } + + if (storedPublicKey && storedPrivateKey) { + // Persist these keys + yield Encryption.persistKeys(server, storedPublicKey, storedPrivateKey); + } else { + // Create new keys since the user doesn't have any + yield Encryption.createKeys(user.id, server); + yield put(encryptionSetBanner(E2E_BANNER_TYPE.SAVE_PASSWORD)); + } + + // Decrypt all pending messages/subscriptions + Encryption.initialize(); + } catch (e) { + log(e); + } +}; + +const handleEncryptionStop = function* handleEncryptionStop() { + // Hide encryption banner + yield put(encryptionSetBanner()); + // Stop Encryption client + Encryption.stop(); +}; + +const handleEncryptionDecodeKey = function* handleEncryptionDecodeKey({ password }) { + try { + const server = yield select(getServer); + const user = yield select(getUserSelector); + + // Fetch server stored e2e keys + const keys = yield RocketChat.e2eFetchMyKeys(); + + const publicKey = EJSON.parse(keys?.publicKey); + + // Decode the current server key + const privateKey = yield Encryption.decodePrivateKey(keys?.privateKey, password, user.id); + + // Persist these decrypted keys + yield Encryption.persistKeys(server, publicKey, privateKey); + + // Decrypt all pending messages/subscriptions + Encryption.initialize(); + + // Hide encryption banner + yield put(encryptionSetBanner()); + + Navigation.back(); + } catch { + // Can't decrypt user private key + showErrorAlert(I18n.t('Encryption_error_desc'), I18n.t('Encryption_error_title')); + } +}; + +const root = function* root() { + yield takeLatest(ENCRYPTION.INIT, handleEncryptionInit); + yield takeLatest(ENCRYPTION.STOP, handleEncryptionStop); + yield takeLatest(ENCRYPTION.DECODE_KEY, handleEncryptionDecodeKey); +}; +export default root; diff --git a/app/sagas/index.js b/app/sagas/index.js index af339c743..e499d74ec 100644 --- a/app/sagas/index.js +++ b/app/sagas/index.js @@ -10,6 +10,7 @@ import state from './state'; import deepLinking from './deepLinking'; import inviteLinks from './inviteLinks'; import createDiscussion from './createDiscussion'; +import encryption from './encryption'; import inquiry from '../ee/omnichannel/sagas/inquiry'; @@ -26,7 +27,8 @@ const root = function* root() { deepLinking(), inviteLinks(), createDiscussion(), - inquiry() + inquiry(), + encryption() ]); }; diff --git a/app/sagas/login.js b/app/sagas/login.js index d1a6a5b50..e175c21f2 100644 --- a/app/sagas/login.js +++ b/app/sagas/login.js @@ -24,10 +24,12 @@ import { inviteLinksRequest } from '../actions/inviteLinks'; import { showErrorAlert } from '../utils/info'; import { localAuthenticate } from '../utils/localAuthentication'; import { setActiveUsers } from '../actions/activeUsers'; +import { encryptionInit, encryptionStop } from '../actions/encryption'; import UserPreferences from '../lib/userPreferences'; import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; +import { E2E_REFRESH_MESSAGES_KEY } from '../lib/encryption/constants'; const getServer = state => state.server.server; const loginWithPasswordCall = args => RocketChat.loginWithPassword(args); @@ -95,6 +97,35 @@ const fetchEnterpriseModules = function* fetchEnterpriseModules({ user }) { } }; +const fetchRooms = function* fetchRooms({ server }) { + try { + // Read the flag to check if refresh was already done + const refreshed = yield UserPreferences.getBoolAsync(E2E_REFRESH_MESSAGES_KEY); + if (!refreshed) { + const serversDB = database.servers; + const serversCollection = serversDB.collections.get('servers'); + + const serverRecord = yield serversCollection.find(server); + + // We need to reset roomsUpdatedAt to request all rooms again + // and save their respective E2EKeys to decrypt all pending messages and lastMessage + // that are already inserted on local database by other app version + yield serversDB.action(async() => { + await serverRecord.update((s) => { + s.roomsUpdatedAt = null; + }); + }); + + // Set the flag to indicate that already refreshed + yield UserPreferences.setBoolAsync(E2E_REFRESH_MESSAGES_KEY, true); + } + } catch (e) { + log(e); + } + + yield put(roomsRequest()); +}; + const handleLoginSuccess = function* handleLoginSuccess({ user }) { try { const adding = yield select(state => state.server.adding); @@ -103,7 +134,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { RocketChat.getUserPresence(user.id); const server = yield select(getServer); - yield put(roomsRequest()); + yield fork(fetchRooms, { server }); yield fork(fetchPermissions); yield fork(fetchCustomEmojis); yield fork(fetchRoles); @@ -111,6 +142,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { yield fork(registerPushToken); yield fork(fetchUsersPresence); yield fork(fetchEnterpriseModules, { user }); + yield put(encryptionInit()); I18n.locale = user.language; moment.locale(toMomentLocale(user.language)); @@ -173,6 +205,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) { }; const handleLogout = function* handleLogout({ forcedByServer }) { + yield put(encryptionStop()); yield put(appStart({ root: ROOT_LOADING, text: I18n.t('Logging_out') })); const server = yield select(getServer); if (server) { diff --git a/app/sagas/selectServer.js b/app/sagas/selectServer.js index 3b777777a..86bea8241 100644 --- a/app/sagas/selectServer.js +++ b/app/sagas/selectServer.js @@ -18,6 +18,7 @@ import I18n from '../i18n'; import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch'; import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app'; import UserPreferences from '../lib/userPreferences'; +import { encryptionStop } from '../actions/encryption'; import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; @@ -67,6 +68,7 @@ const getServerInfo = function* getServerInfo({ server, raiseError = true }) { const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) { try { yield put(inquiryReset()); + yield put(encryptionStop()); const serversDB = database.servers; yield UserPreferences.setStringAsync(RocketChat.CURRENT_SERVER, server); const userId = yield UserPreferences.getStringAsync(`${ RocketChat.TOKEN_KEY }-${ server }`); diff --git a/app/stacks/InsideStack.js b/app/stacks/InsideStack.js index 82e9f8ad4..a5a664986 100644 --- a/app/stacks/InsideStack.js +++ b/app/stacks/InsideStack.js @@ -50,6 +50,13 @@ import AdminPanelView from '../views/AdminPanelView'; import NewMessageView from '../views/NewMessageView'; import CreateChannelView from '../views/CreateChannelView'; +// E2ESaveYourPassword Stack +import E2ESaveYourPasswordView from '../views/E2ESaveYourPasswordView'; +import E2EHowItWorksView from '../views/E2EHowItWorksView'; + +// E2EEnterYourPassword Stack +import E2EEnterYourPasswordView from '../views/E2EEnterYourPasswordView'; + // InsideStackNavigator import AttachmentView from '../views/AttachmentView'; import ModalBlockView from '../views/ModalBlockView'; @@ -302,6 +309,43 @@ const NewMessageStackNavigator = () => { ); }; +// E2ESaveYourPasswordStackNavigator +const E2ESaveYourPasswordStack = createStackNavigator(); +const E2ESaveYourPasswordStackNavigator = () => { + const { theme } = React.useContext(ThemeContext); + + return ( + + + + + ); +}; + +// E2EEnterYourPasswordStackNavigator +const E2EEnterYourPasswordStack = createStackNavigator(); +const E2EEnterYourPasswordStackNavigator = () => { + const { theme } = React.useContext(ThemeContext); + + return ( + + + + ); +}; + // InsideStackNavigator const InsideStack = createStackNavigator(); const InsideStackNavigator = () => { @@ -319,6 +363,16 @@ const InsideStackNavigator = () => { component={NewMessageStackNavigator} options={{ headerShown: false }} /> + + { name='CreateDiscussionView' component={CreateDiscussionView} /> + + + { + const res = []; + let kValue; + let mappedValue; + + for (let k = 0, len = arr.length; k < len; k += 1) { + if ((typeof arr === 'string' && !!arr.charAt(k))) { + kValue = arr.charAt(k); + mappedValue = callback(kValue, k, arr); + res[k] = mappedValue; + } else if (typeof arr !== 'string' && k in arr) { + kValue = arr[k]; + mappedValue = callback(kValue, k, arr); + res[k] = mappedValue; + } + } + return res; +}; + +expect.extend({ + toBeEqual(a, b) { + let i; + const { length } = a; + if (length !== b.length) { + return { + pass: false + }; + } + for (i = 0; i < length; i += 1) { + if ((a[i] & 0xFF) !== (b[i] & 0xFF)) { + return { + pass: false + }; + } + } + return { + pass: true + }; + } +}); + +test('decode url-safe style base64 strings', () => { + const expected = [0xff, 0xff, 0xbe, 0xff, 0xef, 0xbf, 0xfb, 0xef, 0xff]; + + let str = '//++/++/++//'; + let actual = toByteArray(str); + for (let i = 0; i < actual.length; i += 1) { + expect(actual[i]).toBe(expected[i]); + } + + expect(byteLength(str)).toBe(actual.length); + + str = '__--_--_--__'; + actual = toByteArray(str); + for (let i = 0; i < actual.length; i += 1) { + expect(actual[i]).toBe(expected[i]); + } + + expect(byteLength(str)).toBe(actual.length); +}); + +test('padding bytes found inside base64 string', () => { + // See https://github.com/beatgammit/base64-js/issues/42 + const str = 'SQ==QU0='; + expect(toByteArray(str)).toEqual(new Uint8Array([73])); + expect(byteLength(str)).toBe(1); +}); + +const checks = [ + 'a', + 'aa', + 'aaa', + 'hi', + 'hi!', + 'hi!!', + 'sup', + 'sup?', + 'sup?!' +]; + +test('convert to base64 and back', () => { + for (let i = 0; i < checks.length; i += 1) { + const check = checks[i]; + + const b64Str = fromByteArray(map(check, char => char.charCodeAt(0))); + + const arr = toByteArray(b64Str); + const str = map(arr, byte => String.fromCharCode(byte)).join(''); + + expect(check).toBe(str); + expect(byteLength(b64Str)).toBe(arr.length); + } +}); + +const data = [ + [[0, 0, 0], 'AAAA'], + [[0, 0, 1], 'AAAB'], + [[0, 1, -1], 'AAH/'], + [[1, 1, 1], 'AQEB'], + [[0, -73, 23], 'ALcX'] +]; + +test('convert known data to string', () => { + for (let i = 0; i < data.length; i += 1) { + const bytes = data[i][0]; + const expected = data[i][1]; + const actual = fromByteArray(bytes); + expect(actual).toBe(expected); + } +}); + +test('convert known data from string', () => { + for (let i = 0; i < data.length; i += 1) { + const expected = data[i][0]; + const string = data[i][1]; + const actual = toByteArray(string); + expect(actual).toBeEqual(expected); + const length = byteLength(string); + expect(length).toBe(expected.length); + } +}); + +test('convert big data to base64', () => { + let i; + let length; + const big = new Uint8Array(64 * 1024 * 1024); + for (i = 0, length = big.length; i < length; i += 1) { + big[i] = i % 256; + } + const b64str = fromByteArray(big); + const arr = toByteArray(b64str); + expect(arr).toBeEqual(big); + expect(byteLength(b64str)).toBe(arr.length); +}); diff --git a/app/utils/base64-js/index.js b/app/utils/base64-js/index.js new file mode 100644 index 000000000..ef4bcd4ce --- /dev/null +++ b/app/utils/base64-js/index.js @@ -0,0 +1,141 @@ +/* eslint-disable no-bitwise */ +// https://github.com/beatgammit/base64-js/blob/master/index.js + +const lookup = []; +const revLookup = []; +const Arr = typeof Uint8Array !== 'undefined' ? Uint8Array : Array; + +const code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +for (let i = 0, len = code.length; i < len; i += 1) { + lookup[i] = code[i]; + revLookup[code.charCodeAt(i)] = i; +} + +// Support decoding URL-safe base64 strings, as Node.js does. +// See: https://en.wikipedia.org/wiki/Base64#URL_applications +revLookup['-'.charCodeAt(0)] = 62; +revLookup['_'.charCodeAt(0)] = 63; + +const getLens = (b64) => { + const len = b64.length; + + // We're encoding some strings not multiple of 4, so, disable this check + // if (len % 4 > 0) { + // throw new Error('Invalid string. Length must be a multiple of 4'); + // } + + // Trim off extra bytes after placeholder bytes are found + // See: https://github.com/beatgammit/base64-js/issues/42 + let validLen = b64.indexOf('='); + if (validLen === -1) { validLen = len; } + + const placeHoldersLen = validLen === len + ? 0 + : 4 - (validLen % 4); + + return [validLen, placeHoldersLen]; +}; + +// base64 is 4/3 + up to two characters of the original data +export const byteLength = (b64) => { + const lens = getLens(b64); + const validLen = lens[0]; + const placeHoldersLen = lens[1]; + return (((validLen + placeHoldersLen) * 3) / 4) - placeHoldersLen; +}; + +const _byteLength = (b64, validLen, placeHoldersLen) => (((validLen + placeHoldersLen) * 3) / 4) - placeHoldersLen; + +export const toByteArray = (b64) => { + let tmp; + const lens = getLens(b64); + const validLen = lens[0]; + const placeHoldersLen = lens[1]; + + const arr = new Arr(_byteLength(b64, validLen, placeHoldersLen)); + + let curByte = 0; + + // if there are placeholders, only get up to the last complete 4 chars + const len = placeHoldersLen > 0 + ? validLen - 4 + : validLen; + + let i; + for (i = 0; i < len; i += 4) { + tmp = (revLookup[b64.charCodeAt(i)] << 18) + | (revLookup[b64.charCodeAt(i + 1)] << 12) + | (revLookup[b64.charCodeAt(i + 2)] << 6) + | revLookup[b64.charCodeAt(i + 3)]; + arr[curByte] = (tmp >> 16) & 0xFF; + curByte += 1; + arr[curByte] = (tmp >> 8) & 0xFF; + curByte += 1; + arr[curByte] = tmp & 0xFF; + curByte += 1; + } + + if (placeHoldersLen === 2) { + tmp = (revLookup[b64.charCodeAt(i)] << 2) + | (revLookup[b64.charCodeAt(i + 1)] >> 4); + arr[curByte] = tmp & 0xFF; + curByte += 1; + } + + if (placeHoldersLen === 1) { + tmp = (revLookup[b64.charCodeAt(i)] << 10) + | (revLookup[b64.charCodeAt(i + 1)] << 4) + | (revLookup[b64.charCodeAt(i + 2)] >> 2); + arr[curByte] = (tmp >> 8) & 0xFF; + curByte += 1; + arr[curByte] = tmp & 0xFF; + curByte += 1; + } + + return arr; +}; + +const tripletToBase64 = num => lookup[(num >> 18) & 0x3F] + + lookup[(num >> 12) & 0x3F] + + lookup[(num >> 6) & 0x3F] + + lookup[num & 0x3F]; + +const encodeChunk = (uint8, start, end) => { + let tmp; + const output = []; + for (let i = start; i < end; i += 3) { + tmp = ((uint8[i] << 16) & 0xFF0000) + ((uint8[i + 1] << 8) & 0xFF00) + (uint8[i + 2] & 0xFF); + output.push(tripletToBase64(tmp)); + } + return output.join(''); +}; + +export const fromByteArray = (uint8) => { + let tmp; + const len = uint8.length; + const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes + const parts = []; + const maxChunkLength = 16383; // must be multiple of 3 + + // go through the array every three bytes, we'll deal with trailing stuff later + for (let i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) { + parts.push(encodeChunk( + uint8, i, (i + maxChunkLength) > len2 ? len2 : (i + maxChunkLength) + )); + } + + // pad the end with zeros, but make sure to not forget the extra bytes + if (extraBytes === 1) { + tmp = uint8[len - 1]; + parts.push( + `${ lookup[tmp >> 2] + lookup[(tmp << 4) & 0x3F] }==` + ); + } else if (extraBytes === 2) { + tmp = (uint8[len - 2] << 8) + uint8[len - 1]; + parts.push( + `${ lookup[tmp >> 10] + lookup[(tmp >> 4) & 0x3F] + lookup[(tmp << 2) & 0x3F] }=` + ); + } + + return parts.join(''); +}; diff --git a/app/utils/deferred.js b/app/utils/deferred.js new file mode 100644 index 000000000..0c0046e55 --- /dev/null +++ b/app/utils/deferred.js @@ -0,0 +1,14 @@ +// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm/Deferred +export default class Deferred { + constructor() { + const promise = new Promise((resolve, reject) => { + this.resolve = resolve; + this.reject = reject; + }); + + promise.resolve = this.resolve; + promise.reject = this.reject; + + return promise; + } +} diff --git a/app/utils/log/events.js b/app/utils/log/events.js index 9e1dfc9d6..e65b3ca66 100644 --- a/app/utils/log/events.js +++ b/app/utils/log/events.js @@ -58,6 +58,7 @@ export default { RL_ADD_SERVER: 'rl_add_server', RL_CHANGE_SERVER: 'rl_change_server', RL_GO_NEW_MSG: 'rl_go_new_msg', + RL_GO_E2E_SAVE_PASSWORD: 'rl_go_e2e_save_password', RL_SEARCH: 'rl_search', RL_GO_DIRECTORY: 'rl_go_directory', RL_GO_QUEUE: 'rl_go_queue', @@ -103,6 +104,7 @@ export default { CREATE_CHANNEL_TOGGLE_TYPE: 'create_channel_toggle_type', CREATE_CHANNEL_TOGGLE_READ_ONLY: 'create_channel_toggle_read_only', CREATE_CHANNEL_TOGGLE_BROADCAST: 'create_channel_toggle_broadcast', + CREATE_CHANNEL_TOGGLE_ENCRYPTED: 'create_channel_toggle_encrypted', CREATE_CHANNEL_REMOVE_USER: 'create_channel_remove_user', // CREATE DISCUSSION VIEW @@ -161,6 +163,7 @@ export default { // ROOM VIEW ROOM_SEND_MESSAGE: 'room_send_message', + ROOM_ENCRYPTED_PRESS: 'room_encrypted_press', ROOM_OPEN_EMOJI: 'room_open_emoji', ROOM_AUDIO_RECORD: 'room_audio_record', ROOM_AUDIO_RECORD_F: 'room_audio_record_f', @@ -233,6 +236,8 @@ export default { RA_LEAVE_F: 'ra_leave_f', RA_TOGGLE_BLOCK_USER: 'ra_toggle_block_user', RA_TOGGLE_BLOCK_USER_F: 'ra_toggle_block_user_f', + RA_TOGGLE_ENCRYPTED: 'ra_toggle_encrypted', + RA_TOGGLE_ENCRYPTED_F: 'ra_toggle_encrypted_f', // ROOM INFO VIEW RI_GO_RI_EDIT: 'ri_go_ri_edit', @@ -244,6 +249,7 @@ export default { RI_EDIT_TOGGLE_READ_ONLY: 'ri_edit_toggle_read_only', RI_EDIT_TOGGLE_REACTIONS: 'ri_edit_toggle_reactions', RI_EDIT_TOGGLE_SYSTEM_MSG: 'ri_edit_toggle_system_msg', + RI_EDIT_TOGGLE_ENCRYPTED: 'ri_edit_toggle_encrypted', RI_EDIT_SAVE: 'ri_edit_save', RI_EDIT_SAVE_F: 'ri_edit_save_f', RI_EDIT_RESET: 'ri_edit_reset', @@ -288,5 +294,13 @@ export default { NP_DESKTOPNOTIFICATIONDURATION: 'np_desktopnotificationduration', NP_DESKTOPNOTIFICATIONDURATION_F: 'np_desktopnotificationduration_f', NP_EMAILNOTIFICATIONS: 'np_email_notifications', - NP_EMAILNOTIFICATIONS_F: 'np_email_notifications_f' + NP_EMAILNOTIFICATIONS_F: 'np_email_notifications_f', + + // E2E SAVE YOUR PASSWORD VIEW + E2E_SAVE_PW_SAVED: 'e2e_save_pw_saved', + E2E_SAVE_PW_COPY: 'e2e_save_pw_copy', + E2E_SAVE_PW_HOW_IT_WORKS: 'e2e_save_pw_how_it_works', + + // E2E ENTER YOUR PASSWORD VIEW + E2E_ENTER_PW_SUBMIT: 'e2e_enter_pw_submit' }; diff --git a/app/views/CreateChannelView.js b/app/views/CreateChannelView.js index c4f2c5407..c78be31dd 100644 --- a/app/views/CreateChannelView.js +++ b/app/views/CreateChannelView.js @@ -85,6 +85,7 @@ class CreateChannelView extends React.Component { error: PropTypes.object, failure: PropTypes.bool, isFetching: PropTypes.bool, + e2eEnabled: PropTypes.bool, users: PropTypes.array.isRequired, user: PropTypes.shape({ id: PropTypes.string, @@ -97,14 +98,17 @@ class CreateChannelView extends React.Component { channelName: '', type: true, readOnly: false, + encrypted: false, broadcast: false } shouldComponentUpdate(nextProps, nextState) { const { - channelName, type, readOnly, broadcast + channelName, type, readOnly, broadcast, encrypted } = this.state; - const { users, isFetching, theme } = this.props; + const { + users, isFetching, e2eEnabled, theme + } = this.props; if (nextProps.theme !== theme) { return true; } @@ -117,12 +121,18 @@ class CreateChannelView extends React.Component { if (nextState.readOnly !== readOnly) { return true; } + if (nextState.encrypted !== encrypted) { + return true; + } if (nextState.broadcast !== broadcast) { return true; } if (nextProps.isFetching !== isFetching) { return true; } + if (nextProps.e2eEnabled !== e2eEnabled) { + return true; + } if (!equal(nextProps.users, users)) { return true; } @@ -147,7 +157,7 @@ class CreateChannelView extends React.Component { submit = () => { const { - channelName, type, readOnly, broadcast + channelName, type, readOnly, broadcast, encrypted } = this.state; const { users: usersProps, isFetching, create } = this.props; @@ -160,7 +170,7 @@ class CreateChannelView extends React.Component { // create channel create({ - name: channelName, users, type, readOnly, broadcast + name: channelName, users, type, readOnly, broadcast, encrypted }); Review.pushPositiveEvent(); @@ -198,7 +208,8 @@ class CreateChannelView extends React.Component { label: 'Private_Channel', onValueChange: (value) => { logEvent(events.CREATE_CHANNEL_TOGGLE_TYPE); - this.setState({ type: value }); + // If we set the channel as public, encrypted status should be false + this.setState(({ encrypted }) => ({ type: value, encrypted: value && encrypted })); } }); } @@ -217,6 +228,26 @@ class CreateChannelView extends React.Component { }); } + renderEncrypted() { + const { type, encrypted } = this.state; + const { e2eEnabled } = this.props; + + if (!e2eEnabled) { + return null; + } + + return this.renderSwitch({ + id: 'encrypted', + value: encrypted, + label: 'Encrypted', + onValueChange: (value) => { + logEvent(events.CREATE_CHANNEL_TOGGLE_ENCRYPTED); + this.setState({ encrypted: value }); + }, + disabled: !type + }); + } + renderBroadcast() { const { broadcast, readOnly } = this.state; return this.renderSwitch({ @@ -315,6 +346,8 @@ class CreateChannelView extends React.Component { {this.renderFormSeparator()} {this.renderReadOnly()} {this.renderFormSeparator()} + {this.renderEncrypted()} + {this.renderFormSeparator()} {this.renderBroadcast()} @@ -333,6 +366,7 @@ class CreateChannelView extends React.Component { const mapStateToProps = state => ({ baseUrl: state.server.server, isFetching: state.createChannel.isFetching, + e2eEnabled: state.settings.E2E_Enable, users: state.selectedUsers.users, user: getUserSelector(state) }); diff --git a/app/views/E2EEnterYourPasswordView.js b/app/views/E2EEnterYourPasswordView.js new file mode 100644 index 000000000..3b607577e --- /dev/null +++ b/app/views/E2EEnterYourPasswordView.js @@ -0,0 +1,98 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Text, StyleSheet, ScrollView } from 'react-native'; +import { connect } from 'react-redux'; + +import I18n from '../i18n'; +import sharedStyles from './Styles'; +import { withTheme } from '../theme'; +import Button from '../containers/Button'; +import { themes } from '../constants/colors'; +import TextInput from '../containers/TextInput'; +import SafeAreaView from '../containers/SafeAreaView'; +import { CloseModalButton } from '../containers/HeaderButton'; +import { encryptionDecodeKey as encryptionDecodeKeyAction } from '../actions/encryption'; +import scrollPersistTaps from '../utils/scrollPersistTaps'; +import KeyboardView from '../presentation/KeyboardView'; +import StatusBar from '../containers/StatusBar'; +import { logEvent, events } from '../utils/log'; + +const styles = StyleSheet.create({ + container: { + padding: 28 + }, + info: { + fontSize: 14, + marginVertical: 8, + ...sharedStyles.textRegular + } +}); +class E2EEnterYourPasswordView extends React.Component { + static navigationOptions = ({ navigation }) => ({ + headerLeft: () => , + title: I18n.t('Enter_Your_E2E_Password') + }) + + static propTypes = { + encryptionDecodeKey: PropTypes.func, + theme: PropTypes.string + } + + constructor(props) { + super(props); + this.state = { + password: '' + }; + } + + submit = () => { + logEvent(events.E2E_ENTER_PW_SUBMIT); + const { password } = this.state; + const { encryptionDecodeKey } = this.props; + encryptionDecodeKey(password); + } + + render() { + const { password } = this.state; + const { theme } = this.props; + + return ( + + + + + { this.passwordInput = e; }} + placeholder={I18n.t('Password')} + returnKeyType='send' + secureTextEntry + onSubmitEditing={this.submit} + onChangeText={value => this.setState({ password: value })} + testID='e2e-enter-your-password-view-password' + textContentType='password' + autoCompleteType='password' + theme={theme} + /> +