[NEW] E2E Encryption (#2394)

* Add E2EKey to Subscription Model

* Install react-native-simple-crypto

* Install bytebuffer

* Add translations

* CreateChannel Encrypted toggle

* Request E2E_Enabled setting

* Add some E2E API methods

* POC E2E Encryption

* Garbage remove

* Remove keys cleaner

* Android cast JWK -> PKCS1

* Initialize E2E when Login Success

* Add some translations

* Add e2e property to Message model

* Send Encrypted messages

* (iOS) PKCS1 -> JWK & e2e.setUserPublicAndPrivateKeys

* (Android) PKCS1 -> JWK & e2e.setUserPublicAndPrivateKeys

* Create an encrypted channel

* Fix app crashing on RoomsList

* Create room key

* Set Room E2E Key (Android)

* Edit room encrypted

* Show encrypted icon on messages

* logEvents

* Decrypt pending subscriptions & messages

* Handle user cancel e2e password entry

* E2ESavePasswordView

* Update Snapshot

* Add encrypted props to message on Send

* Thread messages encryption

* E2E -> Encryption

* Share Extension: Share encrypted text

* (POC) Search messages on Encrypted room

* Provide room key to new users

* Request roomKey on stream-notify-room-users

* Add e2eKeyId to Room Model

* (WIP) E2E Encryption Screens

* Remove encryption subscription file

* Move E2E_Enable to Server Model

* Encryption List Banner

* Move Encryption init to Sagas

* Show banner only when enabled

* Use RocketChat/react-native-simple-crypto

* Search on WM only when is an Encrypted channel

* (WIP) Encryption Banner

* Encryption banner

* Patch -> Fork

* Improve send encrypted message

* Update simple-crypto

* Not decrypt already decrypted messages

* Add comments

* Change eslint disable to inline

* Improve code

* Remove comment

* Some fixes

* (WIP) Encryption Screens

* Improve sub find

* Resend an encrypted message

* Fix comment

* Code improvements

* Hide e2e buttons on features if it is not enabled

* InApp notifications of a encrypted room

* Encryption stop logic

* Edit encrypted message

* DB batch on decryptPending

* Encryption ready client

* Comments

* Handle getRoomInstance errors

* Multiple messages decrypt

* Remove unnecessary try/catch

* Fix decrypt all messages history

* Just add a questionmark

* Fix some subscriptions missing decrypt

* Disable request key logic

* Fix unicode emojis

* Fix e2ekey request

* roomId -> subscription

* Decrypt subscription after merge

* E2ERoom -> EncryptionRoom

* Fix infinite loading

* Handle import key errors

* Handle request key errors

* Move e2eRequestRoomKey to Rocket.Chat

* WIP handshake when key should be requested

* Add search messages explanation

* Remove some TODO and update comments

* Improvements

* Dont show message hash to user

* Handle key request & prevent multiple calls

* Request E2EKey on decryptSubscription that doesn't exists on database yet

* Insert decrypted subscription

* Fix crash after login

* Decrypt sub when receive the key

* Decrypt pending messages of a room

* Encrypted as a switch

* Buffer to Base64 URI Safe

* Add a relevant comment

* Prevent import key without a privateKey

* Prevent create a new instance when client is not ready

* Update simple-crypto & remove replace trick

* More comments

* Remove useless comment

* Remove useless try/catch

* I18n all E2E screens

* E2ESavePassword -> E2ESaveYourPassword

* Prevent multiple views on message when is not encrypted

* Fix encryption toggle not working sometimes

* follow some suggestions

* dont rotate icons

* remove unnecessary condition

* remove unreachable event

* create channel comment

* disable no-bitwise rule for entire file

* loadKeys -> persistKeys

* getMasterKey -> generateMasterKey

* explicit difference between E2EKey & e2eKeyId

* roomId -> rid

* group columns

* Remove server selector

* missing log events

* remove comment

* use stored public key

* update simple-crypto & remove base64-js patch

* add some logs

* remove unreachable condition

* log errors

* handle errors on provide key directly on subscription

* Downgrade RocketChat/react-native-simple-crypto

* improve get room instance

* migration of older apps

* check encrypted status before send a message

* wait client ready

* use our own base64-js

* add more jest tests

* explain return

* remove unncessary stop

* thrown error to caller

* remove superfluous checks

* use Encryption property

* change ready state logic

* ready -> establishing

* encryption.room -> encryptionRoom

* EncryptionRoom -> Room

* add documentation

* wait establishing before provide a room key

* remove superfluous condition

* improve error handling logic

* fallback e2ekey set

* remove no longer necessary check

* remove e.g.

* improve getRoomInstance

* import from index

* use batch

* fix a comment

* decrypt tmsg

* dont show hash when message is encrypted

* Fix detox

* Apply suggestions from code review

Co-authored-by: Diego Mello <diegolmello@gmail.com>
This commit is contained in:
Djorkaeff Alexandre 2020-09-11 11:31:38 -03:00 committed by GitHub
parent e9531298e7
commit 3c9017a62d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
121 changed files with 13039 additions and 9756 deletions

View File

@ -3333,6 +3333,165 @@ exports[`Storyshots Message list message 1`] = `
> >
Edited Edited
</Text> </Text>
<Text
style={
Array [
Object {
"fontSize": 20,
"fontWeight": "300",
"marginLeft": 10,
"marginVertical": 30,
},
Object {
"color": "#0d0e12",
},
Object {
"marginBottom": 0,
"marginTop": 30,
},
]
}
>
Encrypted
</Text>
<View>
<View
style={
Array [
Object {
"flexDirection": "column",
"paddingHorizontal": 14,
"paddingVertical": 4,
"width": "100%",
},
undefined,
]
}
>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<View
style={
Array [
Object {
"borderRadius": 4,
"height": 36,
"width": 36,
},
Object {
"marginTop": 4,
},
]
}
/>
<View
style={
Array [
Object {
"flex": 1,
"marginLeft": 46,
},
Object {
"marginLeft": 10,
},
]
}
>
<View
style={
Object {
"alignItems": "center",
"flex": 1,
"flexDirection": "row",
}
}
>
<Text
style={
Array [
Object {
"backgroundColor": "transparent",
"fontFamily": "System",
"fontSize": 12,
"fontWeight": "300",
"lineHeight": 22,
"paddingLeft": 10,
},
Object {
"color": "#9ca2a8",
},
]
}
>
10:00 AM
</Text>
</View>
<View>
<View
style={
Object {
"flexDirection": "row",
}
}
>
<View
style={
Object {
"flex": 1,
}
}
>
<Text
numberOfLines={0}
style={
Array [
undefined,
Object {
"color": "#2f343d",
},
]
}
>
<Text
accessibilityLabel="This message has error and is encrypted"
numberOfLines={0}
style={
Array [
Object {
"backgroundColor": "transparent",
"fontFamily": "System",
"fontSize": 16,
"fontWeight": "400",
},
Array [
Object {},
Object {
"alignItems": "flex-start",
"flexDirection": "row",
"flexWrap": "wrap",
"justifyContent": "flex-start",
"marginBottom": 0,
"marginTop": 0,
},
],
]
}
>
This message has error and is encrypted
</Text>
</Text>
</View>
</View>
</View>
</View>
</View>
</View>
</View>
<Text <Text
style={ style={
Array [ Array [

View File

@ -67,3 +67,4 @@ export const INVITE_LINKS = createRequestTypes('INVITE_LINKS', [
export const SETTINGS = createRequestTypes('SETTINGS', ['CLEAR', 'ADD']); export const SETTINGS = createRequestTypes('SETTINGS', ['CLEAR', 'ADD']);
export const APP_STATE = createRequestTypes('APP_STATE', ['FOREGROUND', 'BACKGROUND']); export const APP_STATE = createRequestTypes('APP_STATE', ['FOREGROUND', 'BACKGROUND']);
export const ENTERPRISE_MODULES = createRequestTypes('ENTERPRISE_MODULES', ['CLEAR', 'SET']); export const ENTERPRISE_MODULES = createRequestTypes('ENTERPRISE_MODULES', ['CLEAR', 'SET']);
export const ENCRYPTION = createRequestTypes('ENCRYPTION', ['INIT', 'STOP', 'DECODE_KEY', 'SET_BANNER']);

27
app/actions/encryption.js Normal file
View File

@ -0,0 +1,27 @@
import * as types from './actionsTypes';
export function encryptionInit() {
return {
type: types.ENCRYPTION.INIT
};
}
export function encryptionStop() {
return {
type: types.ENCRYPTION.STOP
};
}
export function encryptionSetBanner(banner) {
return {
type: types.ENCRYPTION.SET_BANNER,
banner
};
}
export function encryptionDecodeKey(password) {
return {
type: types.ENCRYPTION.DECODE_KEY,
password
};
}

View File

@ -68,6 +68,9 @@ export default {
DirectMesssage_maxUsers: { DirectMesssage_maxUsers: {
type: 'valueAsNumber' type: 'valueAsNumber'
}, },
E2E_Enable: {
type: 'valueAsBoolean'
},
Accounts_Directory_DefaultView: { Accounts_Directory_DefaultView: {
type: 'valueAsString' type: 'valueAsString'
}, },

View File

@ -9,6 +9,8 @@ import Markdown from '../markdown';
import { getInfoMessage } from './utils'; import { getInfoMessage } from './utils';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import MessageContext from './Context'; import MessageContext from './Context';
import Encrypted from './Encrypted';
import { E2E_MESSAGE_TYPE } from '../../lib/encryption/constants';
const Content = React.memo((props) => { const Content = React.memo((props) => {
if (props.isInfo) { if (props.isInfo) {
@ -22,10 +24,13 @@ const Content = React.memo((props) => {
); );
} }
const isPreview = props.tmid && !props.isThreadRoom;
let content = null; let content = null;
if (props.tmid && !props.msg) { if (props.tmid && !props.msg) {
content = <Text style={[styles.text, { color: themes[props.theme].bodyText }]}>{I18n.t('Sent_an_attachment')}</Text>; content = <Text style={[styles.text, { color: themes[props.theme].bodyText }]}>{I18n.t('Sent_an_attachment')}</Text>;
} else if (props.isEncrypted) {
content = <Text style={[styles.textInfo, { color: themes[props.theme].auxiliaryText }]}>{I18n.t('Encrypted_message')}</Text>;
} else { } else {
const { baseUrl, user } = useContext(MessageContext); const { baseUrl, user } = useContext(MessageContext);
content = ( content = (
@ -35,8 +40,8 @@ const Content = React.memo((props) => {
getCustomEmoji={props.getCustomEmoji} getCustomEmoji={props.getCustomEmoji}
username={user.username} username={user.username}
isEdited={props.isEdited} isEdited={props.isEdited}
numberOfLines={(props.tmid && !props.isThreadRoom) ? 1 : 0} numberOfLines={isPreview ? 1 : 0}
preview={props.tmid && !props.isThreadRoom} preview={isPreview}
channels={props.channels} channels={props.channels}
mentions={props.mentions} mentions={props.mentions}
navToRoomInfo={props.navToRoomInfo} 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 = (
<View style={styles.flex}>
<View style={styles.contentContainer}>
{content}
</View>
<Encrypted
type={props.type}
theme={props.theme}
/>
</View>
);
}
return ( return (
<View style={props.isTemp && styles.temp}> <View style={props.isTemp && styles.temp}>
{content} {content}
@ -59,9 +79,15 @@ const Content = React.memo((props) => {
if (prevProps.msg !== nextProps.msg) { if (prevProps.msg !== nextProps.msg) {
return false; return false;
} }
if (prevProps.type !== nextProps.type) {
return false;
}
if (prevProps.theme !== nextProps.theme) { if (prevProps.theme !== nextProps.theme) {
return false; return false;
} }
if (prevProps.isEncrypted !== nextProps.isEncrypted) {
return false;
}
if (!equal(prevProps.mentions, nextProps.mentions)) { if (!equal(prevProps.mentions, nextProps.mentions)) {
return false; return false;
} }
@ -79,11 +105,13 @@ Content.propTypes = {
msg: PropTypes.string, msg: PropTypes.string,
theme: PropTypes.string, theme: PropTypes.string,
isEdited: PropTypes.bool, isEdited: PropTypes.bool,
isEncrypted: PropTypes.bool,
getCustomEmoji: PropTypes.func, getCustomEmoji: PropTypes.func,
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]), mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
navToRoomInfo: PropTypes.func, navToRoomInfo: PropTypes.func,
useRealName: PropTypes.bool useRealName: PropTypes.bool,
type: PropTypes.string
}; };
Content.displayName = 'MessageContent'; Content.displayName = 'MessageContent';

View File

@ -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 (
<Touchable onPress={onEncryptedPress} style={styles.encrypted} hitSlop={BUTTON_HIT_SLOP}>
<CustomIcon name='encrypted' size={16} color={themes[theme].auxiliaryText} />
</Touchable>
);
});
Encrypted.propTypes = {
type: PropTypes.string,
theme: PropTypes.string
};
export default Encrypted;

View File

@ -8,9 +8,10 @@ import { CustomIcon } from '../../lib/Icons';
import DisclosureIndicator from '../DisclosureIndicator'; import DisclosureIndicator from '../DisclosureIndicator';
import styles from './styles'; import styles from './styles';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import I18n from '../../i18n';
const RepliedThread = React.memo(({ const RepliedThread = React.memo(({
tmid, tmsg, isHeader, fetchThreadName, id, theme tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme
}) => { }) => {
if (!tmid || !isHeader) { if (!tmid || !isHeader) {
return null; return null;
@ -24,6 +25,10 @@ const RepliedThread = React.memo(({
let msg = shortnameToUnicode(tmsg); let msg = shortnameToUnicode(tmsg);
msg = removeMarkdown(msg); msg = removeMarkdown(msg);
if (isEncrypted) {
msg = I18n.t('Encrypted_message');
}
return ( return (
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}> <View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
<CustomIcon name='threads' size={20} style={styles.repliedThreadIcon} color={themes[theme].tintColor} /> <CustomIcon name='threads' size={20} style={styles.repliedThreadIcon} color={themes[theme].tintColor} />
@ -38,6 +43,9 @@ const RepliedThread = React.memo(({
if (prevProps.tmsg !== nextProps.tmsg) { if (prevProps.tmsg !== nextProps.tmsg) {
return false; return false;
} }
if (prevProps.isEncrypted !== nextProps.isEncrypted) {
return false;
}
if (prevProps.isHeader !== nextProps.isHeader) { if (prevProps.isHeader !== nextProps.isHeader) {
return false; return false;
} }
@ -53,7 +61,8 @@ RepliedThread.propTypes = {
id: PropTypes.string, id: PropTypes.string,
isHeader: PropTypes.bool, isHeader: PropTypes.bool,
theme: PropTypes.string, theme: PropTypes.string,
fetchThreadName: PropTypes.func fetchThreadName: PropTypes.func,
isEncrypted: PropTypes.bool
}; };
RepliedThread.displayName = 'MessageRepliedThread'; RepliedThread.displayName = 'MessageRepliedThread';

View File

@ -8,6 +8,7 @@ import debounce from '../../utils/debounce';
import { SYSTEM_MESSAGES, getMessageTranslation } from './utils'; import { SYSTEM_MESSAGES, getMessageTranslation } from './utils';
import messagesStatus from '../../constants/messagesStatus'; import messagesStatus from '../../constants/messagesStatus';
import { withTheme } from '../../theme'; import { withTheme } from '../../theme';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
class MessageContainer extends React.Component { class MessageContainer extends React.Component {
static propTypes = { static propTypes = {
@ -35,6 +36,7 @@ class MessageContainer extends React.Component {
getCustomEmoji: PropTypes.func, getCustomEmoji: PropTypes.func,
onLongPress: PropTypes.func, onLongPress: PropTypes.func,
onReactionPress: PropTypes.func, onReactionPress: PropTypes.func,
onEncryptedPress: PropTypes.func,
onDiscussionPress: PropTypes.func, onDiscussionPress: PropTypes.func,
onThreadPress: PropTypes.func, onThreadPress: PropTypes.func,
errorActionsShow: PropTypes.func, errorActionsShow: PropTypes.func,
@ -53,6 +55,7 @@ class MessageContainer extends React.Component {
getCustomEmoji: () => {}, getCustomEmoji: () => {},
onLongPress: () => {}, onLongPress: () => {},
onReactionPress: () => {}, onReactionPress: () => {},
onEncryptedPress: () => {},
onDiscussionPress: () => {}, onDiscussionPress: () => {},
onThreadPress: () => {}, onThreadPress: () => {},
errorActionsShow: () => {}, errorActionsShow: () => {},
@ -104,7 +107,7 @@ class MessageContainer extends React.Component {
onLongPress = () => { onLongPress = () => {
const { archived, onLongPress, item } = this.props; const { archived, onLongPress, item } = this.props;
if (this.isInfo || this.hasError || archived) { if (this.isInfo || this.hasError || this.isEncrypted || archived) {
return; return;
} }
if (onLongPress) { if (onLongPress) {
@ -133,6 +136,13 @@ class MessageContainer extends React.Component {
} }
} }
onEncryptedPress = () => {
const { onEncryptedPress } = this.props;
if (onEncryptedPress) {
onEncryptedPress();
}
}
onDiscussionPress = () => { onDiscussionPress = () => {
const { onDiscussionPress, item } = this.props; const { onDiscussionPress, item } = this.props;
if (onDiscussionPress) { if (onDiscussionPress) {
@ -196,6 +206,12 @@ class MessageContainer extends React.Component {
return false; return false;
} }
get isEncrypted() {
const { item } = this.props;
const { t, e2e } = item;
return t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE;
}
get isInfo() { get isInfo() {
const { item } = this.props; const { item } = this.props;
return SYSTEM_MESSAGES.includes(item.t); return SYSTEM_MESSAGES.includes(item.t);
@ -251,6 +267,7 @@ class MessageContainer extends React.Component {
onErrorPress: this.onErrorPress, onErrorPress: this.onErrorPress,
replyBroadcast: this.replyBroadcast, replyBroadcast: this.replyBroadcast,
onReactionPress: this.onReactionPress, onReactionPress: this.onReactionPress,
onEncryptedPress: this.onEncryptedPress,
onDiscussionPress: this.onDiscussionPress, onDiscussionPress: this.onDiscussionPress,
onReactionLongPress: this.onReactionLongPress onReactionLongPress: this.onReactionLongPress
}} }}
@ -295,6 +312,7 @@ class MessageContainer extends React.Component {
isThreadRoom={isThreadRoom} isThreadRoom={isThreadRoom}
isInfo={this.isInfo} isInfo={this.isInfo}
isTemp={this.isTemp} isTemp={this.isTemp}
isEncrypted={this.isEncrypted}
hasError={this.hasError} hasError={this.hasError}
showAttachment={showAttachment} showAttachment={showAttachment}
getCustomEmoji={getCustomEmoji} getCustomEmoji={getCustomEmoji}

View File

@ -13,6 +13,9 @@ export default StyleSheet.create({
paddingHorizontal: 14, paddingHorizontal: 14,
flexDirection: 'column' flexDirection: 'column'
}, },
contentContainer: {
flex: 1
},
messageContent: { messageContent: {
flex: 1, flex: 1,
marginLeft: 46 marginLeft: 46
@ -163,5 +166,8 @@ export default StyleSheet.create({
}, },
readReceipt: { readReceipt: {
lineHeight: 20 lineHeight: 20
},
encrypted: {
justifyContent: 'center'
} }
}); });

View File

@ -198,11 +198,17 @@ export default {
Do_you_have_an_account: 'Do you have an account?', Do_you_have_an_account: 'Do you have an account?',
Do_you_have_a_certificate: 'Do you have a certificate?', 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?', 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', edit: 'edit',
edited: 'edited', edited: 'edited',
Edit: 'Edit', Edit: 'Edit',
Edit_Status: 'Edit Status', Edit_Status: 'Edit Status',
Edit_Invite: 'Edit Invite', 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_All: 'Every Mention/DM',
Email_Notification_Mode_Disabled: 'Disabled', Email_Notification_Mode_Disabled: 'Disabled',
Email_or_password_field_is_empty: 'Email or password field is empty', Email_or_password_field_is_empty: 'Email or password field is empty',
@ -212,6 +218,13 @@ export default {
Empty_title: 'Empty title', Empty_title: 'Empty title',
Enable_Auto_Translate: 'Enable Auto-Translate', Enable_Auto_Translate: 'Enable Auto-Translate',
Enable_notifications: 'Enable notifications', 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', Everyone_can_access_this_channel: 'Everyone can access this channel',
Error_uploading: 'Error uploading', Error_uploading: 'Error uploading',
Expiration_Days: 'Expiration (Days)', Expiration_Days: 'Expiration (Days)',
@ -240,6 +253,7 @@ export default {
Has_left_the_channel: 'Has left the channel', Has_left_the_channel: 'Has left the channel',
Hide_System_Messages: 'Hide System Messages', Hide_System_Messages: 'Hide System Messages',
Hide_type_messages: 'Hide "{{type}}" messages', Hide_type_messages: 'Hide "{{type}}" messages',
How_It_Works: 'How It Works',
Message_HideType_uj: 'User Join', Message_HideType_uj: 'User Join',
Message_HideType_ul: 'User Leave', Message_HideType_ul: 'User Leave',
Message_HideType_ru: 'User Removed', Message_HideType_ru: 'User Removed',
@ -253,6 +267,7 @@ export default {
Message_HideType_subscription_role_removed: 'Role No Longer Defined', Message_HideType_subscription_role_removed: 'Role No Longer Defined',
Message_HideType_room_archived: 'Room Archived', Message_HideType_room_archived: 'Room Archived',
Message_HideType_room_unarchived: 'Room Unarchived', Message_HideType_room_unarchived: 'Room Unarchived',
I_Saved_My_E2E_Password: 'I Saved My E2E Password',
IP: 'IP', IP: 'IP',
In_app: 'In-app', In_app: 'In-app',
IN_APP_AND_DESKTOP: 'IN-APP AND DESKTOP', IN_APP_AND_DESKTOP: 'IN-APP AND DESKTOP',
@ -438,6 +453,10 @@ export default {
saving_profile: 'saving profile', saving_profile: 'saving profile',
saving_settings: 'saving settings', saving_settings: 'saving settings',
saved_to_gallery: 'Saved to gallery', 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_Messages: 'Search Messages',
Search: 'Search', Search: 'Search',
Search_by: 'Search by', 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_expire_on__date__: 'Your invite link will expire on {{date}}.',
Your_invite_link_will_never_expire: 'Your invite link will never expire.', Your_invite_link_will_never_expire: 'Your invite link will never expire.',
Your_workspace: 'Your workspace', Your_workspace: 'Your workspace',
Your_password_is: 'Your password is',
Version_no: 'Version: {{version}}', Version_no: 'Version: {{version}}',
You_will_not_be_able_to_recover_this_message: 'You will not be able to recover this message!', 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', You_will_unset_a_certificate_for_this_server: 'You will unset a certificate for this server',

View File

@ -192,11 +192,17 @@ export default {
Dont_Have_An_Account: 'Não tem uma conta?', Dont_Have_An_Account: 'Não tem uma conta?',
Do_you_have_an_account: 'Você 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?', 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', edit: 'editar',
edited: 'editado', edited: 'editado',
Edit: 'Editar', Edit: 'Editar',
Edit_Invite: 'Editar convite', Edit_Invite: 'Editar convite',
Edit_Status: 'Editar Status', 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_or_password_field_is_empty: 'Email ou senha estão vazios',
Email: 'Email', Email: 'Email',
email: 'e-mail', email: 'e-mail',
@ -205,6 +211,13 @@ export default {
Email_Notification_Mode_Disabled: 'Desativado', Email_Notification_Mode_Disabled: 'Desativado',
Enable_Auto_Translate: 'Ativar a tradução automática', Enable_Auto_Translate: 'Ativar a tradução automática',
Enable_notifications: 'Habilitar notificações', 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', Everyone_can_access_this_channel: 'Todos podem acessar este canal',
Error_uploading: 'Erro subindo', Error_uploading: 'Erro subindo',
Expiration_Days: 'Expira em (dias)', Expiration_Days: 'Expira em (dias)',
@ -410,6 +423,10 @@ export default {
saving_profile: 'salvando perfil', saving_profile: 'salvando perfil',
saving_settings: 'salvando configurações', saving_settings: 'salvando configurações',
saved_to_gallery: 'Salvo na galeria', 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_Messages: 'Buscar Mensagens',
Search: 'Buscar', Search: 'Buscar',
Search_by: 'Buscar por', Search_by: 'Buscar por',

View File

@ -77,4 +77,6 @@ export default class Message extends Model {
@field('tmsg') tmsg; @field('tmsg') tmsg;
@json('blocks', sanitizer) blocks; @json('blocks', sanitizer) blocks;
@field('e2e') e2e;
} }

View File

@ -12,6 +12,8 @@ export default class Room extends Model {
@field('encrypted') encrypted; @field('encrypted') encrypted;
@field('e2e_key_id') e2eKeyId;
@field('ro') ro; @field('ro') ro;
@json('v', sanitizer) v; @json('v', sanitizer) v;

View File

@ -29,4 +29,6 @@ export default class Server extends Model {
@field('unique_id') uniqueID; @field('unique_id') uniqueID;
@field('enterprise_modules') enterpriseModules; @field('enterprise_modules') enterpriseModules;
@field('e2e_enable') E2E_Enable;
} }

View File

@ -109,4 +109,10 @@ export default class Subscription extends Model {
@json('livechat_data', sanitizer) livechatData; @json('livechat_data', sanitizer) livechatData;
@json('tags', sanitizer) tags; @json('tags', sanitizer) tags;
@field('e2e_key') E2EKey;
@field('encrypted') encrypted;
@field('e2e_key_id') e2eKeyId;
} }

View File

@ -73,4 +73,6 @@ export default class Thread extends Model {
@field('auto_translate') autoTranslate; @field('auto_translate') autoTranslate;
@json('translations', sanitizer) translations; @json('translations', sanitizer) translations;
@field('e2e') e2e;
} }

View File

@ -75,4 +75,6 @@ export default class ThreadMessage extends Model {
@json('translations', sanitizer) translations; @json('translations', sanitizer) translations;
@field('draft_message') draftMessage; @field('draft_message') draftMessage;
@field('e2e') e2e;
} }

View File

@ -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 }
]
})
]
} }
] ]
}); });

View File

@ -59,6 +59,17 @@ export default schemaMigrations({
] ]
}) })
] ]
},
{
toVersion: 8,
steps: [
addColumns({
table: 'servers',
columns: [
{ name: 'e2e_enable', type: 'boolean', isOptional: true }
]
})
]
} }
] ]
}); });

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export default appSchema({ export default appSchema({
version: 9, version: 10,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'subscriptions', name: 'subscriptions',
@ -49,7 +49,10 @@ export default appSchema({
{ name: 'department_id', type: 'string', isOptional: true }, { name: 'department_id', type: 'string', isOptional: true },
{ name: 'served_by', type: 'string', isOptional: true }, { name: 'served_by', type: 'string', isOptional: true },
{ name: 'livechat_data', 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({ tableSchema({
@ -63,7 +66,8 @@ export default appSchema({
{ name: 'department_id', type: 'string', isOptional: true }, { name: 'department_id', type: 'string', isOptional: true },
{ name: 'served_by', type: 'string', isOptional: true }, { name: 'served_by', type: 'string', isOptional: true },
{ name: 'livechat_data', 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({ tableSchema({
@ -101,7 +105,8 @@ export default appSchema({
{ name: 'auto_translate', type: 'boolean', isOptional: true }, { name: 'auto_translate', type: 'boolean', isOptional: true },
{ name: 'translations', type: 'string', isOptional: true }, { name: 'translations', type: 'string', isOptional: true },
{ name: 'tmsg', 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({ tableSchema({
@ -137,7 +142,8 @@ export default appSchema({
{ name: 'channels', type: 'string', isOptional: true }, { name: 'channels', type: 'string', isOptional: true },
{ name: 'unread', type: 'boolean', isOptional: true }, { name: 'unread', type: 'boolean', isOptional: true },
{ name: 'auto_translate', 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({ tableSchema({
@ -173,7 +179,8 @@ export default appSchema({
{ name: 'channels', type: 'string', isOptional: true }, { name: 'channels', type: 'string', isOptional: true },
{ name: 'unread', type: 'boolean', isOptional: true }, { name: 'unread', type: 'boolean', isOptional: true },
{ name: 'auto_translate', 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({ tableSchema({

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb'; import { appSchema, tableSchema } from '@nozbe/watermelondb';
export default appSchema({ export default appSchema({
version: 7, version: 8,
tables: [ tables: [
tableSchema({ tableSchema({
name: 'users', name: 'users',
@ -31,7 +31,8 @@ export default appSchema({
{ name: 'auto_lock_time', type: 'number', isOptional: true }, { name: 'auto_lock_time', type: 'number', isOptional: true },
{ name: 'biometry', type: 'boolean', isOptional: true }, { name: 'biometry', type: 'boolean', isOptional: true },
{ name: 'unique_id', type: 'string', 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 }
] ]
}) })
] ]

View File

@ -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.

View File

@ -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'
};

View File

@ -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;

View File

@ -0,0 +1,4 @@
import Encryption from './encryption';
import EncryptionRoom from './room';
export { Encryption, EncryptionRoom };

255
app/lib/encryption/room.js Normal file
View File

@ -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;
}
}

View File

@ -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();

View File

@ -11,7 +11,16 @@ import protectedFunction from './helpers/protectedFunction';
import fetch from '../../utils/fetch'; import fetch from '../../utils/fetch';
import { DEFAULT_AUTO_LOCK } from '../../constants/localAuthentication'; 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 // these settings are used only on onboarding process
const loginSettings = [ const loginSettings = [
@ -71,6 +80,9 @@ const serverInfoUpdate = async(serverInfo, iconSetting) => {
if (setting._id === 'uniqueID') { if (setting._id === 'uniqueID') {
return { ...allSettings, uniqueID: setting.valueAsString }; return { ...allSettings, uniqueID: setting.valueAsString };
} }
if (setting._id === 'E2E_Enable') {
return { ...allSettings, E2E_Enable: setting.valueAsBoolean };
}
return allSettings; return allSettings;
}, {}); }, {});

View File

@ -49,7 +49,10 @@ export default async(subscriptions = [], rooms = []) => {
departmentId: s.departmentId, departmentId: s.departmentId,
servedBy: s.servedBy, servedBy: s.servedBy,
livechatData: s.livechatData, livechatData: s.livechatData,
tags: s.tags tags: s.tags,
encrypted: s.encrypted,
e2eKeyId: s.e2eKeyId,
E2EKey: s.E2EKey
})); }));
subscriptions = subscriptions.concat(existingSubs); subscriptions = subscriptions.concat(existingSubs);
@ -75,7 +78,9 @@ export default async(subscriptions = [], rooms = []) => {
departmentId: r.departmentId, departmentId: r.departmentId,
servedBy: r.servedBy, servedBy: r.servedBy,
livechatData: r.livechatData, livechatData: r.livechatData,
tags: r.tags tags: r.tags,
encrypted: r.encrypted,
e2eKeyId: r.e2eKeyId
})); }));
rooms = rooms.concat(existingRooms); rooms = rooms.concat(existingRooms);
} catch { } catch {

View File

@ -2,6 +2,7 @@ import EJSON from 'ejson';
import normalizeMessage from './normalizeMessage'; import normalizeMessage from './normalizeMessage';
import findSubscriptionsRooms from './findSubscriptionsRooms'; import findSubscriptionsRooms from './findSubscriptionsRooms';
import { Encryption } from '../../encryption';
// TODO: delete and update // TODO: delete and update
export const merge = (subscription, room) => { export const merge = (subscription, room) => {
@ -27,6 +28,8 @@ export const merge = (subscription, room) => {
} }
subscription.ro = room.ro; subscription.ro = room.ro;
subscription.broadcast = room.broadcast; subscription.broadcast = room.broadcast;
subscription.encrypted = room.encrypted;
subscription.e2eKeyId = room.e2eKeyId;
if (!subscription.roles || !subscription.roles.length) { if (!subscription.roles || !subscription.roles.length) {
subscription.roles = []; subscription.roles = [];
} }
@ -72,17 +75,23 @@ export default async(subscriptions = [], rooms = []) => {
rooms = rooms.update; rooms = rooms.update;
} }
// Find missing rooms/subscriptions on local database
({ subscriptions, rooms } = await findSubscriptionsRooms(subscriptions, rooms)); ({ 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 { return {
subscriptions: subscriptions.map((s) => { subscriptions,
const index = rooms.findIndex(({ _id }) => _id === s.rid);
if (index < 0) {
return merge(s);
}
const [room] = rooms.splice(index, 1);
return merge(s, room);
}),
rooms rooms
}; };
}; };

View File

@ -6,6 +6,7 @@ import buildMessage from './helpers/buildMessage';
import database from '../database'; import database from '../database';
import log from '../../utils/log'; import log from '../../utils/log';
import protectedFunction from './helpers/protectedFunction'; import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption';
async function load({ tmid, offset }) { async function load({ tmid, offset }) {
try { try {
@ -32,6 +33,7 @@ export default function loadThreadMessages({ tmid, rid, offset = 0 }) {
InteractionManager.runAfterInteractions(async() => { InteractionManager.runAfterInteractions(async() => {
try { try {
data = data.map(m => buildMessage(m)); data = data.map(m => buildMessage(m));
data = await Encryption.decryptMessages(data);
const db = database.active; const db = database.active;
const threadMessagesCollection = db.collections.get('thread_messages'); const threadMessagesCollection = db.collections.get('thread_messages');
const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch(); const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch();

View File

@ -7,12 +7,20 @@ import { BASIC_AUTH_KEY } from '../../utils/fetch';
import database, { getDatabase } from '../database'; import database, { getDatabase } from '../database';
import RocketChat from '../rocketchat'; import RocketChat from '../rocketchat';
import { useSsl } from '../../utils/url'; import { useSsl } from '../../utils/url';
import {
E2E_PUBLIC_KEY,
E2E_PRIVATE_KEY,
E2E_RANDOM_PASSWORD_KEY
} from '../encryption/constants';
import UserPreferences from '../userPreferences'; import UserPreferences from '../userPreferences';
async function removeServerKeys({ server, userId }) { async function removeServerKeys({ server, userId }) {
await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ server }`); await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ server }`);
await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ userId }`); await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ userId }`);
await UserPreferences.removeItem(`${ BASIC_AUTH_KEY }-${ server }`); 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 }) { async function removeSharedCredentials({ server }) {

View File

@ -4,6 +4,8 @@ import messagesStatus from '../../constants/messagesStatus';
import database from '../database'; import database from '../database';
import log from '../../utils/log'; import log from '../../utils/log';
import random from '../../utils/random'; 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 changeMessageStatus = async(id, tmid, status, message) => {
const db = database.active; const db = database.active;
@ -44,17 +46,11 @@ const changeMessageStatus = async(id, tmid, status, message) => {
}; };
export async function sendMessageCall(message) { export async function sendMessageCall(message) {
const { const { _id, tmid } = message;
id: _id, subscription: { id: rid }, msg, tmid
} = message;
try { try {
const sdk = this.shareSDK || this.sdk; const sdk = this.shareSDK || this.sdk;
// RC 0.60.0 // RC 0.60.0
const result = await sdk.post('chat.sendMessage', { const result = await sdk.post('chat.sendMessage', { message });
message: {
_id, rid, msg, tmid
}
});
if (result.success) { if (result.success) {
return changeMessageStatus(_id, tmid, messagesStatus.SENT, result.message); return changeMessageStatus(_id, tmid, messagesStatus.SENT, result.message);
} }
@ -64,6 +60,32 @@ export async function sendMessageCall(message) {
return changeMessageStatus(_id, tmid, messagesStatus.ERROR); 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) { export default async function(rid, msg, tmid, user) {
try { try {
const db = database.active; const db = database.active;
@ -73,9 +95,12 @@ export default async function(rid, msg, tmid, user) {
const threadMessagesCollection = db.collections.get('thread_messages'); const threadMessagesCollection = db.collections.get('thread_messages');
const messageId = random(17); const messageId = random(17);
const batch = []; 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(); const messageDate = new Date();
let tMessageRecord; let tMessageRecord;
@ -106,6 +131,10 @@ export default async function(rid, msg, tmid, user) {
tm._updatedAt = messageDate; tm._updatedAt = messageDate;
tm.status = messagesStatus.SENT; // Original message was sent already tm.status = messagesStatus.SENT; // Original message was sent already
tm.u = tMessageRecord.u; 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', _id: user.id || '1',
username: user.username username: user.username
}; };
tm.t = message.t;
if (message.t === E2E_MESSAGE_TYPE) {
tm.e2e = E2E_STATUS.DONE;
}
}) })
); );
} catch (e) { } catch (e) {
@ -149,6 +182,10 @@ export default async function(rid, msg, tmid, user) {
m.tlm = messageDate; m.tlm = messageDate;
m.tmsg = tMessageRecord.msg; m.tmsg = tMessageRecord.msg;
} }
m.t = message.t;
if (message.t === E2E_MESSAGE_TYPE) {
m.e2e = E2E_STATUS.DONE;
}
}) })
); );

View File

@ -11,6 +11,7 @@ import { addUserTyping, removeUserTyping, clearUserTyping } from '../../../actio
import debounce from '../../../utils/debounce'; import debounce from '../../../utils/debounce';
import RocketChat from '../../rocketchat'; import RocketChat from '../../rocketchat';
import { subscribeRoom, unsubscribeRoom } from '../../../actions/room'; import { subscribeRoom, unsubscribeRoom } from '../../../actions/room';
import { Encryption } from '../../encryption';
const WINDOW_TIME = 1000; const WINDOW_TIME = 1000;
@ -162,6 +163,9 @@ export default class RoomSubscription {
const threadsCollection = db.collections.get('threads'); const threadsCollection = db.collections.get('threads');
const threadMessagesCollection = db.collections.get('thread_messages'); const threadMessagesCollection = db.collections.get('thread_messages');
// Decrypt the message if necessary
message = await Encryption.decryptMessage(message);
// Create or update message // Create or update message
try { try {
const messageRecord = await msgCollection.find(message._id); const messageRecord = await msgCollection.find(message._id);

View File

@ -16,6 +16,8 @@ import EventEmitter from '../../../utils/events';
import { removedRoom } from '../../../actions/room'; import { removedRoom } from '../../../actions/room';
import { setUser } from '../../../actions/login'; import { setUser } from '../../../actions/login';
import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification'; import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification';
import { Encryption } from '../../encryption';
import { E2E_MESSAGE_TYPE } from '../../encryption/constants';
const removeListener = listener => listener.stop(); const removeListener = listener => listener.stop();
@ -79,7 +81,10 @@ const createOrUpdateSubscription = async(subscription, room) => {
departmentId: s.departmentId, departmentId: s.departmentId,
servedBy: s.servedBy, servedBy: s.servedBy,
livechatData: s.livechatData, livechatData: s.livechatData,
tags: s.tags tags: s.tags,
encrypted: s.encrypted,
e2eKeyId: s.e2eKeyId,
E2EKey: s.E2EKey
}; };
} catch (error) { } catch (error) {
try { try {
@ -107,6 +112,7 @@ const createOrUpdateSubscription = async(subscription, room) => {
tags: r.tags, tags: r.tags,
servedBy: r.servedBy, servedBy: r.servedBy,
encrypted: r.encrypted, encrypted: r.encrypted,
e2eKeyId: r.e2eKeyId,
broadcast: r.broadcast, broadcast: r.broadcast,
customFields: r.customFields, customFields: r.customFields,
departmentId: r.departmentId, departmentId: r.departmentId,
@ -117,73 +123,91 @@ const createOrUpdateSubscription = async(subscription, room) => {
} }
} }
const tmp = merge(subscription, room); let tmp = merge(subscription, room);
await db.action(async() => { tmp = await Encryption.decryptSubscription(tmp);
let sub; 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 { 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) { } catch (error) {
// Do nothing // Do nothing
} }
const batch = []; if (messageRecord) {
if (sub) { batch.push(
try { messageRecord.prepareUpdate(() => {
const update = sub.prepareUpdate((s) => { Object.assign(messageRecord, lastMessage);
Object.assign(s, tmp); })
if (subscription.announcement) { );
if (subscription.announcement !== sub.announcement) {
s.bannerClosed = false;
}
}
});
batch.push(update);
} catch (e) {
console.log(e);
}
} else { } else {
try { batch.push(
const create = subCollection.prepareCreate((s) => { messagesCollection.prepareCreate((m) => {
s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema); m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema);
Object.assign(s, tmp); m.subscription.id = lastMessage.rid;
if (s.roomUpdatedAt) { return Object.assign(m, lastMessage);
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);
})
);
}
} }
}
await db.action(async() => {
await db.batch(...batch); await db.batch(...batch);
}); });
} catch (e) { } catch (e) {
@ -320,12 +344,25 @@ export default function subscribeRooms() {
if (/notification/.test(ev)) { if (/notification/.test(ev)) {
const [notification] = ddpMessage.fields.args; const [notification] = ddpMessage.fields.args;
try { try {
const { payload: { rid } } = notification; const { payload: { rid, message, sender } } = notification;
const room = await RocketChat.getRoom(rid); const room = await RocketChat.getRoom(rid);
notification.title = RocketChat.getRoomTitle(room); notification.title = RocketChat.getRoomTitle(room);
notification.avatar = RocketChat.getRoomAvatar(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) { } catch (e) {
// do nothing log(e);
} }
EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification); EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification);
} }
@ -333,6 +370,14 @@ export default function subscribeRooms() {
const { type: eventType, ...args } = type; const { type: eventType, ...args } = type;
handlePayloadUserInteraction(eventType, args); 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 = () => { const stop = () => {

View File

@ -5,6 +5,7 @@ import buildMessage from './helpers/buildMessage';
import log from '../../utils/log'; import log from '../../utils/log';
import database from '../database'; import database from '../database';
import protectedFunction from './helpers/protectedFunction'; import protectedFunction from './helpers/protectedFunction';
import { Encryption } from '../encryption';
export default function updateMessages({ rid, update = [], remove = [] }) { export default function updateMessages({ rid, update = [], remove = [] }) {
try { try {
@ -13,6 +14,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
} }
const db = database.active; const db = database.active;
return db.action(async() => { return db.action(async() => {
// Decrypt these messages
update = await Encryption.decryptMessages(update);
const subCollection = db.collections.get('subscriptions'); const subCollection = db.collections.get('subscriptions');
let sub; let sub;
try { try {

View File

@ -6,12 +6,12 @@ import AsyncStorage from '@react-native-community/async-storage';
import reduxStore from './createStore'; import reduxStore from './createStore';
import defaultSettings from '../constants/settings'; import defaultSettings from '../constants/settings';
import messagesStatus from '../constants/messagesStatus';
import database from './database'; import database from './database';
import log from '../utils/log'; import log from '../utils/log';
import { isIOS, getBundleId } from '../utils/deviceInfo'; import { isIOS, getBundleId } from '../utils/deviceInfo';
import fetch from '../utils/fetch'; import fetch from '../utils/fetch';
import { encryptionInit } from '../actions/encryption';
import { setUser, setLoginServices, loginRequest } from '../actions/login'; import { setUser, setLoginServices, loginRequest } from '../actions/login';
import { disconnect, connectSuccess, connectRequest } from '../actions/connect'; import { disconnect, connectSuccess, connectRequest } from '../actions/connect';
import { import {
@ -40,7 +40,7 @@ import loadMessagesForRoom from './methods/loadMessagesForRoom';
import loadMissedMessages from './methods/loadMissedMessages'; import loadMissedMessages from './methods/loadMissedMessages';
import loadThreadMessages from './methods/loadThreadMessages'; 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 { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage';
import callJitsi from './methods/callJitsi'; import callJitsi from './methods/callJitsi';
@ -53,6 +53,7 @@ import { twoFactor } from '../utils/twoFactor';
import { selectServerFailure } from '../actions/server'; import { selectServerFailure } from '../actions/server';
import { useSsl } from '../utils/url'; import { useSsl } from '../utils/url';
import UserPreferences from './userPreferences'; import UserPreferences from './userPreferences';
import { Encryption } from './encryption';
import EventEmitter from '../utils/events'; import EventEmitter from '../utils/events';
const TOKEN_KEY = 'reactnativemeteor_usertoken'; const TOKEN_KEY = 'reactnativemeteor_usertoken';
@ -80,10 +81,10 @@ const RocketChat = {
}, },
canOpenRoom, canOpenRoom,
createChannel({ createChannel({
name, users, type, readOnly, broadcast name, users, type, readOnly, broadcast, encrypted
}) { }) {
// RC 0.51.0 // 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 }) { async getWebsocketInfo({ server }) {
const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: useSsl(server) }); const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: useSsl(server) });
@ -307,6 +308,7 @@ const RocketChat = {
} }
reduxStore.dispatch(shareSetUser(user)); reduxStore.dispatch(shareSetUser(user));
await RocketChat.login({ resume: user.token }); await RocketChat.login({ resume: user.token });
reduxStore.dispatch(encryptionInit());
} catch (e) { } catch (e) {
log(e); log(e);
} }
@ -321,6 +323,45 @@ const RocketChat = {
reduxStore.dispatch(shareSetUser({})); 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) { updateJitsiTimeout(roomId) {
// RC 0.74.0 // RC 0.74.0
return this.post('video-conference/jitsi.update-timeout', { roomId }); return this.post('video-conference/jitsi.update-timeout', { roomId });
@ -468,30 +509,7 @@ const RocketChat = {
sendMessage, sendMessage,
getRooms, getRooms,
readMessages, readMessages,
async resendMessage(message, tmid) { resendMessage,
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);
}
},
async search({ text, filterUsers = true, filterRooms = true }) { async search({ text, filterUsers = true, filterRooms = true }) {
const searchText = text.trim(); const searchText = text.trim();
@ -641,10 +659,10 @@ const RocketChat = {
// RC 0.48.0 // RC 0.48.0
return this.post('chat.delete', { msgId: messageId, roomId: rid }); return this.post('chat.delete', { msgId: messageId, roomId: rid });
}, },
editMessage(message) { async editMessage(message) {
const { id, msg, rid } = message; const { rid, msg } = await Encryption.encryptMessage(message);
// RC 0.49.0 // 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 }) { markAsUnread({ messageId }) {
return this.post('subscriptions.unread', { firstUnreadMessage: { _id: messageId } }); return this.post('subscriptions.unread', { firstUnreadMessage: { _id: messageId } });
@ -1270,6 +1288,10 @@ const RocketChat = {
translateMessage(message, targetLanguage) { translateMessage(message, targetLanguage) {
return this.methodCallWrapper('autoTranslate.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) { getRoomTitle(room) {
const { UI_Use_Real_Name: useRealName, UI_Allow_room_names_with_special_chars: allowSpecialChars } = reduxStore.getState().settings; const { UI_Use_Real_Name: useRealName, UI_Allow_room_names_with_special_chars: allowSpecialChars } = reduxStore.getState().settings;
const { username } = reduxStore.getState().login.user; const { username } = reduxStore.getState().login.user;

View File

@ -6,6 +6,7 @@ import I18n from '../../i18n';
import styles from './styles'; import styles from './styles';
import Markdown from '../../containers/markdown'; import Markdown from '../../containers/markdown';
import { themes } from '../../constants/colors'; import { themes } from '../../constants/colors';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
const formatMsg = ({ const formatMsg = ({
lastMessage, type, showLastMessage, username, useRealName lastMessage, type, showLastMessage, username, useRealName
@ -29,6 +30,11 @@ const formatMsg = ({
return I18n.t('User_sent_an_attachment', { user }); 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) { if (isLastMessageSentByMe) {
prefix = I18n.t('You_colon'); prefix = I18n.t('You_colon');
} else if (type !== 'd') { } else if (type !== 'd') {

View File

@ -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;
}
}

View File

@ -17,6 +17,7 @@ import usersTyping from './usersTyping';
import inviteLinks from './inviteLinks'; import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion'; import createDiscussion from './createDiscussion';
import enterpriseModules from './enterpriseModules'; import enterpriseModules from './enterpriseModules';
import encryption from './encryption';
import inquiry from '../ee/omnichannel/reducers/inquiry'; import inquiry from '../ee/omnichannel/reducers/inquiry';
@ -39,5 +40,6 @@ export default combineReducers({
inviteLinks, inviteLinks,
createDiscussion, createDiscussion,
inquiry, inquiry,
enterpriseModules enterpriseModules,
encryption
}); });

View File

@ -36,8 +36,18 @@ const handleRequest = function* handleRequest({ data }) {
({ room: sub } = result); ({ room: sub } = result);
} }
} else { } else {
const { type, readOnly, broadcast } = data; const {
logEvent(events.CREATE_CHANNEL_CREATE, { type: type ? 'private' : 'public', readOnly, broadcast }); type,
readOnly,
broadcast,
encrypted
} = data;
logEvent(events.CREATE_CHANNEL_CREATE, {
type: type ? 'private' : 'public',
readOnly,
broadcast,
encrypted
});
sub = yield call(createChannel, data); sub = yield call(createChannel, data);
} }

122
app/sagas/encryption.js Normal file
View File

@ -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;

View File

@ -10,6 +10,7 @@ import state from './state';
import deepLinking from './deepLinking'; import deepLinking from './deepLinking';
import inviteLinks from './inviteLinks'; import inviteLinks from './inviteLinks';
import createDiscussion from './createDiscussion'; import createDiscussion from './createDiscussion';
import encryption from './encryption';
import inquiry from '../ee/omnichannel/sagas/inquiry'; import inquiry from '../ee/omnichannel/sagas/inquiry';
@ -26,7 +27,8 @@ const root = function* root() {
deepLinking(), deepLinking(),
inviteLinks(), inviteLinks(),
createDiscussion(), createDiscussion(),
inquiry() inquiry(),
encryption()
]); ]);
}; };

View File

@ -24,10 +24,12 @@ import { inviteLinksRequest } from '../actions/inviteLinks';
import { showErrorAlert } from '../utils/info'; import { showErrorAlert } from '../utils/info';
import { localAuthenticate } from '../utils/localAuthentication'; import { localAuthenticate } from '../utils/localAuthentication';
import { setActiveUsers } from '../actions/activeUsers'; import { setActiveUsers } from '../actions/activeUsers';
import { encryptionInit, encryptionStop } from '../actions/encryption';
import UserPreferences from '../lib/userPreferences'; import UserPreferences from '../lib/userPreferences';
import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry'; import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry';
import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib'; import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib';
import { E2E_REFRESH_MESSAGES_KEY } from '../lib/encryption/constants';
const getServer = state => state.server.server; const getServer = state => state.server.server;
const loginWithPasswordCall = args => RocketChat.loginWithPassword(args); 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 }) { const handleLoginSuccess = function* handleLoginSuccess({ user }) {
try { try {
const adding = yield select(state => state.server.adding); const adding = yield select(state => state.server.adding);
@ -103,7 +134,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
RocketChat.getUserPresence(user.id); RocketChat.getUserPresence(user.id);
const server = yield select(getServer); const server = yield select(getServer);
yield put(roomsRequest()); yield fork(fetchRooms, { server });
yield fork(fetchPermissions); yield fork(fetchPermissions);
yield fork(fetchCustomEmojis); yield fork(fetchCustomEmojis);
yield fork(fetchRoles); yield fork(fetchRoles);
@ -111,6 +142,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
yield fork(registerPushToken); yield fork(registerPushToken);
yield fork(fetchUsersPresence); yield fork(fetchUsersPresence);
yield fork(fetchEnterpriseModules, { user }); yield fork(fetchEnterpriseModules, { user });
yield put(encryptionInit());
I18n.locale = user.language; I18n.locale = user.language;
moment.locale(toMomentLocale(user.language)); moment.locale(toMomentLocale(user.language));
@ -173,6 +205,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
}; };
const handleLogout = function* handleLogout({ forcedByServer }) { const handleLogout = function* handleLogout({ forcedByServer }) {
yield put(encryptionStop());
yield put(appStart({ root: ROOT_LOADING, text: I18n.t('Logging_out') })); yield put(appStart({ root: ROOT_LOADING, text: I18n.t('Logging_out') }));
const server = yield select(getServer); const server = yield select(getServer);
if (server) { if (server) {

View File

@ -18,6 +18,7 @@ import I18n from '../i18n';
import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch'; import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch';
import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app'; import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app';
import UserPreferences from '../lib/userPreferences'; import UserPreferences from '../lib/userPreferences';
import { encryptionStop } from '../actions/encryption';
import { inquiryReset } from '../ee/omnichannel/actions/inquiry'; 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 }) { const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) {
try { try {
yield put(inquiryReset()); yield put(inquiryReset());
yield put(encryptionStop());
const serversDB = database.servers; const serversDB = database.servers;
yield UserPreferences.setStringAsync(RocketChat.CURRENT_SERVER, server); yield UserPreferences.setStringAsync(RocketChat.CURRENT_SERVER, server);
const userId = yield UserPreferences.getStringAsync(`${ RocketChat.TOKEN_KEY }-${ server }`); const userId = yield UserPreferences.getStringAsync(`${ RocketChat.TOKEN_KEY }-${ server }`);

View File

@ -50,6 +50,13 @@ import AdminPanelView from '../views/AdminPanelView';
import NewMessageView from '../views/NewMessageView'; import NewMessageView from '../views/NewMessageView';
import CreateChannelView from '../views/CreateChannelView'; 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 // InsideStackNavigator
import AttachmentView from '../views/AttachmentView'; import AttachmentView from '../views/AttachmentView';
import ModalBlockView from '../views/ModalBlockView'; import ModalBlockView from '../views/ModalBlockView';
@ -302,6 +309,43 @@ const NewMessageStackNavigator = () => {
); );
}; };
// E2ESaveYourPasswordStackNavigator
const E2ESaveYourPasswordStack = createStackNavigator();
const E2ESaveYourPasswordStackNavigator = () => {
const { theme } = React.useContext(ThemeContext);
return (
<E2ESaveYourPasswordStack.Navigator screenOptions={{ ...defaultHeader, ...themedHeader(theme), ...StackAnimation }}>
<E2ESaveYourPasswordStack.Screen
name='E2ESaveYourPasswordView'
component={E2ESaveYourPasswordView}
options={E2ESaveYourPasswordView.navigationOptions}
/>
<E2ESaveYourPasswordStack.Screen
name='E2EHowItWorksView'
component={E2EHowItWorksView}
options={E2EHowItWorksView.navigationOptions}
/>
</E2ESaveYourPasswordStack.Navigator>
);
};
// E2EEnterYourPasswordStackNavigator
const E2EEnterYourPasswordStack = createStackNavigator();
const E2EEnterYourPasswordStackNavigator = () => {
const { theme } = React.useContext(ThemeContext);
return (
<E2EEnterYourPasswordStack.Navigator screenOptions={{ ...defaultHeader, ...themedHeader(theme), ...StackAnimation }}>
<E2EEnterYourPasswordStack.Screen
name='E2EEnterYourPasswordView'
component={E2EEnterYourPasswordView}
options={E2EEnterYourPasswordView.navigationOptions}
/>
</E2EEnterYourPasswordStack.Navigator>
);
};
// InsideStackNavigator // InsideStackNavigator
const InsideStack = createStackNavigator(); const InsideStack = createStackNavigator();
const InsideStackNavigator = () => { const InsideStackNavigator = () => {
@ -319,6 +363,16 @@ const InsideStackNavigator = () => {
component={NewMessageStackNavigator} component={NewMessageStackNavigator}
options={{ headerShown: false }} options={{ headerShown: false }}
/> />
<InsideStack.Screen
name='E2ESaveYourPasswordStackNavigator'
component={E2ESaveYourPasswordStackNavigator}
options={{ headerShown: false }}
/>
<InsideStack.Screen
name='E2EEnterYourPasswordStackNavigator'
component={E2EEnterYourPasswordStackNavigator}
options={{ headerShown: false }}
/>
<InsideStack.Screen <InsideStack.Screen
name='AttachmentView' name='AttachmentView'
component={AttachmentView} component={AttachmentView}

View File

@ -50,6 +50,9 @@ import ModalBlockView from '../../views/ModalBlockView';
import JitsiMeetView from '../../views/JitsiMeetView'; import JitsiMeetView from '../../views/JitsiMeetView';
import StatusView from '../../views/StatusView'; import StatusView from '../../views/StatusView';
import CreateDiscussionView from '../../views/CreateDiscussionView'; import CreateDiscussionView from '../../views/CreateDiscussionView';
import E2ESaveYourPasswordView from '../../views/E2ESaveYourPasswordView';
import E2EHowItWorksView from '../../views/E2EHowItWorksView';
import E2EEnterYourPasswordView from '../../views/E2EEnterYourPasswordView';
import { setKeyCommands, deleteKeyCommands } from '../../commands'; import { setKeyCommands, deleteKeyCommands } from '../../commands';
import ShareView from '../../views/ShareView'; import ShareView from '../../views/ShareView';
@ -256,6 +259,21 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
name='CreateDiscussionView' name='CreateDiscussionView'
component={CreateDiscussionView} component={CreateDiscussionView}
/> />
<ModalStack.Screen
name='E2ESaveYourPasswordView'
component={E2ESaveYourPasswordView}
options={E2ESaveYourPasswordView.navigationOptions}
/>
<ModalStack.Screen
name='E2EHowItWorksView'
component={E2EHowItWorksView}
options={E2EHowItWorksView.navigationOptions}
/>
<ModalStack.Screen
name='E2EEnterYourPasswordView'
component={E2EEnterYourPasswordView}
options={E2EEnterYourPasswordView.navigationOptions}
/>
<ModalStack.Screen <ModalStack.Screen
name='UserPreferencesView' name='UserPreferencesView'
component={UserPreferencesView} component={UserPreferencesView}

View File

@ -0,0 +1,144 @@
/* eslint-disable no-undef */
/* eslint-disable no-bitwise */
// https://github.com/beatgammit/base64-js/tree/master/test
import {
byteLength,
toByteArray,
fromByteArray
} from './index';
const map = (arr, callback) => {
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);
});

View File

@ -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('');
};

14
app/utils/deferred.js Normal file
View File

@ -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;
}
}

View File

@ -58,6 +58,7 @@ export default {
RL_ADD_SERVER: 'rl_add_server', RL_ADD_SERVER: 'rl_add_server',
RL_CHANGE_SERVER: 'rl_change_server', RL_CHANGE_SERVER: 'rl_change_server',
RL_GO_NEW_MSG: 'rl_go_new_msg', RL_GO_NEW_MSG: 'rl_go_new_msg',
RL_GO_E2E_SAVE_PASSWORD: 'rl_go_e2e_save_password',
RL_SEARCH: 'rl_search', RL_SEARCH: 'rl_search',
RL_GO_DIRECTORY: 'rl_go_directory', RL_GO_DIRECTORY: 'rl_go_directory',
RL_GO_QUEUE: 'rl_go_queue', RL_GO_QUEUE: 'rl_go_queue',
@ -103,6 +104,7 @@ export default {
CREATE_CHANNEL_TOGGLE_TYPE: 'create_channel_toggle_type', CREATE_CHANNEL_TOGGLE_TYPE: 'create_channel_toggle_type',
CREATE_CHANNEL_TOGGLE_READ_ONLY: 'create_channel_toggle_read_only', CREATE_CHANNEL_TOGGLE_READ_ONLY: 'create_channel_toggle_read_only',
CREATE_CHANNEL_TOGGLE_BROADCAST: 'create_channel_toggle_broadcast', 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_CHANNEL_REMOVE_USER: 'create_channel_remove_user',
// CREATE DISCUSSION VIEW // CREATE DISCUSSION VIEW
@ -161,6 +163,7 @@ export default {
// ROOM VIEW // ROOM VIEW
ROOM_SEND_MESSAGE: 'room_send_message', ROOM_SEND_MESSAGE: 'room_send_message',
ROOM_ENCRYPTED_PRESS: 'room_encrypted_press',
ROOM_OPEN_EMOJI: 'room_open_emoji', ROOM_OPEN_EMOJI: 'room_open_emoji',
ROOM_AUDIO_RECORD: 'room_audio_record', ROOM_AUDIO_RECORD: 'room_audio_record',
ROOM_AUDIO_RECORD_F: 'room_audio_record_f', ROOM_AUDIO_RECORD_F: 'room_audio_record_f',
@ -233,6 +236,8 @@ export default {
RA_LEAVE_F: 'ra_leave_f', RA_LEAVE_F: 'ra_leave_f',
RA_TOGGLE_BLOCK_USER: 'ra_toggle_block_user', RA_TOGGLE_BLOCK_USER: 'ra_toggle_block_user',
RA_TOGGLE_BLOCK_USER_F: 'ra_toggle_block_user_f', 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 // ROOM INFO VIEW
RI_GO_RI_EDIT: 'ri_go_ri_edit', 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_READ_ONLY: 'ri_edit_toggle_read_only',
RI_EDIT_TOGGLE_REACTIONS: 'ri_edit_toggle_reactions', RI_EDIT_TOGGLE_REACTIONS: 'ri_edit_toggle_reactions',
RI_EDIT_TOGGLE_SYSTEM_MSG: 'ri_edit_toggle_system_msg', 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: 'ri_edit_save',
RI_EDIT_SAVE_F: 'ri_edit_save_f', RI_EDIT_SAVE_F: 'ri_edit_save_f',
RI_EDIT_RESET: 'ri_edit_reset', RI_EDIT_RESET: 'ri_edit_reset',
@ -288,5 +294,13 @@ export default {
NP_DESKTOPNOTIFICATIONDURATION: 'np_desktopnotificationduration', NP_DESKTOPNOTIFICATIONDURATION: 'np_desktopnotificationduration',
NP_DESKTOPNOTIFICATIONDURATION_F: 'np_desktopnotificationduration_f', NP_DESKTOPNOTIFICATIONDURATION_F: 'np_desktopnotificationduration_f',
NP_EMAILNOTIFICATIONS: 'np_email_notifications', 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'
}; };

View File

@ -85,6 +85,7 @@ class CreateChannelView extends React.Component {
error: PropTypes.object, error: PropTypes.object,
failure: PropTypes.bool, failure: PropTypes.bool,
isFetching: PropTypes.bool, isFetching: PropTypes.bool,
e2eEnabled: PropTypes.bool,
users: PropTypes.array.isRequired, users: PropTypes.array.isRequired,
user: PropTypes.shape({ user: PropTypes.shape({
id: PropTypes.string, id: PropTypes.string,
@ -97,14 +98,17 @@ class CreateChannelView extends React.Component {
channelName: '', channelName: '',
type: true, type: true,
readOnly: false, readOnly: false,
encrypted: false,
broadcast: false broadcast: false
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
const { const {
channelName, type, readOnly, broadcast channelName, type, readOnly, broadcast, encrypted
} = this.state; } = this.state;
const { users, isFetching, theme } = this.props; const {
users, isFetching, e2eEnabled, theme
} = this.props;
if (nextProps.theme !== theme) { if (nextProps.theme !== theme) {
return true; return true;
} }
@ -117,12 +121,18 @@ class CreateChannelView extends React.Component {
if (nextState.readOnly !== readOnly) { if (nextState.readOnly !== readOnly) {
return true; return true;
} }
if (nextState.encrypted !== encrypted) {
return true;
}
if (nextState.broadcast !== broadcast) { if (nextState.broadcast !== broadcast) {
return true; return true;
} }
if (nextProps.isFetching !== isFetching) { if (nextProps.isFetching !== isFetching) {
return true; return true;
} }
if (nextProps.e2eEnabled !== e2eEnabled) {
return true;
}
if (!equal(nextProps.users, users)) { if (!equal(nextProps.users, users)) {
return true; return true;
} }
@ -147,7 +157,7 @@ class CreateChannelView extends React.Component {
submit = () => { submit = () => {
const { const {
channelName, type, readOnly, broadcast channelName, type, readOnly, broadcast, encrypted
} = this.state; } = this.state;
const { users: usersProps, isFetching, create } = this.props; const { users: usersProps, isFetching, create } = this.props;
@ -160,7 +170,7 @@ class CreateChannelView extends React.Component {
// create channel // create channel
create({ create({
name: channelName, users, type, readOnly, broadcast name: channelName, users, type, readOnly, broadcast, encrypted
}); });
Review.pushPositiveEvent(); Review.pushPositiveEvent();
@ -198,7 +208,8 @@ class CreateChannelView extends React.Component {
label: 'Private_Channel', label: 'Private_Channel',
onValueChange: (value) => { onValueChange: (value) => {
logEvent(events.CREATE_CHANNEL_TOGGLE_TYPE); 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() { renderBroadcast() {
const { broadcast, readOnly } = this.state; const { broadcast, readOnly } = this.state;
return this.renderSwitch({ return this.renderSwitch({
@ -315,6 +346,8 @@ class CreateChannelView extends React.Component {
{this.renderFormSeparator()} {this.renderFormSeparator()}
{this.renderReadOnly()} {this.renderReadOnly()}
{this.renderFormSeparator()} {this.renderFormSeparator()}
{this.renderEncrypted()}
{this.renderFormSeparator()}
{this.renderBroadcast()} {this.renderBroadcast()}
</View> </View>
<View style={styles.invitedHeader}> <View style={styles.invitedHeader}>
@ -333,6 +366,7 @@ class CreateChannelView extends React.Component {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
baseUrl: state.server.server, baseUrl: state.server.server,
isFetching: state.createChannel.isFetching, isFetching: state.createChannel.isFetching,
e2eEnabled: state.settings.E2E_Enable,
users: state.selectedUsers.users, users: state.selectedUsers.users,
user: getUserSelector(state) user: getUserSelector(state)
}); });

View File

@ -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: () => <CloseModalButton navigation={navigation} testID='e2e-enter-your-password-view-close' />,
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 (
<KeyboardView
style={{ backgroundColor: themes[theme].backgroundColor }}
contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<StatusBar theme={theme} />
<ScrollView {...scrollPersistTaps} style={sharedStyles.container} contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}>
<SafeAreaView theme={theme} style={[styles.container, { backgroundColor: themes[theme].backgroundColor }]}>
<TextInput
inputRef={(e) => { 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}
/>
<Button
onPress={this.submit}
title={I18n.t('Confirm')}
disabled={!password}
theme={theme}
/>
<Text style={[styles.info, { color: themes[theme].bodyText }]}>{I18n.t('Enter_Your_Encryption_Password_desc1')}</Text>
<Text style={[styles.info, { color: themes[theme].bodyText }]}>{I18n.t('Enter_Your_Encryption_Password_desc2')}</Text>
</SafeAreaView>
</ScrollView>
</KeyboardView>
);
}
}
const mapDispatchToProps = dispatch => ({
encryptionDecodeKey: password => dispatch(encryptionDecodeKeyAction(password))
});
export default connect(null, mapDispatchToProps)(withTheme(E2EEnterYourPasswordView));

View File

@ -0,0 +1,73 @@
import React from 'react';
import PropTypes from 'prop-types';
import { StyleSheet } from 'react-native';
import SafeAreaView from '../containers/SafeAreaView';
import { themes } from '../constants/colors';
import { CloseModalButton } from '../containers/HeaderButton';
import Markdown from '../containers/markdown';
import { withTheme } from '../theme';
import I18n from '../i18n';
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 44,
paddingTop: 32
},
info: {
fontSize: 14,
marginVertical: 8
}
});
class E2EHowItWorksView extends React.Component {
static navigationOptions = ({ route, navigation }) => {
const showCloseModal = route.params?.showCloseModal;
return {
title: I18n.t('How_It_Works'),
headerLeft: showCloseModal ? () => <CloseModalButton navigation={navigation} /> : undefined
};
}
static propTypes = {
theme: PropTypes.string
}
render() {
const { theme } = this.props;
const infoStyle = [styles.info, { color: themes[theme].bodyText }];
return (
<SafeAreaView
style={[styles.container, { backgroundColor: themes[theme].backgroundColor }]}
testID='e2e-how-it-works-view'
theme={theme}
>
<Markdown
msg={I18n.t('E2E_How_It_Works_info1')}
style={infoStyle}
theme={theme}
/>
<Markdown
msg={I18n.t('E2E_How_It_Works_info2')}
style={infoStyle}
theme={theme}
/>
<Markdown
msg={I18n.t('E2E_How_It_Works_info3')}
style={infoStyle}
theme={theme}
/>
<Markdown
msg={I18n.t('E2E_How_It_Works_info4')}
style={infoStyle}
theme={theme}
/>
</SafeAreaView>
);
}
}
export default withTheme(E2EHowItWorksView);

View File

@ -0,0 +1,172 @@
import React from 'react';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import {
Text,
View,
Clipboard,
ScrollView,
StyleSheet
} from 'react-native';
import { encryptionSetBanner as encryptionSetBannerAction } from '../actions/encryption';
import { E2E_RANDOM_PASSWORD_KEY } from '../lib/encryption/constants';
import { CloseModalButton } from '../containers/HeaderButton';
import scrollPersistTaps from '../utils/scrollPersistTaps';
import SafeAreaView from '../containers/SafeAreaView';
import UserPreferences from '../lib/userPreferences';
import { logEvent, events } from '../utils/log';
import StatusBar from '../containers/StatusBar';
import { LISTENER } from '../containers/Toast';
import { themes } from '../constants/colors';
import EventEmitter from '../utils/events';
import Button from '../containers/Button';
import { withTheme } from '../theme';
import sharedStyles from './Styles';
import I18n from '../i18n';
const styles = StyleSheet.create({
container: {
flex: 1,
padding: 44,
paddingTop: 32
},
content: {
marginVertical: 68,
alignItems: 'center'
},
warning: {
fontSize: 14,
...sharedStyles.textMedium
},
passwordText: {
marginBottom: 8,
...sharedStyles.textAlignCenter
},
password: {
fontSize: 24,
marginBottom: 24,
...sharedStyles.textBold
},
copyButton: {
width: 72,
height: 32
},
info: {
fontSize: 14,
marginBottom: 64,
...sharedStyles.textRegular
}
});
class E2ESaveYourPasswordView extends React.Component {
static navigationOptions = ({ navigation }) => ({
headerLeft: () => <CloseModalButton navigation={navigation} testID='e2e-save-your-password-view-close' />,
title: I18n.t('Save_Your_E2E_Password')
})
static propTypes = {
server: PropTypes.string,
navigation: PropTypes.object,
encryptionSetBanner: PropTypes.func,
theme: PropTypes.string
}
constructor(props) {
super(props);
this.mounted = false;
this.state = { password: '' };
this.init();
}
componentDidMount() {
this.mounted = true;
}
init = async() => {
const { server } = this.props;
try {
// Set stored password on local state
const password = await UserPreferences.getStringAsync(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`);
if (this.mounted) {
this.setState({ password });
} else {
this.state.password = password;
}
} catch {
// Do nothing
}
}
onSaved = async() => {
logEvent(events.E2E_SAVE_PW_SAVED);
const { navigation, server, encryptionSetBanner } = this.props;
// Remove stored password
await UserPreferences.removeItem(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`);
// Hide encryption banner
encryptionSetBanner();
navigation.pop();
}
onCopy = () => {
logEvent(events.E2E_SAVE_PW_COPY);
const { password } = this.state;
Clipboard.setString(password);
EventEmitter.emit(LISTENER, { message: I18n.t('Copied_to_clipboard') });
}
onHowItWorks = () => {
logEvent(events.E2E_SAVE_PW_HOW_IT_WORKS);
const { navigation } = this.props;
navigation.navigate('E2EHowItWorksView');
}
render() {
const { password } = this.state;
const { theme } = this.props;
return (
<SafeAreaView theme={theme} style={{ backgroundColor: themes[theme].backgroundColor }}>
<StatusBar theme={theme} />
<ScrollView {...scrollPersistTaps} style={sharedStyles.container} contentContainerStyle={sharedStyles.containerScrollView}>
<View style={[styles.container, { backgroundColor: themes[theme].backgroundColor }]}>
<Text style={[styles.warning, { color: themes[theme].dangerColor }]}>{I18n.t('Save_Your_Encryption_Password_warning')}</Text>
<View style={styles.content}>
<Text style={[styles.passwordText, { color: themes[theme].bodyText }]}>{I18n.t('Your_password_is')}</Text>
<Text style={[styles.password, { color: themes[theme].bodyText }]}>{password}</Text>
<Button
onPress={this.onCopy}
style={[styles.copyButton, { backgroundColor: themes[theme].auxiliaryBackground }]}
title={I18n.t('Copy')}
type='secondary'
fontSize={12}
theme={theme}
/>
</View>
<Text style={[styles.info, { color: themes[theme].bodyText }]}>{I18n.t('Save_Your_Encryption_Password_info')}</Text>
<Button
onPress={this.onHowItWorks}
style={{ backgroundColor: themes[theme].auxiliaryBackground }}
title={I18n.t('How_It_Works')}
type='secondary'
theme={theme}
/>
<Button
onPress={this.onSaved}
title={I18n.t('I_Saved_My_E2E_Password')}
theme={theme}
/>
</View>
</ScrollView>
</SafeAreaView>
);
}
}
const mapStateToProps = state => ({
server: state.server.server
});
const mapDispatchToProps = dispatch => ({
encryptionSetBanner: () => dispatch(encryptionSetBannerAction())
});
export default connect(mapStateToProps, mapDispatchToProps)(withTheme(E2ESaveYourPasswordView));

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
View, SectionList, Text, Alert, Share View, SectionList, Text, Alert, Share, Switch
} from 'react-native'; } from 'react-native';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import _ from 'lodash'; import _ from 'lodash';
@ -21,13 +21,16 @@ import scrollPersistTaps from '../../utils/scrollPersistTaps';
import { CustomIcon } from '../../lib/Icons'; import { CustomIcon } from '../../lib/Icons';
import DisclosureIndicator from '../../containers/DisclosureIndicator'; import DisclosureIndicator from '../../containers/DisclosureIndicator';
import StatusBar from '../../containers/StatusBar'; import StatusBar from '../../containers/StatusBar';
import { themes } from '../../constants/colors'; import { themes, SWITCH_TRACK_COLOR } from '../../constants/colors';
import { withTheme } from '../../theme'; import { withTheme } from '../../theme';
import { CloseModalButton } from '../../containers/HeaderButton'; import { CloseModalButton } from '../../containers/HeaderButton';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import Markdown from '../../containers/markdown'; import Markdown from '../../containers/markdown';
import { showConfirmationAlert, showErrorAlert } from '../../utils/info'; import { showConfirmationAlert, showErrorAlert } from '../../utils/info';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import { E2E_ROOM_TYPES } from '../../lib/encryption/constants';
import protectedFunction from '../../lib/methods/helpers/protectedFunction';
import database from '../../lib/database';
class RoomActionsView extends React.Component { class RoomActionsView extends React.Component {
static navigationOptions = ({ navigation, isMasterDetail }) => { static navigationOptions = ({ navigation, isMasterDetail }) => {
@ -50,6 +53,7 @@ class RoomActionsView extends React.Component {
}), }),
leaveRoom: PropTypes.func, leaveRoom: PropTypes.func,
jitsiEnabled: PropTypes.bool, jitsiEnabled: PropTypes.bool,
e2eEnabled: PropTypes.bool,
setLoadingInvite: PropTypes.func, setLoadingInvite: PropTypes.func,
closeRoom: PropTypes.func, closeRoom: PropTypes.func,
theme: PropTypes.string theme: PropTypes.string
@ -72,7 +76,8 @@ class RoomActionsView extends React.Component {
canAddUser: false, canAddUser: false,
canInviteUser: false, canInviteUser: false,
canForwardGuest: false, canForwardGuest: false,
canReturnQueue: false canReturnQueue: false,
canEdit: false
}; };
if (room && room.observe && room.rid) { if (room && room.observe && room.rid) {
this.roomObservable = room.observe(); this.roomObservable = room.observe();
@ -120,6 +125,7 @@ class RoomActionsView extends React.Component {
this.canAddUser(); this.canAddUser();
this.canInviteUser(); this.canInviteUser();
this.canEdit();
// livechat permissions // livechat permissions
if (room.t === 'l') { if (room.t === 'l') {
@ -184,6 +190,15 @@ class RoomActionsView extends React.Component {
this.setState({ canInviteUser }); this.setState({ canInviteUser });
} }
canEdit = async() => {
const { room } = this.state;
const { rid } = room;
const permissions = await RocketChat.hasPermission(['edit-room'], rid);
const canEdit = permissions && permissions['edit-room'];
this.setState({ canEdit });
}
canViewMembers = async() => { canViewMembers = async() => {
const { room } = this.state; const { room } = this.state;
const { rid, t, broadcast } = room; const { rid, t, broadcast } = room;
@ -227,11 +242,11 @@ class RoomActionsView extends React.Component {
get sections() { get sections() {
const { const {
room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue, canEdit
} = this.state; } = this.state;
const { jitsiEnabled } = this.props; const { jitsiEnabled, e2eEnabled } = this.props;
const { const {
rid, t, blocker rid, t, blocker, encrypted
} = room; } = room;
const isGroupChat = RocketChat.isGroupChat(room); const isGroupChat = RocketChat.isGroupChat(room);
@ -240,7 +255,8 @@ class RoomActionsView extends React.Component {
name: I18n.t('Notifications'), name: I18n.t('Notifications'),
route: 'NotificationPrefView', route: 'NotificationPrefView',
params: { rid, room }, params: { rid, room },
testID: 'room-actions-notifications' testID: 'room-actions-notifications',
right: this.renderDisclosure
}; };
const jitsiActions = jitsiEnabled ? [ const jitsiActions = jitsiEnabled ? [
@ -248,13 +264,15 @@ class RoomActionsView extends React.Component {
icon: 'phone', icon: 'phone',
name: I18n.t('Voice_call'), name: I18n.t('Voice_call'),
event: () => RocketChat.callJitsi(rid, true), event: () => RocketChat.callJitsi(rid, true),
testID: 'room-actions-voice' testID: 'room-actions-voice',
right: this.renderDisclosure
}, },
{ {
icon: 'camera', icon: 'camera',
name: I18n.t('Video_call'), name: I18n.t('Video_call'),
event: () => RocketChat.callJitsi(rid), event: () => RocketChat.callJitsi(rid),
testID: 'room-actions-video' testID: 'room-actions-video',
right: this.renderDisclosure
} }
] : []; ] : [];
@ -281,41 +299,47 @@ class RoomActionsView extends React.Component {
name: I18n.t('Files'), name: I18n.t('Files'),
route: 'MessagesView', route: 'MessagesView',
params: { rid, t, name: 'Files' }, params: { rid, t, name: 'Files' },
testID: 'room-actions-files' testID: 'room-actions-files',
right: this.renderDisclosure
}, },
{ {
icon: 'mention', icon: 'mention',
name: I18n.t('Mentions'), name: I18n.t('Mentions'),
route: 'MessagesView', route: 'MessagesView',
params: { rid, t, name: 'Mentions' }, params: { rid, t, name: 'Mentions' },
testID: 'room-actions-mentioned' testID: 'room-actions-mentioned',
right: this.renderDisclosure
}, },
{ {
icon: 'star', icon: 'star',
name: I18n.t('Starred'), name: I18n.t('Starred'),
route: 'MessagesView', route: 'MessagesView',
params: { rid, t, name: 'Starred' }, params: { rid, t, name: 'Starred' },
testID: 'room-actions-starred' testID: 'room-actions-starred',
right: this.renderDisclosure
}, },
{ {
icon: 'search', icon: 'search',
name: I18n.t('Search'), name: I18n.t('Search'),
route: 'SearchMessagesView', route: 'SearchMessagesView',
params: { rid }, params: { rid, encrypted },
testID: 'room-actions-search' testID: 'room-actions-search',
right: this.renderDisclosure
}, },
{ {
icon: 'share', icon: 'share',
name: I18n.t('Share'), name: I18n.t('Share'),
event: this.handleShare, event: this.handleShare,
testID: 'room-actions-share' testID: 'room-actions-share',
right: this.renderDisclosure
}, },
{ {
icon: 'pin', icon: 'pin',
name: I18n.t('Pinned'), name: I18n.t('Pinned'),
route: 'MessagesView', route: 'MessagesView',
params: { rid, t, name: 'Pinned' }, params: { rid, t, name: 'Pinned' },
testID: 'room-actions-pinned' testID: 'room-actions-pinned',
right: this.renderDisclosure
} }
], ],
renderItem: this.renderItem renderItem: this.renderItem
@ -327,7 +351,8 @@ class RoomActionsView extends React.Component {
name: I18n.t('Auto_Translate'), name: I18n.t('Auto_Translate'),
route: 'AutoTranslateView', route: 'AutoTranslateView',
params: { rid, room }, params: { rid, room },
testID: 'room-actions-auto-translate' testID: 'room-actions-auto-translate',
right: this.renderDisclosure
}); });
} }
@ -338,7 +363,8 @@ class RoomActionsView extends React.Component {
description: membersCount > 0 ? `${ membersCount } ${ I18n.t('members') }` : null, description: membersCount > 0 ? `${ membersCount } ${ I18n.t('members') }` : null,
route: 'RoomMembersView', route: 'RoomMembersView',
params: { rid, room }, params: { rid, room },
testID: 'room-actions-members' testID: 'room-actions-members',
right: this.renderDisclosure
}); });
} }
@ -350,7 +376,8 @@ class RoomActionsView extends React.Component {
name: I18n.t(`${ blocker ? 'Unblock' : 'Block' }_user`), name: I18n.t(`${ blocker ? 'Unblock' : 'Block' }_user`),
type: 'danger', type: 'danger',
event: this.toggleBlockUser, event: this.toggleBlockUser,
testID: 'room-actions-block-user' testID: 'room-actions-block-user',
right: this.renderDisclosure
} }
], ],
renderItem: this.renderItem renderItem: this.renderItem
@ -366,7 +393,8 @@ class RoomActionsView extends React.Component {
description: membersCount > 0 ? `${ membersCount } ${ I18n.t('members') }` : null, description: membersCount > 0 ? `${ membersCount } ${ I18n.t('members') }` : null,
route: 'RoomMembersView', route: 'RoomMembersView',
params: { rid, room }, params: { rid, room },
testID: 'room-actions-members' testID: 'room-actions-members',
right: this.renderDisclosure
}); });
} }
@ -380,7 +408,8 @@ class RoomActionsView extends React.Component {
title: I18n.t('Add_users'), title: I18n.t('Add_users'),
nextAction: this.addUser nextAction: this.addUser
}, },
testID: 'room-actions-add-user' testID: 'room-actions-add-user',
right: this.renderDisclosure
}); });
} }
if (canInviteUser) { if (canInviteUser) {
@ -391,7 +420,8 @@ class RoomActionsView extends React.Component {
params: { params: {
rid rid
}, },
testID: 'room-actions-invite-user' testID: 'room-actions-invite-user',
right: this.renderDisclosure
}); });
} }
sections[2].data = [...actions, ...sections[2].data]; sections[2].data = [...actions, ...sections[2].data];
@ -405,7 +435,8 @@ class RoomActionsView extends React.Component {
name: I18n.t('Leave_channel'), name: I18n.t('Leave_channel'),
type: 'danger', type: 'danger',
event: this.leaveChannel, event: this.leaveChannel,
testID: 'room-actions-leave-channel' testID: 'room-actions-leave-channel',
right: this.renderDisclosure
} }
], ],
renderItem: this.renderItem renderItem: this.renderItem
@ -418,7 +449,8 @@ class RoomActionsView extends React.Component {
sections[2].data.push({ sections[2].data.push({
icon: 'close', icon: 'close',
name: I18n.t('Close'), name: I18n.t('Close'),
event: this.closeLivechat event: this.closeLivechat,
right: this.renderDisclosure
}); });
if (canForwardGuest) { if (canForwardGuest) {
@ -426,7 +458,8 @@ class RoomActionsView extends React.Component {
icon: 'user-forward', icon: 'user-forward',
name: I18n.t('Forward'), name: I18n.t('Forward'),
route: 'ForwardLivechatView', route: 'ForwardLivechatView',
params: { rid } params: { rid },
right: this.renderDisclosure
}); });
} }
@ -434,7 +467,8 @@ class RoomActionsView extends React.Component {
sections[2].data.push({ sections[2].data.push({
icon: 'undo', icon: 'undo',
name: I18n.t('Return'), name: I18n.t('Return'),
event: this.returnLivechat event: this.returnLivechat,
right: this.renderDisclosure
}); });
} }
@ -442,7 +476,8 @@ class RoomActionsView extends React.Component {
icon: 'history', icon: 'history',
name: I18n.t('Navigation_history'), name: I18n.t('Navigation_history'),
route: 'VisitorNavigationView', route: 'VisitorNavigationView',
params: { rid } params: { rid },
right: this.renderDisclosure
}); });
} }
@ -452,14 +487,47 @@ class RoomActionsView extends React.Component {
}); });
} }
// If can edit this room
// If this room type can be Encrypted
// If e2e is enabled for this server
if (canEdit && E2E_ROOM_TYPES[t] && e2eEnabled) {
sections.splice(2, 0, {
data: [{
icon: 'encrypted',
name: I18n.t('Encrypted'),
testID: 'room-actions-encrypt',
right: this.renderEncryptedSwitch
}],
renderItem: this.renderItem
});
}
return sections; return sections;
} }
renderDisclosure = () => {
const { theme } = this.props;
return <DisclosureIndicator theme={theme} />;
}
renderSeparator = () => { renderSeparator = () => {
const { theme } = this.props; const { theme } = this.props;
return <View style={[styles.separator, { backgroundColor: themes[theme].separatorColor }]} />; return <View style={[styles.separator, { backgroundColor: themes[theme].separatorColor }]} />;
} }
renderEncryptedSwitch = () => {
const { room } = this.state;
const { encrypted } = room;
return (
<Switch
value={encrypted}
trackColor={SWITCH_TRACK_COLOR}
onValueChange={this.toggleEncrypted}
style={styles.encryptedSwitch}
/>
);
}
closeLivechat = () => { closeLivechat = () => {
const { room: { rid } } = this.state; const { room: { rid } } = this.state;
const { closeRoom } = this.props; const { closeRoom } = this.props;
@ -514,19 +582,58 @@ class RoomActionsView extends React.Component {
} }
} }
toggleBlockUser = () => { toggleBlockUser = async() => {
logEvent(events.RA_TOGGLE_BLOCK_USER); logEvent(events.RA_TOGGLE_BLOCK_USER);
const { room } = this.state; const { room } = this.state;
const { rid, blocker } = room; const { rid, blocker } = room;
const { member } = this.state; const { member } = this.state;
try { try {
RocketChat.toggleBlockUser(rid, member._id, !blocker); await RocketChat.toggleBlockUser(rid, member._id, !blocker);
} catch (e) { } catch (e) {
logEvent(events.RA_TOGGLE_BLOCK_USER_F); logEvent(events.RA_TOGGLE_BLOCK_USER_F);
log(e); log(e);
} }
} }
toggleEncrypted = async() => {
logEvent(events.RA_TOGGLE_ENCRYPTED);
const { room } = this.state;
const { rid } = room;
const db = database.active;
// Toggle encrypted value
const encrypted = !room.encrypted;
try {
// Instantly feedback to the user
await db.action(async() => {
await room.update(protectedFunction((r) => {
r.encrypted = encrypted;
}));
});
try {
// Send new room setting value to server
const { result } = await RocketChat.saveRoomSettings(rid, { encrypted });
// If it was saved successfully
if (result) {
return;
}
} catch {
// do nothing
}
// If something goes wrong we go back to the previous value
await db.action(async() => {
await room.update(protectedFunction((r) => {
r.encrypted = room.encrypted;
}));
});
} catch (e) {
logEvent(events.RA_TOGGLE_ENCRYPTED_F);
log(e);
}
}
handleShare = () => { handleShare = () => {
logEvent(events.RA_SHARE); logEvent(events.RA_SHARE);
const { room } = this.state; const { room } = this.state;
@ -638,7 +745,7 @@ class RoomActionsView extends React.Component {
<CustomIcon name={item.icon} size={24} style={[styles.sectionItemIcon, { color: themes[theme].bodyText }]} /> <CustomIcon name={item.icon} size={24} style={[styles.sectionItemIcon, { color: themes[theme].bodyText }]} />
<Text style={[styles.sectionItemName, { color: themes[theme].bodyText }]}>{ item.name }</Text> <Text style={[styles.sectionItemName, { color: themes[theme].bodyText }]}>{ item.name }</Text>
{item.description ? <Text style={[styles.sectionItemDescription, { color: themes[theme].auxiliaryText }]}>{ item.description }</Text> : null} {item.description ? <Text style={[styles.sectionItemDescription, { color: themes[theme].auxiliaryText }]}>{ item.description }</Text> : null}
<DisclosureIndicator theme={theme} /> {item?.right?.()}
</> </>
); );
return this.renderTouchableItem(subview, item); return this.renderTouchableItem(subview, item);
@ -679,7 +786,8 @@ class RoomActionsView extends React.Component {
const mapStateToProps = state => ({ const mapStateToProps = state => ({
user: getUserSelector(state), user: getUserSelector(state),
baseUrl: state.server.server, baseUrl: state.server.server,
jitsiEnabled: state.settings.Jitsi_Enabled || false jitsiEnabled: state.settings.Jitsi_Enabled || false,
e2eEnabled: state.settings.E2E_Enable || false
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -58,5 +58,8 @@ export default StyleSheet.create({
paddingRight: 16, paddingRight: 16,
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center' alignItems: 'center'
},
encryptedSwitch: {
marginHorizontal: 16
} }
}); });

View File

@ -56,6 +56,7 @@ class RoomInfoEditView extends React.Component {
route: PropTypes.object, route: PropTypes.object,
deleteRoom: PropTypes.func, deleteRoom: PropTypes.func,
serverVersion: PropTypes.string, serverVersion: PropTypes.string,
e2eEnabled: PropTypes.bool,
theme: PropTypes.string theme: PropTypes.string
}; };
@ -76,7 +77,8 @@ class RoomInfoEditView extends React.Component {
reactWhenReadOnly: false, reactWhenReadOnly: false,
archived: false, archived: false,
systemMessages: [], systemMessages: [],
enableSysMes: false enableSysMes: false,
encrypted: false
}; };
this.loadRoom(); this.loadRoom();
} }
@ -123,7 +125,7 @@ class RoomInfoEditView extends React.Component {
init = (room) => { init = (room) => {
const { const {
description, topic, announcement, t, ro, reactWhenReadOnly, joinCodeRequired, sysMes description, topic, announcement, t, ro, reactWhenReadOnly, joinCodeRequired, sysMes, encrypted
} = room; } = room;
// fake password just to user knows about it // fake password just to user knows about it
this.randomValue = random(15); this.randomValue = random(15);
@ -139,7 +141,8 @@ class RoomInfoEditView extends React.Component {
joinCode: joinCodeRequired ? this.randomValue : '', joinCode: joinCodeRequired ? this.randomValue : '',
archived: room.archived, archived: room.archived,
systemMessages: sysMes, systemMessages: sysMes,
enableSysMes: sysMes && sysMes.length > 0 enableSysMes: sysMes && sysMes.length > 0,
encrypted
}); });
} }
@ -157,7 +160,7 @@ class RoomInfoEditView extends React.Component {
formIsChanged = () => { formIsChanged = () => {
const { const {
room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, enableSysMes room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, enableSysMes, encrypted
} = this.state; } = this.state;
const { joinCodeRequired } = room; const { joinCodeRequired } = room;
return !(room.name === name return !(room.name === name
@ -170,6 +173,7 @@ class RoomInfoEditView extends React.Component {
&& room.reactWhenReadOnly === reactWhenReadOnly && room.reactWhenReadOnly === reactWhenReadOnly
&& isEqual(room.sysMes, systemMessages) && isEqual(room.sysMes, systemMessages)
&& enableSysMes === (room.sysMes && room.sysMes.length > 0) && enableSysMes === (room.sysMes && room.sysMes.length > 0)
&& room.encrypted === encrypted
); );
} }
@ -177,7 +181,7 @@ class RoomInfoEditView extends React.Component {
logEvent(events.RI_EDIT_SAVE); logEvent(events.RI_EDIT_SAVE);
Keyboard.dismiss(); Keyboard.dismiss();
const { const {
room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages room, name, description, topic, announcement, t, ro, reactWhenReadOnly, joinCode, systemMessages, encrypted
} = this.state; } = this.state;
this.setState({ saving: true }); this.setState({ saving: true });
@ -231,6 +235,11 @@ class RoomInfoEditView extends React.Component {
params.joinCode = joinCode; params.joinCode = joinCode;
} }
// Encrypted
if (room.encrypted !== encrypted) {
params.encrypted = encrypted;
}
try { try {
await RocketChat.saveRoomSettings(room.rid, params); await RocketChat.saveRoomSettings(room.rid, params);
} catch (e) { } catch (e) {
@ -340,7 +349,7 @@ class RoomInfoEditView extends React.Component {
toggleRoomType = (value) => { toggleRoomType = (value) => {
logEvent(events.RI_EDIT_TOGGLE_ROOM_TYPE); logEvent(events.RI_EDIT_TOGGLE_ROOM_TYPE);
this.setState({ t: value }); this.setState(({ encrypted }) => ({ t: value, encrypted: value && encrypted }));
} }
toggleReadOnly = (value) => { toggleReadOnly = (value) => {
@ -358,11 +367,16 @@ class RoomInfoEditView extends React.Component {
this.setState(({ systemMessages }) => ({ enableSysMes: value, systemMessages: value ? systemMessages : [] })); this.setState(({ systemMessages }) => ({ enableSysMes: value, systemMessages: value ? systemMessages : [] }));
} }
toggleEncrypted = (value) => {
logEvent(events.RI_EDIT_TOGGLE_ENCRYPTED);
this.setState({ encrypted: value });
}
render() { render() {
const { const {
name, nameError, description, topic, announcement, t, ro, reactWhenReadOnly, room, joinCode, saving, permissions, archived, enableSysMes name, nameError, description, topic, announcement, t, ro, reactWhenReadOnly, room, joinCode, saving, permissions, archived, enableSysMes, encrypted
} = this.state; } = this.state;
const { serverVersion, theme } = this.props; const { serverVersion, e2eEnabled, theme } = this.props;
const { dangerColor } = themes[theme]; const { dangerColor } = themes[theme];
return ( return (
@ -487,6 +501,19 @@ class RoomInfoEditView extends React.Component {
{this.renderSystemMessages()} {this.renderSystemMessages()}
</SwitchContainer> </SwitchContainer>
) : null} ) : null}
{e2eEnabled ? (
<SwitchContainer
value={encrypted}
disabled={!t}
leftLabelPrimary={I18n.t('Encrypted')}
leftLabelSecondary={I18n.t('End_to_end_encrypted_room')}
theme={theme}
testID='room-info-edit-switch-encrypted'
onValueChange={this.toggleEncrypted}
labelContainerStyle={styles.hideSystemMessages}
leftLabelStyle={styles.systemMessagesLabel}
/>
) : null}
<TouchableOpacity <TouchableOpacity
style={[ style={[
styles.buttonContainer, styles.buttonContainer,
@ -577,7 +604,8 @@ class RoomInfoEditView extends React.Component {
} }
const mapStateToProps = state => ({ const mapStateToProps = state => ({
serverVersion: state.server.version serverVersion: state.server.version,
e2eEnabled: state.settings.E2E_Enable || false
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -17,6 +17,7 @@ import {
import List from './List'; import List from './List';
import database from '../../lib/database'; import database from '../../lib/database';
import RocketChat from '../../lib/rocketchat'; import RocketChat from '../../lib/rocketchat';
import { Encryption } from '../../lib/encryption';
import Message from '../../containers/message'; import Message from '../../containers/message';
import MessageActions from '../../containers/MessageActions'; import MessageActions from '../../containers/MessageActions';
import MessageErrorActions from '../../containers/MessageErrorActions'; import MessageErrorActions from '../../containers/MessageErrorActions';
@ -55,6 +56,7 @@ import Navigation from '../../lib/Navigation';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import { withDimensions } from '../../dimensions'; import { withDimensions } from '../../dimensions';
import { getHeaderTitlePosition } from '../../containers/Header'; import { getHeaderTitlePosition } from '../../containers/Header';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
import { takeInquiry } from '../../ee/omnichannel/lib'; import { takeInquiry } from '../../ee/omnichannel/lib';
@ -582,6 +584,18 @@ class RoomView extends React.Component {
this.setState({ selectedMessage: {}, reactionsModalVisible: false }); this.setState({ selectedMessage: {}, reactionsModalVisible: false });
} }
onEncryptedPress = () => {
logEvent(events.ROOM_ENCRYPTED_PRESS);
const { navigation, isMasterDetail } = this.props;
const screen = { screen: 'E2EHowItWorksView', params: { showCloseModal: true } };
if (isMasterDetail) {
return navigation.navigate('ModalStackNavigator', screen);
}
navigation.navigate('E2ESaveYourPasswordStackNavigator', screen);
}
onDiscussionPress = debounce((item) => { onDiscussionPress = debounce((item) => {
const { navigation } = this.props; const { navigation } = this.props;
navigation.push('RoomView', { navigation.push('RoomView', {
@ -616,8 +630,12 @@ class RoomView extends React.Component {
if (!item.tmsg) { if (!item.tmsg) {
await this.fetchThreadName(item.tmid, item.id); await this.fetchThreadName(item.tmid, item.id);
} }
let name = item.tmsg;
if (item.t === E2E_MESSAGE_TYPE && item.e2e !== E2E_STATUS.DONE) {
name = I18n.t('Encrypted_message');
}
navigation.push('RoomView', { navigation.push('RoomView', {
rid: item.subscription.id, tmid: item.tmid, name: item.tmsg, t: 'thread' rid: item.subscription.id, tmid: item.tmid, name, t: 'thread'
}); });
} else if (item.tlm) { } else if (item.tlm) {
navigation.push('RoomView', { navigation.push('RoomView', {
@ -723,7 +741,8 @@ class RoomView extends React.Component {
}); });
}); });
} else { } else {
const { message: thread } = await RocketChat.getSingleMessage(tmid); let { message: thread } = await RocketChat.getSingleMessage(tmid);
thread = await Encryption.decryptMessage(thread);
await db.action(async() => { await db.action(async() => {
await db.batch( await db.batch(
threadCollection.prepareCreate((t) => { threadCollection.prepareCreate((t) => {
@ -858,6 +877,7 @@ class RoomView extends React.Component {
onReactionPress={this.onReactionPress} onReactionPress={this.onReactionPress}
onReactionLongPress={this.onReactionLongPress} onReactionLongPress={this.onReactionLongPress}
onLongPress={this.onMessageLongPress} onLongPress={this.onMessageLongPress}
onEncryptedPress={this.onEncryptedPress}
onDiscussionPress={this.onDiscussionPress} onDiscussionPress={this.onDiscussionPress}
onThreadPress={this.onThreadPress} onThreadPress={this.onThreadPress}
showAttachment={this.showAttachment} showAttachment={this.showAttachment}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Text } from 'react-native';
import PropTypes from 'prop-types';
import { BorderlessButton } from 'react-native-gesture-handler';
import { withTheme } from '../../../theme';
import { CustomIcon } from '../../../lib/Icons';
import { themes } from '../../../constants/colors';
import I18n from '../../../i18n';
import styles from '../styles';
import { E2E_BANNER_TYPE } from '../../../lib/encryption/constants';
const Encryption = React.memo(({
searching,
goEncryption,
encryptionBanner,
theme
}) => {
if (searching > 0 || !encryptionBanner) {
return null;
}
let text = I18n.t('Save_Your_Encryption_Password');
if (encryptionBanner === E2E_BANNER_TYPE.REQUEST_PASSWORD) {
text = I18n.t('Enter_Your_E2E_Password');
}
return (
<BorderlessButton style={[styles.encryptionButton, { backgroundColor: themes[theme].actionTintColor }]} theme={theme} onPress={goEncryption}>
<CustomIcon name='encrypted' size={24} color={themes[theme].buttonText} style={styles.encryptionIcon} />
<Text style={[styles.encryptionText, { color: themes[theme].buttonText }]}>{text}</Text>
</BorderlessButton>
);
});
Encryption.propTypes = {
searching: PropTypes.bool,
goEncryption: PropTypes.func,
encryptionBanner: PropTypes.string,
theme: PropTypes.string
};
export default withTheme(Encryption);

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import Sort from './Sort'; import Sort from './Sort';
import Encryption from './Encryption';
import OmnichannelStatus from '../../../ee/omnichannel/containers/OmnichannelStatus'; import OmnichannelStatus from '../../../ee/omnichannel/containers/OmnichannelStatus';
@ -9,12 +10,15 @@ const ListHeader = React.memo(({
searching, searching,
sortBy, sortBy,
toggleSort, toggleSort,
goEncryption,
goQueue, goQueue,
queueSize, queueSize,
inquiryEnabled, inquiryEnabled,
encryptionBanner,
user user
}) => ( }) => (
<> <>
<Encryption searching={searching} goEncryption={goEncryption} encryptionBanner={encryptionBanner} />
<Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} /> <Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} />
<OmnichannelStatus searching={searching} goQueue={goQueue} inquiryEnabled={inquiryEnabled} queueSize={queueSize} user={user} /> <OmnichannelStatus searching={searching} goQueue={goQueue} inquiryEnabled={inquiryEnabled} queueSize={queueSize} user={user} />
</> </>
@ -24,9 +28,11 @@ ListHeader.propTypes = {
searching: PropTypes.bool, searching: PropTypes.bool,
sortBy: PropTypes.string, sortBy: PropTypes.string,
toggleSort: PropTypes.func, toggleSort: PropTypes.func,
goEncryption: PropTypes.func,
goQueue: PropTypes.func, goQueue: PropTypes.func,
queueSize: PropTypes.number, queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool, inquiryEnabled: PropTypes.bool,
encryptionBanner: PropTypes.string,
user: PropTypes.object user: PropTypes.object
}; };

View File

@ -63,6 +63,7 @@ import SafeAreaView from '../../containers/SafeAreaView';
import Header, { getHeaderTitlePosition } from '../../containers/Header'; import Header, { getHeaderTitlePosition } from '../../containers/Header';
import { withDimensions } from '../../dimensions'; import { withDimensions } from '../../dimensions';
import { showErrorAlert, showConfirmationAlert } from '../../utils/info'; import { showErrorAlert, showConfirmationAlert } from '../../utils/info';
import { E2E_BANNER_TYPE } from '../../lib/encryption/constants';
import { getInquiryQueueSelector } from '../../ee/omnichannel/selectors/inquiry'; import { getInquiryQueueSelector } from '../../ee/omnichannel/selectors/inquiry';
import { changeLivechatStatus, isOmnichannelStatusAvailable } from '../../ee/omnichannel/lib'; import { changeLivechatStatus, isOmnichannelStatusAvailable } from '../../ee/omnichannel/lib';
@ -98,7 +99,8 @@ const shouldUpdateProps = [
'isMasterDetail', 'isMasterDetail',
'refreshing', 'refreshing',
'queueSize', 'queueSize',
'inquiryEnabled' 'inquiryEnabled',
'encryptionBanner'
]; ];
const getItemLayout = (data, index) => ({ const getItemLayout = (data, index) => ({
length: ROW_HEIGHT, length: ROW_HEIGHT,
@ -143,7 +145,8 @@ class RoomsListView extends React.Component {
width: PropTypes.number, width: PropTypes.number,
insets: PropTypes.object, insets: PropTypes.object,
queueSize: PropTypes.number, queueSize: PropTypes.number,
inquiryEnabled: PropTypes.bool inquiryEnabled: PropTypes.bool,
encryptionBanner: PropTypes.string
}; };
constructor(props) { constructor(props) {
@ -803,6 +806,20 @@ class RoomsListView extends React.Component {
} }
} }
goEncryption = () => {
logEvent(events.RL_GO_E2E_SAVE_PASSWORD);
const { navigation, isMasterDetail, encryptionBanner } = this.props;
const isSavePassword = encryptionBanner === E2E_BANNER_TYPE.SAVE_PASSWORD;
if (isMasterDetail) {
const screen = isSavePassword ? 'E2ESaveYourPasswordView' : 'E2EEnterYourPasswordView';
navigation.navigate('ModalStackNavigator', { screen });
} else {
const screen = isSavePassword ? 'E2ESaveYourPasswordStackNavigator' : 'E2EEnterYourPasswordStackNavigator';
navigation.navigate(screen);
}
}
handleCommands = ({ event }) => { handleCommands = ({ event }) => {
const { navigation, server, isMasterDetail } = this.props; const { navigation, server, isMasterDetail } = this.props;
const { input } = event; const { input } = event;
@ -848,17 +865,18 @@ class RoomsListView extends React.Component {
renderListHeader = () => { renderListHeader = () => {
const { searching } = this.state; const { searching } = this.state;
const { const {
sortBy, queueSize, inquiryEnabled, user sortBy, queueSize, inquiryEnabled, encryptionBanner, user
} = this.props; } = this.props;
return ( return (
<ListHeader <ListHeader
searching={searching} searching={searching}
sortBy={sortBy} sortBy={sortBy}
toggleSort={this.toggleSort} toggleSort={this.toggleSort}
goDirectory={this.goDirectory} goEncryption={this.goEncryption}
goQueue={this.goQueue} goQueue={this.goQueue}
queueSize={queueSize} queueSize={queueSize}
inquiryEnabled={inquiryEnabled} inquiryEnabled={inquiryEnabled}
encryptionBanner={encryptionBanner}
user={user} user={user}
/> />
); );
@ -1028,7 +1046,8 @@ const mapStateToProps = state => ({
StoreLastMessage: state.settings.Store_Last_Message, StoreLastMessage: state.settings.Store_Last_Message,
rooms: state.room.rooms, rooms: state.room.rooms,
queueSize: getInquiryQueueSelector(state).length, queueSize: getInquiryQueueSelector(state).length,
inquiryEnabled: state.inquiry.enabled inquiryEnabled: state.inquiry.enabled,
encryptionBanner: state.encryption.banner
}); });
const mapDispatchToProps = dispatch => ({ const mapDispatchToProps = dispatch => ({

View File

@ -126,6 +126,21 @@ export default StyleSheet.create({
height: StyleSheet.hairlineWidth, height: StyleSheet.hairlineWidth,
marginLeft: 72 marginLeft: 72
}, },
encryptionButton: {
width: '100%',
flexDirection: 'row',
alignItems: 'center',
padding: 12
},
encryptionIcon: {
...sharedStyles.textMedium
},
encryptionText: {
flex: 1,
fontSize: 16,
marginHorizontal: 16,
...sharedStyles.textMedium
},
omnichannelToggle: { omnichannelToggle: {
marginRight: 12 marginRight: 12
} }

View File

@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { View, FlatList, Text } from 'react-native'; import { View, FlatList, Text } from 'react-native';
import { Q } from '@nozbe/watermelondb';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import equal from 'deep-equal'; import equal from 'deep-equal';
@ -20,6 +21,7 @@ import { withTheme } from '../../theme';
import { getUserSelector } from '../../selectors/login'; import { getUserSelector } from '../../selectors/login';
import SafeAreaView from '../../containers/SafeAreaView'; import SafeAreaView from '../../containers/SafeAreaView';
import { CloseModalButton } from '../../containers/HeaderButton'; import { CloseModalButton } from '../../containers/HeaderButton';
import database from '../../lib/database';
class SearchMessagesView extends React.Component { class SearchMessagesView extends React.Component {
static navigationOptions = ({ navigation, route }) => { static navigationOptions = ({ navigation, route }) => {
@ -50,6 +52,7 @@ class SearchMessagesView extends React.Component {
searchText: '' searchText: ''
}; };
this.rid = props.route.params?.rid; this.rid = props.route.params?.rid;
this.encrypted = props.route.params?.encrypted;
} }
shouldComponentUpdate(nextProps, nextState) { shouldComponentUpdate(nextProps, nextState) {
@ -71,21 +74,40 @@ class SearchMessagesView extends React.Component {
} }
componentWillUnmount() { componentWillUnmount() {
this.search.stop(); this.search?.stop?.();
}
// Handle encrypted rooms search messages
searchMessages = async(searchText) => {
// If it's a encrypted, room we'll search only on the local stored messages
if (this.encrypted) {
const db = database.active;
const messagesCollection = db.collections.get('messages');
return messagesCollection
.query(
// Messages of this room
Q.where('rid', this.rid),
// Message content is like the search text
Q.where('msg', Q.like(`%${ Q.sanitizeLikeString(searchText) }%`))
)
.fetch();
}
// If it's not a encrypted room, search messages on the server
const result = await RocketChat.searchMessages(this.rid, searchText);
if (result.success) {
return result.messages;
}
} }
// eslint-disable-next-line react/sort-comp
search = debounce(async(searchText) => { search = debounce(async(searchText) => {
this.setState({ searchText, loading: true, messages: [] }); this.setState({ searchText, loading: true, messages: [] });
try { try {
const result = await RocketChat.searchMessages(this.rid, searchText); const messages = await this.searchMessages(searchText);
if (result.success) { this.setState({
this.setState({ messages: messages || [],
messages: result.messages || [], loading: false
loading: false });
});
}
} catch (e) { } catch (e) {
this.setState({ loading: false }); this.setState({ loading: false });
log(e); log(e);

View File

@ -13,7 +13,7 @@ async function waitForToast() {
// await expect(element(by.id('toast'))).toBeVisible(); // await expect(element(by.id('toast'))).toBeVisible();
// await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000); // await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000);
// await expect(element(by.id('toast'))).toBeNotVisible(); // await expect(element(by.id('toast'))).toBeNotVisible();
await sleep(1); await sleep(300);
} }
describe('Profile screen', () => { describe('Profile screen', () => {

View File

@ -7,7 +7,7 @@ const data = require('../../data');
const testuser = data.users.regular const testuser = data.users.regular
async function waitForToast() { async function waitForToast() {
await sleep(1); await sleep(300);
} }
describe('Status screen', () => { describe('Status screen', () => {

View File

@ -245,7 +245,7 @@ describe('Room actions screen', () => {
//Back into Room Actions //Back into Room Actions
await element(by.id('room-view-header-actions')).tap(); await element(by.id('room-view-header-actions')).tap();
await waitFor(element(by.id('room-actions-view'))).toExist().withTimeout(5000); await waitFor(element(by.id('room-actions-view'))).toExist().withTimeout(5000);
await element(by.type('UIScrollView')).atIndex(1).scrollTo('bottom');
await waitFor(element(by.id('room-actions-pinned'))).toExist(); await waitFor(element(by.id('room-actions-pinned'))).toExist();
await element(by.id('room-actions-pinned')).tap(); await element(by.id('room-actions-pinned')).tap();
await waitFor(element(by.id('pinned-messages-view'))).toExist().withTimeout(2000); await waitFor(element(by.id('pinned-messages-view'))).toExist().withTimeout(2000);
@ -281,6 +281,7 @@ describe('Room actions screen', () => {
describe('Notification', async() => { describe('Notification', async() => {
it('should navigate to notification preference view', async() => { it('should navigate to notification preference view', async() => {
await element(by.type('UIScrollView')).atIndex(1).scrollTo('bottom');
await waitFor(element(by.id('room-actions-notifications'))).toExist().withTimeout(2000); await waitFor(element(by.id('room-actions-notifications'))).toExist().withTimeout(2000);
await element(by.id('room-actions-notifications')).tap(); await element(by.id('room-actions-notifications')).tap();
await waitFor(element(by.id('notification-preference-view'))).toExist().withTimeout(2000); await waitFor(element(by.id('notification-preference-view'))).toExist().withTimeout(2000);

View File

@ -28,7 +28,7 @@ async function waitForToast() {
// await expect(element(by.id('toast'))).toExist(); // await expect(element(by.id('toast'))).toExist();
// await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000); // await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000);
// await expect(element(by.id('toast'))).toBeNotVisible(); // await expect(element(by.id('toast'))).toBeNotVisible();
await sleep(1); await sleep(300);
} }
describe('Room info screen', () => { describe('Room info screen', () => {

View File

@ -386,6 +386,9 @@ PODS:
- React - React
- react-native-safe-area-context (3.1.1): - react-native-safe-area-context (3.1.1):
- React - React
- react-native-simple-crypto (0.3.1):
- OpenSSL-Universal
- React
- react-native-slider (3.0.2): - react-native-slider (3.0.2):
- React - React
- react-native-webview (10.3.2): - react-native-webview (10.3.2):
@ -596,6 +599,7 @@ DEPENDENCIES:
- react-native-notifications (from `../node_modules/react-native-notifications`) - react-native-notifications (from `../node_modules/react-native-notifications`)
- react-native-orientation-locker (from `../node_modules/react-native-orientation-locker`) - react-native-orientation-locker (from `../node_modules/react-native-orientation-locker`)
- react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`) - react-native-safe-area-context (from `../node_modules/react-native-safe-area-context`)
- react-native-simple-crypto (from `../node_modules/react-native-simple-crypto`)
- "react-native-slider (from `../node_modules/@react-native-community/slider`)" - "react-native-slider (from `../node_modules/@react-native-community/slider`)"
- react-native-webview (from `../node_modules/react-native-webview`) - react-native-webview (from `../node_modules/react-native-webview`)
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
@ -756,6 +760,8 @@ EXTERNAL SOURCES:
:path: "../node_modules/react-native-orientation-locker" :path: "../node_modules/react-native-orientation-locker"
react-native-safe-area-context: react-native-safe-area-context:
:path: "../node_modules/react-native-safe-area-context" :path: "../node_modules/react-native-safe-area-context"
react-native-simple-crypto:
:path: "../node_modules/react-native-simple-crypto"
react-native-slider: react-native-slider:
:path: "../node_modules/@react-native-community/slider" :path: "../node_modules/@react-native-community/slider"
react-native-webview: react-native-webview:
@ -918,6 +924,7 @@ SPEC CHECKSUMS:
react-native-notifications: ee8fd739853e72694f3af8b374c8ccb106b7b227 react-native-notifications: ee8fd739853e72694f3af8b374c8ccb106b7b227
react-native-orientation-locker: f0ca1a8e5031dab6b74bfb4ab33a17ed2c2fcb0d react-native-orientation-locker: f0ca1a8e5031dab6b74bfb4ab33a17ed2c2fcb0d
react-native-safe-area-context: 344b969c45af3d8464d36e8dea264942992ef033 react-native-safe-area-context: 344b969c45af3d8464d36e8dea264942992ef033
react-native-simple-crypto: 5e4f2877f71675d95baabf5823cd0cc0c379d0e6
react-native-slider: 0221b417686c5957f6e77cd9ac22c1478a165355 react-native-slider: 0221b417686c5957f6e77cd9ac22c1478a165355
react-native-webview: 679b6f400176e2ea8a785acf7ae16cf282e7d1eb react-native-webview: 679b6f400176e2ea8a785acf7ae16cf282e7d1eb
React-RCTActionSheet: 1702a1a85e550b5c36e2e03cb2bd3adea053de95 React-RCTActionSheet: 1702a1a85e550b5c36e2e03cb2bd3adea053de95

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Aes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Hmac.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Pbkdf2.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTAes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTCrypto-Bridging-Header.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTHmac.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTPbkdf2.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTRsa.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTSha.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RNRandomBytes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Rsa.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/RsaFormatter.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Sha.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Shared.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Aes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Hmac.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Pbkdf2.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTAes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTCrypto-Bridging-Header.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTHmac.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTPbkdf2.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTRsa.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTSha.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RNRandomBytes.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Rsa.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/RsaFormatter.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Sha.h

View File

@ -0,0 +1 @@
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Shared.h

View File

@ -0,0 +1 @@
../../../Target Support Files/react-native-simple-crypto/react-native-simple-crypto-umbrella.h

View File

@ -0,0 +1 @@
../../../Target Support Files/react-native-simple-crypto/react-native-simple-crypto.modulemap

View File

@ -0,0 +1,24 @@
{
"name": "react-native-simple-crypto",
"version": "0.3.1",
"summary": "A simpler React-Native crypto library",
"authors": "Gary Button <gary.button.public@gmail.com>",
"license": "MIT",
"requires_arc": true,
"homepage": "https://github.com/ghbutton/react-native-simple-crypto",
"source": {
"git": "git+https://github.com/ghbutton/react-native-simple-crypto.git"
},
"platforms": {
"ios": "8.0"
},
"source_files": "ios/**/*.{h,m,swift}",
"dependencies": {
"React": [
],
"OpenSSL-Universal": [
]
}
}

Some files were not shown because too many files have changed in this diff Show More