[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:
parent
e9531298e7
commit
3c9017a62d
|
@ -3333,6 +3333,165 @@ exports[`Storyshots Message list message 1`] = `
|
|||
>
|
||||
Edited
|
||||
</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
|
||||
style={
|
||||
Array [
|
||||
|
|
|
@ -67,3 +67,4 @@ export const INVITE_LINKS = createRequestTypes('INVITE_LINKS', [
|
|||
export const SETTINGS = createRequestTypes('SETTINGS', ['CLEAR', 'ADD']);
|
||||
export const APP_STATE = createRequestTypes('APP_STATE', ['FOREGROUND', 'BACKGROUND']);
|
||||
export const ENTERPRISE_MODULES = createRequestTypes('ENTERPRISE_MODULES', ['CLEAR', 'SET']);
|
||||
export const ENCRYPTION = createRequestTypes('ENCRYPTION', ['INIT', 'STOP', 'DECODE_KEY', 'SET_BANNER']);
|
||||
|
|
|
@ -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
|
||||
};
|
||||
}
|
|
@ -68,6 +68,9 @@ export default {
|
|||
DirectMesssage_maxUsers: {
|
||||
type: 'valueAsNumber'
|
||||
},
|
||||
E2E_Enable: {
|
||||
type: 'valueAsBoolean'
|
||||
},
|
||||
Accounts_Directory_DefaultView: {
|
||||
type: 'valueAsString'
|
||||
},
|
||||
|
|
|
@ -9,6 +9,8 @@ import Markdown from '../markdown';
|
|||
import { getInfoMessage } from './utils';
|
||||
import { themes } from '../../constants/colors';
|
||||
import MessageContext from './Context';
|
||||
import Encrypted from './Encrypted';
|
||||
import { E2E_MESSAGE_TYPE } from '../../lib/encryption/constants';
|
||||
|
||||
const Content = React.memo((props) => {
|
||||
if (props.isInfo) {
|
||||
|
@ -22,10 +24,13 @@ const Content = React.memo((props) => {
|
|||
);
|
||||
}
|
||||
|
||||
const isPreview = props.tmid && !props.isThreadRoom;
|
||||
let content = null;
|
||||
|
||||
if (props.tmid && !props.msg) {
|
||||
content = <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 {
|
||||
const { baseUrl, user } = useContext(MessageContext);
|
||||
content = (
|
||||
|
@ -35,8 +40,8 @@ const Content = React.memo((props) => {
|
|||
getCustomEmoji={props.getCustomEmoji}
|
||||
username={user.username}
|
||||
isEdited={props.isEdited}
|
||||
numberOfLines={(props.tmid && !props.isThreadRoom) ? 1 : 0}
|
||||
preview={props.tmid && !props.isThreadRoom}
|
||||
numberOfLines={isPreview ? 1 : 0}
|
||||
preview={isPreview}
|
||||
channels={props.channels}
|
||||
mentions={props.mentions}
|
||||
navToRoomInfo={props.navToRoomInfo}
|
||||
|
@ -47,6 +52,21 @@ const Content = React.memo((props) => {
|
|||
);
|
||||
}
|
||||
|
||||
// If this is a encrypted message and is not a preview
|
||||
if (props.type === E2E_MESSAGE_TYPE && !isPreview) {
|
||||
content = (
|
||||
<View style={styles.flex}>
|
||||
<View style={styles.contentContainer}>
|
||||
{content}
|
||||
</View>
|
||||
<Encrypted
|
||||
type={props.type}
|
||||
theme={props.theme}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={props.isTemp && styles.temp}>
|
||||
{content}
|
||||
|
@ -59,9 +79,15 @@ const Content = React.memo((props) => {
|
|||
if (prevProps.msg !== nextProps.msg) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.type !== nextProps.type) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.theme !== nextProps.theme) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isEncrypted !== nextProps.isEncrypted) {
|
||||
return false;
|
||||
}
|
||||
if (!equal(prevProps.mentions, nextProps.mentions)) {
|
||||
return false;
|
||||
}
|
||||
|
@ -79,11 +105,13 @@ Content.propTypes = {
|
|||
msg: PropTypes.string,
|
||||
theme: PropTypes.string,
|
||||
isEdited: PropTypes.bool,
|
||||
isEncrypted: PropTypes.bool,
|
||||
getCustomEmoji: PropTypes.func,
|
||||
channels: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
mentions: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
||||
navToRoomInfo: PropTypes.func,
|
||||
useRealName: PropTypes.bool
|
||||
useRealName: PropTypes.bool,
|
||||
type: PropTypes.string
|
||||
};
|
||||
Content.displayName = 'MessageContent';
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -8,9 +8,10 @@ import { CustomIcon } from '../../lib/Icons';
|
|||
import DisclosureIndicator from '../DisclosureIndicator';
|
||||
import styles from './styles';
|
||||
import { themes } from '../../constants/colors';
|
||||
import I18n from '../../i18n';
|
||||
|
||||
const RepliedThread = React.memo(({
|
||||
tmid, tmsg, isHeader, fetchThreadName, id, theme
|
||||
tmid, tmsg, isHeader, fetchThreadName, id, isEncrypted, theme
|
||||
}) => {
|
||||
if (!tmid || !isHeader) {
|
||||
return null;
|
||||
|
@ -24,6 +25,10 @@ const RepliedThread = React.memo(({
|
|||
let msg = shortnameToUnicode(tmsg);
|
||||
msg = removeMarkdown(msg);
|
||||
|
||||
if (isEncrypted) {
|
||||
msg = I18n.t('Encrypted_message');
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.repliedThread} testID={`message-thread-replied-on-${ msg }`}>
|
||||
<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) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isEncrypted !== nextProps.isEncrypted) {
|
||||
return false;
|
||||
}
|
||||
if (prevProps.isHeader !== nextProps.isHeader) {
|
||||
return false;
|
||||
}
|
||||
|
@ -53,7 +61,8 @@ RepliedThread.propTypes = {
|
|||
id: PropTypes.string,
|
||||
isHeader: PropTypes.bool,
|
||||
theme: PropTypes.string,
|
||||
fetchThreadName: PropTypes.func
|
||||
fetchThreadName: PropTypes.func,
|
||||
isEncrypted: PropTypes.bool
|
||||
};
|
||||
RepliedThread.displayName = 'MessageRepliedThread';
|
||||
|
||||
|
|
|
@ -8,6 +8,7 @@ import debounce from '../../utils/debounce';
|
|||
import { SYSTEM_MESSAGES, getMessageTranslation } from './utils';
|
||||
import messagesStatus from '../../constants/messagesStatus';
|
||||
import { withTheme } from '../../theme';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
|
||||
|
||||
class MessageContainer extends React.Component {
|
||||
static propTypes = {
|
||||
|
@ -35,6 +36,7 @@ class MessageContainer extends React.Component {
|
|||
getCustomEmoji: PropTypes.func,
|
||||
onLongPress: PropTypes.func,
|
||||
onReactionPress: PropTypes.func,
|
||||
onEncryptedPress: PropTypes.func,
|
||||
onDiscussionPress: PropTypes.func,
|
||||
onThreadPress: PropTypes.func,
|
||||
errorActionsShow: PropTypes.func,
|
||||
|
@ -53,6 +55,7 @@ class MessageContainer extends React.Component {
|
|||
getCustomEmoji: () => {},
|
||||
onLongPress: () => {},
|
||||
onReactionPress: () => {},
|
||||
onEncryptedPress: () => {},
|
||||
onDiscussionPress: () => {},
|
||||
onThreadPress: () => {},
|
||||
errorActionsShow: () => {},
|
||||
|
@ -104,7 +107,7 @@ class MessageContainer extends React.Component {
|
|||
|
||||
onLongPress = () => {
|
||||
const { archived, onLongPress, item } = this.props;
|
||||
if (this.isInfo || this.hasError || archived) {
|
||||
if (this.isInfo || this.hasError || this.isEncrypted || archived) {
|
||||
return;
|
||||
}
|
||||
if (onLongPress) {
|
||||
|
@ -133,6 +136,13 @@ class MessageContainer extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
onEncryptedPress = () => {
|
||||
const { onEncryptedPress } = this.props;
|
||||
if (onEncryptedPress) {
|
||||
onEncryptedPress();
|
||||
}
|
||||
}
|
||||
|
||||
onDiscussionPress = () => {
|
||||
const { onDiscussionPress, item } = this.props;
|
||||
if (onDiscussionPress) {
|
||||
|
@ -196,6 +206,12 @@ class MessageContainer extends React.Component {
|
|||
return false;
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
const { item } = this.props;
|
||||
const { t, e2e } = item;
|
||||
return t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE;
|
||||
}
|
||||
|
||||
get isInfo() {
|
||||
const { item } = this.props;
|
||||
return SYSTEM_MESSAGES.includes(item.t);
|
||||
|
@ -251,6 +267,7 @@ class MessageContainer extends React.Component {
|
|||
onErrorPress: this.onErrorPress,
|
||||
replyBroadcast: this.replyBroadcast,
|
||||
onReactionPress: this.onReactionPress,
|
||||
onEncryptedPress: this.onEncryptedPress,
|
||||
onDiscussionPress: this.onDiscussionPress,
|
||||
onReactionLongPress: this.onReactionLongPress
|
||||
}}
|
||||
|
@ -295,6 +312,7 @@ class MessageContainer extends React.Component {
|
|||
isThreadRoom={isThreadRoom}
|
||||
isInfo={this.isInfo}
|
||||
isTemp={this.isTemp}
|
||||
isEncrypted={this.isEncrypted}
|
||||
hasError={this.hasError}
|
||||
showAttachment={showAttachment}
|
||||
getCustomEmoji={getCustomEmoji}
|
||||
|
|
|
@ -13,6 +13,9 @@ export default StyleSheet.create({
|
|||
paddingHorizontal: 14,
|
||||
flexDirection: 'column'
|
||||
},
|
||||
contentContainer: {
|
||||
flex: 1
|
||||
},
|
||||
messageContent: {
|
||||
flex: 1,
|
||||
marginLeft: 46
|
||||
|
@ -163,5 +166,8 @@ export default StyleSheet.create({
|
|||
},
|
||||
readReceipt: {
|
||||
lineHeight: 20
|
||||
},
|
||||
encrypted: {
|
||||
justifyContent: 'center'
|
||||
}
|
||||
});
|
||||
|
|
|
@ -198,11 +198,17 @@ export default {
|
|||
Do_you_have_an_account: 'Do you have an account?',
|
||||
Do_you_have_a_certificate: 'Do you have a certificate?',
|
||||
Do_you_really_want_to_key_this_room_question_mark: 'Do you really want to {{key}} this room?',
|
||||
E2E_How_It_Works_info1: 'You can now create encrypted private groups and direct messages. You may also change existing private groups or DMs to encrypted.',
|
||||
E2E_How_It_Works_info2: 'This is *end to end encryption* so the key to encode/decode your messages and they will not be saved on the server. For that reason *you need to store this password somewhere safe* which you can access later if you may need.',
|
||||
E2E_How_It_Works_info3: 'If you proceed, it will be auto generated an E2E password.',
|
||||
E2E_How_It_Works_info4: 'You can also setup a new password for your encryption key any time from any browser you have entered the existing E2E password.',
|
||||
edit: 'edit',
|
||||
edited: 'edited',
|
||||
Edit: 'Edit',
|
||||
Edit_Status: 'Edit Status',
|
||||
Edit_Invite: 'Edit Invite',
|
||||
End_to_end_encrypted_room: 'End to end encrypted room',
|
||||
end_to_end_encryption: 'end to end encryption',
|
||||
Email_Notification_Mode_All: 'Every Mention/DM',
|
||||
Email_Notification_Mode_Disabled: 'Disabled',
|
||||
Email_or_password_field_is_empty: 'Email or password field is empty',
|
||||
|
@ -212,6 +218,13 @@ export default {
|
|||
Empty_title: 'Empty title',
|
||||
Enable_Auto_Translate: 'Enable Auto-Translate',
|
||||
Enable_notifications: 'Enable notifications',
|
||||
Encrypted: 'Encrypted',
|
||||
Encrypted_message: 'Encrypted message',
|
||||
Enter_Your_E2E_Password: 'Enter Your E2E Password',
|
||||
Enter_Your_Encryption_Password_desc1: 'This will allow you to access your encrypted private groups and direct messages.',
|
||||
Enter_Your_Encryption_Password_desc2: 'You need to enter the password to encode/decode messages every place you use the chat.',
|
||||
Encryption_error_title: 'Your encryption password seems wrong',
|
||||
Encryption_error_desc: 'Wasn\'t possible to decode your encryption key to be imported.',
|
||||
Everyone_can_access_this_channel: 'Everyone can access this channel',
|
||||
Error_uploading: 'Error uploading',
|
||||
Expiration_Days: 'Expiration (Days)',
|
||||
|
@ -240,6 +253,7 @@ export default {
|
|||
Has_left_the_channel: 'Has left the channel',
|
||||
Hide_System_Messages: 'Hide System Messages',
|
||||
Hide_type_messages: 'Hide "{{type}}" messages',
|
||||
How_It_Works: 'How It Works',
|
||||
Message_HideType_uj: 'User Join',
|
||||
Message_HideType_ul: 'User Leave',
|
||||
Message_HideType_ru: 'User Removed',
|
||||
|
@ -253,6 +267,7 @@ export default {
|
|||
Message_HideType_subscription_role_removed: 'Role No Longer Defined',
|
||||
Message_HideType_room_archived: 'Room Archived',
|
||||
Message_HideType_room_unarchived: 'Room Unarchived',
|
||||
I_Saved_My_E2E_Password: 'I Saved My E2E Password',
|
||||
IP: 'IP',
|
||||
In_app: 'In-app',
|
||||
IN_APP_AND_DESKTOP: 'IN-APP AND DESKTOP',
|
||||
|
@ -438,6 +453,10 @@ export default {
|
|||
saving_profile: 'saving profile',
|
||||
saving_settings: 'saving settings',
|
||||
saved_to_gallery: 'Saved to gallery',
|
||||
Save_Your_E2E_Password: 'Save Your E2E Password',
|
||||
Save_Your_Encryption_Password: 'Save Your Encryption Password',
|
||||
Save_Your_Encryption_Password_warning: 'This password is not stored anywhere so save it carefully somewhere else.',
|
||||
Save_Your_Encryption_Password_info: 'Notice that you lose your password, there is no way to recover it and you will lose access to your messages.',
|
||||
Search_Messages: 'Search Messages',
|
||||
Search: 'Search',
|
||||
Search_by: 'Search by',
|
||||
|
@ -585,6 +604,7 @@ export default {
|
|||
Your_invite_link_will_expire_on__date__: 'Your invite link will expire on {{date}}.',
|
||||
Your_invite_link_will_never_expire: 'Your invite link will never expire.',
|
||||
Your_workspace: 'Your workspace',
|
||||
Your_password_is: 'Your password is',
|
||||
Version_no: 'Version: {{version}}',
|
||||
You_will_not_be_able_to_recover_this_message: 'You will not be able to recover this message!',
|
||||
You_will_unset_a_certificate_for_this_server: 'You will unset a certificate for this server',
|
||||
|
|
|
@ -192,11 +192,17 @@ export default {
|
|||
Dont_Have_An_Account: 'Não tem uma conta?',
|
||||
Do_you_have_an_account: 'Você tem uma conta?',
|
||||
Do_you_really_want_to_key_this_room_question_mark: 'Você quer realmente {{key}} esta sala?',
|
||||
E2E_How_It_Works_info1: 'Agora você pode criar grupos privados criptografados e mensagens diretas. Você também pode alterar grupos privados existentes ou DMs para criptografados.',
|
||||
E2E_How_It_Works_info2: 'Esta é a criptografia *ponta a ponta*, portanto, a chave para codificar/decodificar suas mensagens e elas não serão salvas no servidor. Por esse motivo *você precisa armazenar esta senha em algum lugar seguro* que você pode acessar mais tarde se precisar.',
|
||||
E2E_How_It_Works_info3: 'Se você continuar, será gerada automaticamente uma senha E2E.',
|
||||
E2E_How_It_Works_info4: 'Você também pode configurar uma nova senha para sua chave de criptografia a qualquer momento em qualquer navegador em que tenha inserido a senha E2E existente.',
|
||||
edit: 'editar',
|
||||
edited: 'editado',
|
||||
Edit: 'Editar',
|
||||
Edit_Invite: 'Editar convite',
|
||||
Edit_Status: 'Editar Status',
|
||||
End_to_end_encrypted_room: 'Sala criptografada de ponta a ponta',
|
||||
end_to_end_encryption: 'criptografia de ponta a ponta',
|
||||
Email_or_password_field_is_empty: 'Email ou senha estão vazios',
|
||||
Email: 'Email',
|
||||
email: 'e-mail',
|
||||
|
@ -205,6 +211,13 @@ export default {
|
|||
Email_Notification_Mode_Disabled: 'Desativado',
|
||||
Enable_Auto_Translate: 'Ativar a tradução automática',
|
||||
Enable_notifications: 'Habilitar notificações',
|
||||
Encrypted: 'Criptografado',
|
||||
Encrypted_message: 'Mensagem criptografada',
|
||||
Enter_Your_E2E_Password: 'Digite Sua Senha E2E',
|
||||
Enter_Your_Encryption_Password_desc1: 'Isso permitirá que você acesse seus grupos privados e mensagens diretas criptografadas.',
|
||||
Enter_Your_Encryption_Password_desc2: 'Você precisa inserir a senha para codificar/decodificar mensagens em todos os lugares em que usar o chat.',
|
||||
Encryption_error_title: 'Sua senha de criptografia parece errada',
|
||||
Encryption_error_desc: 'Não foi possível decodificar sua chave de criptografia para ser importada.',
|
||||
Everyone_can_access_this_channel: 'Todos podem acessar este canal',
|
||||
Error_uploading: 'Erro subindo',
|
||||
Expiration_Days: 'Expira em (dias)',
|
||||
|
@ -410,6 +423,10 @@ export default {
|
|||
saving_profile: 'salvando perfil',
|
||||
saving_settings: 'salvando configurações',
|
||||
saved_to_gallery: 'Salvo na galeria',
|
||||
Save_Your_E2E_Password: 'Salve sua senha E2E',
|
||||
Save_Your_Encryption_Password: 'Salve Sua Senha de Criptografia',
|
||||
Save_Your_Encryption_Password_warning: 'Esta senha não é armazenada em nenhum lugar, portanto, salve-a com cuidado em outro lugar.',
|
||||
Save_Your_Encryption_Password_info: 'Observe que se você perder sua senha, não há como recuperá-la e você perderá o acesso às suas mensagens.',
|
||||
Search_Messages: 'Buscar Mensagens',
|
||||
Search: 'Buscar',
|
||||
Search_by: 'Buscar por',
|
||||
|
|
|
@ -77,4 +77,6 @@ export default class Message extends Model {
|
|||
@field('tmsg') tmsg;
|
||||
|
||||
@json('blocks', sanitizer) blocks;
|
||||
|
||||
@field('e2e') e2e;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,8 @@ export default class Room extends Model {
|
|||
|
||||
@field('encrypted') encrypted;
|
||||
|
||||
@field('e2e_key_id') e2eKeyId;
|
||||
|
||||
@field('ro') ro;
|
||||
|
||||
@json('v', sanitizer) v;
|
||||
|
|
|
@ -29,4 +29,6 @@ export default class Server extends Model {
|
|||
@field('unique_id') uniqueID;
|
||||
|
||||
@field('enterprise_modules') enterpriseModules;
|
||||
|
||||
@field('e2e_enable') E2E_Enable;
|
||||
}
|
||||
|
|
|
@ -109,4 +109,10 @@ export default class Subscription extends Model {
|
|||
@json('livechat_data', sanitizer) livechatData;
|
||||
|
||||
@json('tags', sanitizer) tags;
|
||||
|
||||
@field('e2e_key') E2EKey;
|
||||
|
||||
@field('encrypted') encrypted;
|
||||
|
||||
@field('e2e_key_id') e2eKeyId;
|
||||
}
|
||||
|
|
|
@ -73,4 +73,6 @@ export default class Thread extends Model {
|
|||
@field('auto_translate') autoTranslate;
|
||||
|
||||
@json('translations', sanitizer) translations;
|
||||
|
||||
@field('e2e') e2e;
|
||||
}
|
||||
|
|
|
@ -75,4 +75,6 @@ export default class ThreadMessage extends Model {
|
|||
@json('translations', sanitizer) translations;
|
||||
|
||||
@field('draft_message') draftMessage;
|
||||
|
||||
@field('e2e') e2e;
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
|
@ -59,6 +59,17 @@ export default schemaMigrations({
|
|||
]
|
||||
})
|
||||
]
|
||||
},
|
||||
{
|
||||
toVersion: 8,
|
||||
steps: [
|
||||
addColumns({
|
||||
table: 'servers',
|
||||
columns: [
|
||||
{ name: 'e2e_enable', type: 'boolean', isOptional: true }
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||
|
||||
export default appSchema({
|
||||
version: 9,
|
||||
version: 10,
|
||||
tables: [
|
||||
tableSchema({
|
||||
name: 'subscriptions',
|
||||
|
@ -49,7 +49,10 @@ export default appSchema({
|
|||
{ name: 'department_id', type: 'string', isOptional: true },
|
||||
{ name: 'served_by', type: 'string', isOptional: true },
|
||||
{ name: 'livechat_data', type: 'string', isOptional: true },
|
||||
{ name: 'tags', type: 'string', isOptional: true }
|
||||
{ name: 'tags', type: 'string', isOptional: true },
|
||||
{ name: 'e2e_key', type: 'string', isOptional: true },
|
||||
{ name: 'encrypted', type: 'boolean', isOptional: true },
|
||||
{ name: 'e2e_key_id', type: 'string', isOptional: true }
|
||||
]
|
||||
}),
|
||||
tableSchema({
|
||||
|
@ -63,7 +66,8 @@ export default appSchema({
|
|||
{ name: 'department_id', type: 'string', isOptional: true },
|
||||
{ name: 'served_by', type: 'string', isOptional: true },
|
||||
{ name: 'livechat_data', type: 'string', isOptional: true },
|
||||
{ name: 'tags', type: 'string', isOptional: true }
|
||||
{ name: 'tags', type: 'string', isOptional: true },
|
||||
{ name: 'e2e_key_id', type: 'string', isOptional: true }
|
||||
]
|
||||
}),
|
||||
tableSchema({
|
||||
|
@ -101,7 +105,8 @@ export default appSchema({
|
|||
{ name: 'auto_translate', type: 'boolean', isOptional: true },
|
||||
{ name: 'translations', type: 'string', isOptional: true },
|
||||
{ name: 'tmsg', type: 'string', isOptional: true },
|
||||
{ name: 'blocks', type: 'string', isOptional: true }
|
||||
{ name: 'blocks', type: 'string', isOptional: true },
|
||||
{ name: 'e2e', type: 'string', isOptional: true }
|
||||
]
|
||||
}),
|
||||
tableSchema({
|
||||
|
@ -137,7 +142,8 @@ export default appSchema({
|
|||
{ name: 'channels', type: 'string', isOptional: true },
|
||||
{ name: 'unread', type: 'boolean', isOptional: true },
|
||||
{ name: 'auto_translate', type: 'boolean', isOptional: true },
|
||||
{ name: 'translations', type: 'string', isOptional: true }
|
||||
{ name: 'translations', type: 'string', isOptional: true },
|
||||
{ name: 'e2e', type: 'string', isOptional: true }
|
||||
]
|
||||
}),
|
||||
tableSchema({
|
||||
|
@ -173,7 +179,8 @@ export default appSchema({
|
|||
{ name: 'channels', type: 'string', isOptional: true },
|
||||
{ name: 'unread', type: 'boolean', isOptional: true },
|
||||
{ name: 'auto_translate', type: 'boolean', isOptional: true },
|
||||
{ name: 'translations', type: 'string', isOptional: true }
|
||||
{ name: 'translations', type: 'string', isOptional: true },
|
||||
{ name: 'e2e', type: 'string', isOptional: true }
|
||||
]
|
||||
}),
|
||||
tableSchema({
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { appSchema, tableSchema } from '@nozbe/watermelondb';
|
||||
|
||||
export default appSchema({
|
||||
version: 7,
|
||||
version: 8,
|
||||
tables: [
|
||||
tableSchema({
|
||||
name: 'users',
|
||||
|
@ -31,7 +31,8 @@ export default appSchema({
|
|||
{ name: 'auto_lock_time', type: 'number', isOptional: true },
|
||||
{ name: 'biometry', type: 'boolean', isOptional: true },
|
||||
{ name: 'unique_id', type: 'string', isOptional: true },
|
||||
{ name: 'enterprise_modules', type: 'string', isOptional: true }
|
||||
{ name: 'enterprise_modules', type: 'string', isOptional: true },
|
||||
{ name: 'e2e_enable', type: 'boolean', isOptional: true }
|
||||
]
|
||||
})
|
||||
]
|
||||
|
|
|
@ -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.
|
|
@ -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'
|
||||
};
|
|
@ -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;
|
|
@ -0,0 +1,4 @@
|
|||
import Encryption from './encryption';
|
||||
import EncryptionRoom from './room';
|
||||
|
||||
export { Encryption, EncryptionRoom };
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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();
|
|
@ -11,7 +11,16 @@ import protectedFunction from './helpers/protectedFunction';
|
|||
import fetch from '../../utils/fetch';
|
||||
import { DEFAULT_AUTO_LOCK } from '../../constants/localAuthentication';
|
||||
|
||||
const serverInfoKeys = ['Site_Name', 'UI_Use_Real_Name', 'FileUpload_MediaTypeWhiteList', 'FileUpload_MaxFileSize', 'Force_Screen_Lock', 'Force_Screen_Lock_After', 'uniqueID'];
|
||||
const serverInfoKeys = [
|
||||
'Site_Name',
|
||||
'UI_Use_Real_Name',
|
||||
'FileUpload_MediaTypeWhiteList',
|
||||
'FileUpload_MaxFileSize',
|
||||
'Force_Screen_Lock',
|
||||
'Force_Screen_Lock_After',
|
||||
'uniqueID',
|
||||
'E2E_Enable'
|
||||
];
|
||||
|
||||
// these settings are used only on onboarding process
|
||||
const loginSettings = [
|
||||
|
@ -71,6 +80,9 @@ const serverInfoUpdate = async(serverInfo, iconSetting) => {
|
|||
if (setting._id === 'uniqueID') {
|
||||
return { ...allSettings, uniqueID: setting.valueAsString };
|
||||
}
|
||||
if (setting._id === 'E2E_Enable') {
|
||||
return { ...allSettings, E2E_Enable: setting.valueAsBoolean };
|
||||
}
|
||||
return allSettings;
|
||||
}, {});
|
||||
|
||||
|
|
|
@ -49,7 +49,10 @@ export default async(subscriptions = [], rooms = []) => {
|
|||
departmentId: s.departmentId,
|
||||
servedBy: s.servedBy,
|
||||
livechatData: s.livechatData,
|
||||
tags: s.tags
|
||||
tags: s.tags,
|
||||
encrypted: s.encrypted,
|
||||
e2eKeyId: s.e2eKeyId,
|
||||
E2EKey: s.E2EKey
|
||||
}));
|
||||
subscriptions = subscriptions.concat(existingSubs);
|
||||
|
||||
|
@ -75,7 +78,9 @@ export default async(subscriptions = [], rooms = []) => {
|
|||
departmentId: r.departmentId,
|
||||
servedBy: r.servedBy,
|
||||
livechatData: r.livechatData,
|
||||
tags: r.tags
|
||||
tags: r.tags,
|
||||
encrypted: r.encrypted,
|
||||
e2eKeyId: r.e2eKeyId
|
||||
}));
|
||||
rooms = rooms.concat(existingRooms);
|
||||
} catch {
|
||||
|
|
|
@ -2,6 +2,7 @@ import EJSON from 'ejson';
|
|||
|
||||
import normalizeMessage from './normalizeMessage';
|
||||
import findSubscriptionsRooms from './findSubscriptionsRooms';
|
||||
import { Encryption } from '../../encryption';
|
||||
// TODO: delete and update
|
||||
|
||||
export const merge = (subscription, room) => {
|
||||
|
@ -27,6 +28,8 @@ export const merge = (subscription, room) => {
|
|||
}
|
||||
subscription.ro = room.ro;
|
||||
subscription.broadcast = room.broadcast;
|
||||
subscription.encrypted = room.encrypted;
|
||||
subscription.e2eKeyId = room.e2eKeyId;
|
||||
if (!subscription.roles || !subscription.roles.length) {
|
||||
subscription.roles = [];
|
||||
}
|
||||
|
@ -72,17 +75,23 @@ export default async(subscriptions = [], rooms = []) => {
|
|||
rooms = rooms.update;
|
||||
}
|
||||
|
||||
// Find missing rooms/subscriptions on local database
|
||||
({ subscriptions, rooms } = await findSubscriptionsRooms(subscriptions, rooms));
|
||||
|
||||
return {
|
||||
subscriptions: subscriptions.map((s) => {
|
||||
// Merge each subscription into a room
|
||||
subscriptions = subscriptions.map((s) => {
|
||||
const index = rooms.findIndex(({ _id }) => _id === s.rid);
|
||||
// Room not found
|
||||
if (index < 0) {
|
||||
return merge(s);
|
||||
}
|
||||
const [room] = rooms.splice(index, 1);
|
||||
return merge(s, room);
|
||||
}),
|
||||
});
|
||||
// Decrypt all subscriptions missing decryption
|
||||
subscriptions = await Encryption.decryptSubscriptions(subscriptions);
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
rooms
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,6 +6,7 @@ import buildMessage from './helpers/buildMessage';
|
|||
import database from '../database';
|
||||
import log from '../../utils/log';
|
||||
import protectedFunction from './helpers/protectedFunction';
|
||||
import { Encryption } from '../encryption';
|
||||
|
||||
async function load({ tmid, offset }) {
|
||||
try {
|
||||
|
@ -32,6 +33,7 @@ export default function loadThreadMessages({ tmid, rid, offset = 0 }) {
|
|||
InteractionManager.runAfterInteractions(async() => {
|
||||
try {
|
||||
data = data.map(m => buildMessage(m));
|
||||
data = await Encryption.decryptMessages(data);
|
||||
const db = database.active;
|
||||
const threadMessagesCollection = db.collections.get('thread_messages');
|
||||
const allThreadMessagesRecords = await threadMessagesCollection.query(Q.where('rid', tmid)).fetch();
|
||||
|
|
|
@ -7,12 +7,20 @@ import { BASIC_AUTH_KEY } from '../../utils/fetch';
|
|||
import database, { getDatabase } from '../database';
|
||||
import RocketChat from '../rocketchat';
|
||||
import { useSsl } from '../../utils/url';
|
||||
import {
|
||||
E2E_PUBLIC_KEY,
|
||||
E2E_PRIVATE_KEY,
|
||||
E2E_RANDOM_PASSWORD_KEY
|
||||
} from '../encryption/constants';
|
||||
import UserPreferences from '../userPreferences';
|
||||
|
||||
async function removeServerKeys({ server, userId }) {
|
||||
await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ server }`);
|
||||
await UserPreferences.removeItem(`${ RocketChat.TOKEN_KEY }-${ userId }`);
|
||||
await UserPreferences.removeItem(`${ BASIC_AUTH_KEY }-${ server }`);
|
||||
await UserPreferences.removeItem(`${ server }-${ E2E_PUBLIC_KEY }`);
|
||||
await UserPreferences.removeItem(`${ server }-${ E2E_PRIVATE_KEY }`);
|
||||
await UserPreferences.removeItem(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`);
|
||||
}
|
||||
|
||||
async function removeSharedCredentials({ server }) {
|
||||
|
|
|
@ -4,6 +4,8 @@ import messagesStatus from '../../constants/messagesStatus';
|
|||
import database from '../database';
|
||||
import log from '../../utils/log';
|
||||
import random from '../../utils/random';
|
||||
import { Encryption } from '../encryption';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../encryption/constants';
|
||||
|
||||
const changeMessageStatus = async(id, tmid, status, message) => {
|
||||
const db = database.active;
|
||||
|
@ -44,17 +46,11 @@ const changeMessageStatus = async(id, tmid, status, message) => {
|
|||
};
|
||||
|
||||
export async function sendMessageCall(message) {
|
||||
const {
|
||||
id: _id, subscription: { id: rid }, msg, tmid
|
||||
} = message;
|
||||
const { _id, tmid } = message;
|
||||
try {
|
||||
const sdk = this.shareSDK || this.sdk;
|
||||
// RC 0.60.0
|
||||
const result = await sdk.post('chat.sendMessage', {
|
||||
message: {
|
||||
_id, rid, msg, tmid
|
||||
}
|
||||
});
|
||||
const result = await sdk.post('chat.sendMessage', { message });
|
||||
if (result.success) {
|
||||
return changeMessageStatus(_id, tmid, messagesStatus.SENT, result.message);
|
||||
}
|
||||
|
@ -64,6 +60,32 @@ export async function sendMessageCall(message) {
|
|||
return changeMessageStatus(_id, tmid, messagesStatus.ERROR);
|
||||
}
|
||||
|
||||
export async function resendMessage(message, tmid) {
|
||||
const db = database.active;
|
||||
try {
|
||||
await db.action(async() => {
|
||||
await message.update((m) => {
|
||||
m.status = messagesStatus.TEMP;
|
||||
});
|
||||
});
|
||||
let m = {
|
||||
_id: message.id,
|
||||
rid: message.subscription.id,
|
||||
msg: message.msg
|
||||
};
|
||||
if (tmid) {
|
||||
m = {
|
||||
...m,
|
||||
tmid
|
||||
};
|
||||
}
|
||||
m = await Encryption.encryptMessage(m);
|
||||
await sendMessageCall.call(this, m);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
}
|
||||
|
||||
export default async function(rid, msg, tmid, user) {
|
||||
try {
|
||||
const db = database.active;
|
||||
|
@ -73,9 +95,12 @@ export default async function(rid, msg, tmid, user) {
|
|||
const threadMessagesCollection = db.collections.get('thread_messages');
|
||||
const messageId = random(17);
|
||||
const batch = [];
|
||||
const message = {
|
||||
id: messageId, subscription: { id: rid }, msg, tmid
|
||||
|
||||
let message = {
|
||||
_id: messageId, rid, msg, tmid
|
||||
};
|
||||
message = await Encryption.encryptMessage(message);
|
||||
|
||||
const messageDate = new Date();
|
||||
let tMessageRecord;
|
||||
|
||||
|
@ -106,6 +131,10 @@ export default async function(rid, msg, tmid, user) {
|
|||
tm._updatedAt = messageDate;
|
||||
tm.status = messagesStatus.SENT; // Original message was sent already
|
||||
tm.u = tMessageRecord.u;
|
||||
tm.t = message.t;
|
||||
if (message.t === E2E_MESSAGE_TYPE) {
|
||||
tm.e2e = E2E_STATUS.DONE;
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
@ -124,6 +153,10 @@ export default async function(rid, msg, tmid, user) {
|
|||
_id: user.id || '1',
|
||||
username: user.username
|
||||
};
|
||||
tm.t = message.t;
|
||||
if (message.t === E2E_MESSAGE_TYPE) {
|
||||
tm.e2e = E2E_STATUS.DONE;
|
||||
}
|
||||
})
|
||||
);
|
||||
} catch (e) {
|
||||
|
@ -149,6 +182,10 @@ export default async function(rid, msg, tmid, user) {
|
|||
m.tlm = messageDate;
|
||||
m.tmsg = tMessageRecord.msg;
|
||||
}
|
||||
m.t = message.t;
|
||||
if (message.t === E2E_MESSAGE_TYPE) {
|
||||
m.e2e = E2E_STATUS.DONE;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
|
|
|
@ -11,6 +11,7 @@ import { addUserTyping, removeUserTyping, clearUserTyping } from '../../../actio
|
|||
import debounce from '../../../utils/debounce';
|
||||
import RocketChat from '../../rocketchat';
|
||||
import { subscribeRoom, unsubscribeRoom } from '../../../actions/room';
|
||||
import { Encryption } from '../../encryption';
|
||||
|
||||
const WINDOW_TIME = 1000;
|
||||
|
||||
|
@ -162,6 +163,9 @@ export default class RoomSubscription {
|
|||
const threadsCollection = db.collections.get('threads');
|
||||
const threadMessagesCollection = db.collections.get('thread_messages');
|
||||
|
||||
// Decrypt the message if necessary
|
||||
message = await Encryption.decryptMessage(message);
|
||||
|
||||
// Create or update message
|
||||
try {
|
||||
const messageRecord = await msgCollection.find(message._id);
|
||||
|
|
|
@ -16,6 +16,8 @@ import EventEmitter from '../../../utils/events';
|
|||
import { removedRoom } from '../../../actions/room';
|
||||
import { setUser } from '../../../actions/login';
|
||||
import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification';
|
||||
import { Encryption } from '../../encryption';
|
||||
import { E2E_MESSAGE_TYPE } from '../../encryption/constants';
|
||||
|
||||
const removeListener = listener => listener.stop();
|
||||
|
||||
|
@ -79,7 +81,10 @@ const createOrUpdateSubscription = async(subscription, room) => {
|
|||
departmentId: s.departmentId,
|
||||
servedBy: s.servedBy,
|
||||
livechatData: s.livechatData,
|
||||
tags: s.tags
|
||||
tags: s.tags,
|
||||
encrypted: s.encrypted,
|
||||
e2eKeyId: s.e2eKeyId,
|
||||
E2EKey: s.E2EKey
|
||||
};
|
||||
} catch (error) {
|
||||
try {
|
||||
|
@ -107,6 +112,7 @@ const createOrUpdateSubscription = async(subscription, room) => {
|
|||
tags: r.tags,
|
||||
servedBy: r.servedBy,
|
||||
encrypted: r.encrypted,
|
||||
e2eKeyId: r.e2eKeyId,
|
||||
broadcast: r.broadcast,
|
||||
customFields: r.customFields,
|
||||
departmentId: r.departmentId,
|
||||
|
@ -117,8 +123,8 @@ const createOrUpdateSubscription = async(subscription, room) => {
|
|||
}
|
||||
}
|
||||
|
||||
const tmp = merge(subscription, room);
|
||||
await db.action(async() => {
|
||||
let tmp = merge(subscription, room);
|
||||
tmp = await Encryption.decryptSubscription(tmp);
|
||||
let sub;
|
||||
try {
|
||||
sub = await subCollection.find(tmp.rid);
|
||||
|
@ -126,6 +132,23 @@ const createOrUpdateSubscription = async(subscription, room) => {
|
|||
// 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 {
|
||||
|
@ -184,6 +207,7 @@ const createOrUpdateSubscription = async(subscription, room) => {
|
|||
}
|
||||
}
|
||||
|
||||
await db.action(async() => {
|
||||
await db.batch(...batch);
|
||||
});
|
||||
} catch (e) {
|
||||
|
@ -320,12 +344,25 @@ export default function subscribeRooms() {
|
|||
if (/notification/.test(ev)) {
|
||||
const [notification] = ddpMessage.fields.args;
|
||||
try {
|
||||
const { payload: { rid } } = notification;
|
||||
const { payload: { rid, message, sender } } = notification;
|
||||
const room = await RocketChat.getRoom(rid);
|
||||
notification.title = RocketChat.getRoomTitle(room);
|
||||
notification.avatar = RocketChat.getRoomAvatar(room);
|
||||
|
||||
// If it's from a encrypted room
|
||||
if (message.t === E2E_MESSAGE_TYPE) {
|
||||
// Decrypt this message content
|
||||
const { msg } = await Encryption.decryptMessage({ ...message, rid });
|
||||
// If it's a direct the content is the message decrypted
|
||||
if (room.t === 'd') {
|
||||
notification.text = msg;
|
||||
// If it's a private group we should add the sender name
|
||||
} else {
|
||||
notification.text = `${ RocketChat.getSenderName(sender) }: ${ msg }`;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// do nothing
|
||||
log(e);
|
||||
}
|
||||
EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification);
|
||||
}
|
||||
|
@ -333,6 +370,14 @@ export default function subscribeRooms() {
|
|||
const { type: eventType, ...args } = type;
|
||||
handlePayloadUserInteraction(eventType, args);
|
||||
}
|
||||
if (/e2ekeyRequest/.test(ev)) {
|
||||
const [roomId, keyId] = ddpMessage.fields.args;
|
||||
try {
|
||||
await Encryption.provideRoomKeyToUser(keyId, roomId);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const stop = () => {
|
||||
|
|
|
@ -5,6 +5,7 @@ import buildMessage from './helpers/buildMessage';
|
|||
import log from '../../utils/log';
|
||||
import database from '../database';
|
||||
import protectedFunction from './helpers/protectedFunction';
|
||||
import { Encryption } from '../encryption';
|
||||
|
||||
export default function updateMessages({ rid, update = [], remove = [] }) {
|
||||
try {
|
||||
|
@ -13,6 +14,8 @@ export default function updateMessages({ rid, update = [], remove = [] }) {
|
|||
}
|
||||
const db = database.active;
|
||||
return db.action(async() => {
|
||||
// Decrypt these messages
|
||||
update = await Encryption.decryptMessages(update);
|
||||
const subCollection = db.collections.get('subscriptions');
|
||||
let sub;
|
||||
try {
|
||||
|
|
|
@ -6,12 +6,12 @@ import AsyncStorage from '@react-native-community/async-storage';
|
|||
|
||||
import reduxStore from './createStore';
|
||||
import defaultSettings from '../constants/settings';
|
||||
import messagesStatus from '../constants/messagesStatus';
|
||||
import database from './database';
|
||||
import log from '../utils/log';
|
||||
import { isIOS, getBundleId } from '../utils/deviceInfo';
|
||||
import fetch from '../utils/fetch';
|
||||
|
||||
import { encryptionInit } from '../actions/encryption';
|
||||
import { setUser, setLoginServices, loginRequest } from '../actions/login';
|
||||
import { disconnect, connectSuccess, connectRequest } from '../actions/connect';
|
||||
import {
|
||||
|
@ -40,7 +40,7 @@ import loadMessagesForRoom from './methods/loadMessagesForRoom';
|
|||
import loadMissedMessages from './methods/loadMissedMessages';
|
||||
import loadThreadMessages from './methods/loadThreadMessages';
|
||||
|
||||
import sendMessage, { sendMessageCall } from './methods/sendMessage';
|
||||
import sendMessage, { resendMessage } from './methods/sendMessage';
|
||||
import { sendFileMessage, cancelUpload, isUploadActive } from './methods/sendFileMessage';
|
||||
|
||||
import callJitsi from './methods/callJitsi';
|
||||
|
@ -53,6 +53,7 @@ import { twoFactor } from '../utils/twoFactor';
|
|||
import { selectServerFailure } from '../actions/server';
|
||||
import { useSsl } from '../utils/url';
|
||||
import UserPreferences from './userPreferences';
|
||||
import { Encryption } from './encryption';
|
||||
import EventEmitter from '../utils/events';
|
||||
|
||||
const TOKEN_KEY = 'reactnativemeteor_usertoken';
|
||||
|
@ -80,10 +81,10 @@ const RocketChat = {
|
|||
},
|
||||
canOpenRoom,
|
||||
createChannel({
|
||||
name, users, type, readOnly, broadcast
|
||||
name, users, type, readOnly, broadcast, encrypted
|
||||
}) {
|
||||
// RC 0.51.0
|
||||
return this.methodCallWrapper(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast });
|
||||
return this.methodCallWrapper(type ? 'createPrivateGroup' : 'createChannel', name, users, readOnly, {}, { broadcast, encrypted });
|
||||
},
|
||||
async getWebsocketInfo({ server }) {
|
||||
const sdk = new RocketchatClient({ host: server, protocol: 'ddp', useSsl: useSsl(server) });
|
||||
|
@ -307,6 +308,7 @@ const RocketChat = {
|
|||
}
|
||||
reduxStore.dispatch(shareSetUser(user));
|
||||
await RocketChat.login({ resume: user.token });
|
||||
reduxStore.dispatch(encryptionInit());
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
@ -321,6 +323,45 @@ const RocketChat = {
|
|||
reduxStore.dispatch(shareSetUser({}));
|
||||
},
|
||||
|
||||
async e2eFetchMyKeys() {
|
||||
// RC 0.70.0
|
||||
const sdk = this.shareSDK || this.sdk;
|
||||
const result = await sdk.get('e2e.fetchMyKeys');
|
||||
// snake_case -> camelCase
|
||||
if (result.success) {
|
||||
return {
|
||||
success: result.success,
|
||||
publicKey: result.public_key,
|
||||
privateKey: result.private_key
|
||||
};
|
||||
}
|
||||
return result;
|
||||
},
|
||||
e2eSetUserPublicAndPrivateKeys(public_key, private_key) {
|
||||
// RC 2.2.0
|
||||
return this.post('e2e.setUserPublicAndPrivateKeys', { public_key, private_key });
|
||||
},
|
||||
e2eRequestSubscriptionKeys() {
|
||||
// RC 0.72.0
|
||||
return this.methodCallWrapper('e2e.requestSubscriptionKeys');
|
||||
},
|
||||
e2eGetUsersOfRoomWithoutKey(rid) {
|
||||
// RC 0.70.0
|
||||
return this.sdk.get('e2e.getUsersOfRoomWithoutKey', { rid });
|
||||
},
|
||||
e2eSetRoomKeyID(rid, keyID) {
|
||||
// RC 0.70.0
|
||||
return this.post('e2e.setRoomKeyID', { rid, keyID });
|
||||
},
|
||||
e2eUpdateGroupKey(uid, rid, key) {
|
||||
// RC 0.70.0
|
||||
return this.post('e2e.updateGroupKey', { uid, rid, key });
|
||||
},
|
||||
e2eRequestRoomKey(rid, e2eKeyId) {
|
||||
// RC 0.70.0
|
||||
return this.methodCallWrapper('stream-notify-room-users', `${ rid }/e2ekeyRequest`, rid, e2eKeyId);
|
||||
},
|
||||
|
||||
updateJitsiTimeout(roomId) {
|
||||
// RC 0.74.0
|
||||
return this.post('video-conference/jitsi.update-timeout', { roomId });
|
||||
|
@ -468,30 +509,7 @@ const RocketChat = {
|
|||
sendMessage,
|
||||
getRooms,
|
||||
readMessages,
|
||||
async resendMessage(message, tmid) {
|
||||
const db = database.active;
|
||||
try {
|
||||
await db.action(async() => {
|
||||
await message.update((m) => {
|
||||
m.status = messagesStatus.TEMP;
|
||||
});
|
||||
});
|
||||
let m = {
|
||||
id: message.id,
|
||||
msg: message.msg,
|
||||
subscription: { id: message.subscription.id }
|
||||
};
|
||||
if (tmid) {
|
||||
m = {
|
||||
...m,
|
||||
tmid
|
||||
};
|
||||
}
|
||||
await sendMessageCall.call(this, m);
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
},
|
||||
resendMessage,
|
||||
|
||||
async search({ text, filterUsers = true, filterRooms = true }) {
|
||||
const searchText = text.trim();
|
||||
|
@ -641,10 +659,10 @@ const RocketChat = {
|
|||
// RC 0.48.0
|
||||
return this.post('chat.delete', { msgId: messageId, roomId: rid });
|
||||
},
|
||||
editMessage(message) {
|
||||
const { id, msg, rid } = message;
|
||||
async editMessage(message) {
|
||||
const { rid, msg } = await Encryption.encryptMessage(message);
|
||||
// RC 0.49.0
|
||||
return this.post('chat.update', { roomId: rid, msgId: id, text: msg });
|
||||
return this.post('chat.update', { roomId: rid, msgId: message.id, text: msg });
|
||||
},
|
||||
markAsUnread({ messageId }) {
|
||||
return this.post('subscriptions.unread', { firstUnreadMessage: { _id: messageId } });
|
||||
|
@ -1270,6 +1288,10 @@ const RocketChat = {
|
|||
translateMessage(message, targetLanguage) {
|
||||
return this.methodCallWrapper('autoTranslate.translateMessage', message, targetLanguage);
|
||||
},
|
||||
getSenderName(sender) {
|
||||
const { UI_Use_Real_Name: useRealName } = reduxStore.getState().settings;
|
||||
return useRealName ? sender.name : sender.username;
|
||||
},
|
||||
getRoomTitle(room) {
|
||||
const { UI_Use_Real_Name: useRealName, UI_Allow_room_names_with_special_chars: allowSpecialChars } = reduxStore.getState().settings;
|
||||
const { username } = reduxStore.getState().login.user;
|
||||
|
|
|
@ -6,6 +6,7 @@ import I18n from '../../i18n';
|
|||
import styles from './styles';
|
||||
import Markdown from '../../containers/markdown';
|
||||
import { themes } from '../../constants/colors';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
|
||||
|
||||
const formatMsg = ({
|
||||
lastMessage, type, showLastMessage, username, useRealName
|
||||
|
@ -29,6 +30,11 @@ const formatMsg = ({
|
|||
return I18n.t('User_sent_an_attachment', { user });
|
||||
}
|
||||
|
||||
// Encrypted message pending decrypt
|
||||
if (lastMessage.t === E2E_MESSAGE_TYPE && lastMessage.e2e !== E2E_STATUS.DONE) {
|
||||
lastMessage.msg = I18n.t('Encrypted_message');
|
||||
}
|
||||
|
||||
if (isLastMessageSentByMe) {
|
||||
prefix = I18n.t('You_colon');
|
||||
} else if (type !== 'd') {
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -17,6 +17,7 @@ import usersTyping from './usersTyping';
|
|||
import inviteLinks from './inviteLinks';
|
||||
import createDiscussion from './createDiscussion';
|
||||
import enterpriseModules from './enterpriseModules';
|
||||
import encryption from './encryption';
|
||||
|
||||
import inquiry from '../ee/omnichannel/reducers/inquiry';
|
||||
|
||||
|
@ -39,5 +40,6 @@ export default combineReducers({
|
|||
inviteLinks,
|
||||
createDiscussion,
|
||||
inquiry,
|
||||
enterpriseModules
|
||||
enterpriseModules,
|
||||
encryption
|
||||
});
|
||||
|
|
|
@ -36,8 +36,18 @@ const handleRequest = function* handleRequest({ data }) {
|
|||
({ room: sub } = result);
|
||||
}
|
||||
} else {
|
||||
const { type, readOnly, broadcast } = data;
|
||||
logEvent(events.CREATE_CHANNEL_CREATE, { type: type ? 'private' : 'public', readOnly, broadcast });
|
||||
const {
|
||||
type,
|
||||
readOnly,
|
||||
broadcast,
|
||||
encrypted
|
||||
} = data;
|
||||
logEvent(events.CREATE_CHANNEL_CREATE, {
|
||||
type: type ? 'private' : 'public',
|
||||
readOnly,
|
||||
broadcast,
|
||||
encrypted
|
||||
});
|
||||
sub = yield call(createChannel, data);
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
|
@ -10,6 +10,7 @@ import state from './state';
|
|||
import deepLinking from './deepLinking';
|
||||
import inviteLinks from './inviteLinks';
|
||||
import createDiscussion from './createDiscussion';
|
||||
import encryption from './encryption';
|
||||
|
||||
import inquiry from '../ee/omnichannel/sagas/inquiry';
|
||||
|
||||
|
@ -26,7 +27,8 @@ const root = function* root() {
|
|||
deepLinking(),
|
||||
inviteLinks(),
|
||||
createDiscussion(),
|
||||
inquiry()
|
||||
inquiry(),
|
||||
encryption()
|
||||
]);
|
||||
};
|
||||
|
||||
|
|
|
@ -24,10 +24,12 @@ import { inviteLinksRequest } from '../actions/inviteLinks';
|
|||
import { showErrorAlert } from '../utils/info';
|
||||
import { localAuthenticate } from '../utils/localAuthentication';
|
||||
import { setActiveUsers } from '../actions/activeUsers';
|
||||
import { encryptionInit, encryptionStop } from '../actions/encryption';
|
||||
import UserPreferences from '../lib/userPreferences';
|
||||
|
||||
import { inquiryRequest, inquiryReset } from '../ee/omnichannel/actions/inquiry';
|
||||
import { isOmnichannelStatusAvailable } from '../ee/omnichannel/lib';
|
||||
import { E2E_REFRESH_MESSAGES_KEY } from '../lib/encryption/constants';
|
||||
|
||||
const getServer = state => state.server.server;
|
||||
const loginWithPasswordCall = args => RocketChat.loginWithPassword(args);
|
||||
|
@ -95,6 +97,35 @@ const fetchEnterpriseModules = function* fetchEnterpriseModules({ user }) {
|
|||
}
|
||||
};
|
||||
|
||||
const fetchRooms = function* fetchRooms({ server }) {
|
||||
try {
|
||||
// Read the flag to check if refresh was already done
|
||||
const refreshed = yield UserPreferences.getBoolAsync(E2E_REFRESH_MESSAGES_KEY);
|
||||
if (!refreshed) {
|
||||
const serversDB = database.servers;
|
||||
const serversCollection = serversDB.collections.get('servers');
|
||||
|
||||
const serverRecord = yield serversCollection.find(server);
|
||||
|
||||
// We need to reset roomsUpdatedAt to request all rooms again
|
||||
// and save their respective E2EKeys to decrypt all pending messages and lastMessage
|
||||
// that are already inserted on local database by other app version
|
||||
yield serversDB.action(async() => {
|
||||
await serverRecord.update((s) => {
|
||||
s.roomsUpdatedAt = null;
|
||||
});
|
||||
});
|
||||
|
||||
// Set the flag to indicate that already refreshed
|
||||
yield UserPreferences.setBoolAsync(E2E_REFRESH_MESSAGES_KEY, true);
|
||||
}
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
|
||||
yield put(roomsRequest());
|
||||
};
|
||||
|
||||
const handleLoginSuccess = function* handleLoginSuccess({ user }) {
|
||||
try {
|
||||
const adding = yield select(state => state.server.adding);
|
||||
|
@ -103,7 +134,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
|
|||
RocketChat.getUserPresence(user.id);
|
||||
|
||||
const server = yield select(getServer);
|
||||
yield put(roomsRequest());
|
||||
yield fork(fetchRooms, { server });
|
||||
yield fork(fetchPermissions);
|
||||
yield fork(fetchCustomEmojis);
|
||||
yield fork(fetchRoles);
|
||||
|
@ -111,6 +142,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
|
|||
yield fork(registerPushToken);
|
||||
yield fork(fetchUsersPresence);
|
||||
yield fork(fetchEnterpriseModules, { user });
|
||||
yield put(encryptionInit());
|
||||
|
||||
I18n.locale = user.language;
|
||||
moment.locale(toMomentLocale(user.language));
|
||||
|
@ -173,6 +205,7 @@ const handleLoginSuccess = function* handleLoginSuccess({ user }) {
|
|||
};
|
||||
|
||||
const handleLogout = function* handleLogout({ forcedByServer }) {
|
||||
yield put(encryptionStop());
|
||||
yield put(appStart({ root: ROOT_LOADING, text: I18n.t('Logging_out') }));
|
||||
const server = yield select(getServer);
|
||||
if (server) {
|
||||
|
|
|
@ -18,6 +18,7 @@ import I18n from '../i18n';
|
|||
import { BASIC_AUTH_KEY, setBasicAuth } from '../utils/fetch';
|
||||
import { appStart, ROOT_INSIDE, ROOT_OUTSIDE } from '../actions/app';
|
||||
import UserPreferences from '../lib/userPreferences';
|
||||
import { encryptionStop } from '../actions/encryption';
|
||||
|
||||
import { inquiryReset } from '../ee/omnichannel/actions/inquiry';
|
||||
|
||||
|
@ -67,6 +68,7 @@ const getServerInfo = function* getServerInfo({ server, raiseError = true }) {
|
|||
const handleSelectServer = function* handleSelectServer({ server, version, fetchVersion }) {
|
||||
try {
|
||||
yield put(inquiryReset());
|
||||
yield put(encryptionStop());
|
||||
const serversDB = database.servers;
|
||||
yield UserPreferences.setStringAsync(RocketChat.CURRENT_SERVER, server);
|
||||
const userId = yield UserPreferences.getStringAsync(`${ RocketChat.TOKEN_KEY }-${ server }`);
|
||||
|
|
|
@ -50,6 +50,13 @@ import AdminPanelView from '../views/AdminPanelView';
|
|||
import NewMessageView from '../views/NewMessageView';
|
||||
import CreateChannelView from '../views/CreateChannelView';
|
||||
|
||||
// E2ESaveYourPassword Stack
|
||||
import E2ESaveYourPasswordView from '../views/E2ESaveYourPasswordView';
|
||||
import E2EHowItWorksView from '../views/E2EHowItWorksView';
|
||||
|
||||
// E2EEnterYourPassword Stack
|
||||
import E2EEnterYourPasswordView from '../views/E2EEnterYourPasswordView';
|
||||
|
||||
// InsideStackNavigator
|
||||
import AttachmentView from '../views/AttachmentView';
|
||||
import ModalBlockView from '../views/ModalBlockView';
|
||||
|
@ -302,6 +309,43 @@ const NewMessageStackNavigator = () => {
|
|||
);
|
||||
};
|
||||
|
||||
// E2ESaveYourPasswordStackNavigator
|
||||
const E2ESaveYourPasswordStack = createStackNavigator();
|
||||
const E2ESaveYourPasswordStackNavigator = () => {
|
||||
const { theme } = React.useContext(ThemeContext);
|
||||
|
||||
return (
|
||||
<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
|
||||
const InsideStack = createStackNavigator();
|
||||
const InsideStackNavigator = () => {
|
||||
|
@ -319,6 +363,16 @@ const InsideStackNavigator = () => {
|
|||
component={NewMessageStackNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<InsideStack.Screen
|
||||
name='E2ESaveYourPasswordStackNavigator'
|
||||
component={E2ESaveYourPasswordStackNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<InsideStack.Screen
|
||||
name='E2EEnterYourPasswordStackNavigator'
|
||||
component={E2EEnterYourPasswordStackNavigator}
|
||||
options={{ headerShown: false }}
|
||||
/>
|
||||
<InsideStack.Screen
|
||||
name='AttachmentView'
|
||||
component={AttachmentView}
|
||||
|
|
|
@ -50,6 +50,9 @@ import ModalBlockView from '../../views/ModalBlockView';
|
|||
import JitsiMeetView from '../../views/JitsiMeetView';
|
||||
import StatusView from '../../views/StatusView';
|
||||
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 ShareView from '../../views/ShareView';
|
||||
|
@ -256,6 +259,21 @@ const ModalStackNavigator = React.memo(({ navigation }) => {
|
|||
name='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
|
||||
name='UserPreferencesView'
|
||||
component={UserPreferencesView}
|
||||
|
|
|
@ -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);
|
||||
});
|
|
@ -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('');
|
||||
};
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -58,6 +58,7 @@ export default {
|
|||
RL_ADD_SERVER: 'rl_add_server',
|
||||
RL_CHANGE_SERVER: 'rl_change_server',
|
||||
RL_GO_NEW_MSG: 'rl_go_new_msg',
|
||||
RL_GO_E2E_SAVE_PASSWORD: 'rl_go_e2e_save_password',
|
||||
RL_SEARCH: 'rl_search',
|
||||
RL_GO_DIRECTORY: 'rl_go_directory',
|
||||
RL_GO_QUEUE: 'rl_go_queue',
|
||||
|
@ -103,6 +104,7 @@ export default {
|
|||
CREATE_CHANNEL_TOGGLE_TYPE: 'create_channel_toggle_type',
|
||||
CREATE_CHANNEL_TOGGLE_READ_ONLY: 'create_channel_toggle_read_only',
|
||||
CREATE_CHANNEL_TOGGLE_BROADCAST: 'create_channel_toggle_broadcast',
|
||||
CREATE_CHANNEL_TOGGLE_ENCRYPTED: 'create_channel_toggle_encrypted',
|
||||
CREATE_CHANNEL_REMOVE_USER: 'create_channel_remove_user',
|
||||
|
||||
// CREATE DISCUSSION VIEW
|
||||
|
@ -161,6 +163,7 @@ export default {
|
|||
|
||||
// ROOM VIEW
|
||||
ROOM_SEND_MESSAGE: 'room_send_message',
|
||||
ROOM_ENCRYPTED_PRESS: 'room_encrypted_press',
|
||||
ROOM_OPEN_EMOJI: 'room_open_emoji',
|
||||
ROOM_AUDIO_RECORD: 'room_audio_record',
|
||||
ROOM_AUDIO_RECORD_F: 'room_audio_record_f',
|
||||
|
@ -233,6 +236,8 @@ export default {
|
|||
RA_LEAVE_F: 'ra_leave_f',
|
||||
RA_TOGGLE_BLOCK_USER: 'ra_toggle_block_user',
|
||||
RA_TOGGLE_BLOCK_USER_F: 'ra_toggle_block_user_f',
|
||||
RA_TOGGLE_ENCRYPTED: 'ra_toggle_encrypted',
|
||||
RA_TOGGLE_ENCRYPTED_F: 'ra_toggle_encrypted_f',
|
||||
|
||||
// ROOM INFO VIEW
|
||||
RI_GO_RI_EDIT: 'ri_go_ri_edit',
|
||||
|
@ -244,6 +249,7 @@ export default {
|
|||
RI_EDIT_TOGGLE_READ_ONLY: 'ri_edit_toggle_read_only',
|
||||
RI_EDIT_TOGGLE_REACTIONS: 'ri_edit_toggle_reactions',
|
||||
RI_EDIT_TOGGLE_SYSTEM_MSG: 'ri_edit_toggle_system_msg',
|
||||
RI_EDIT_TOGGLE_ENCRYPTED: 'ri_edit_toggle_encrypted',
|
||||
RI_EDIT_SAVE: 'ri_edit_save',
|
||||
RI_EDIT_SAVE_F: 'ri_edit_save_f',
|
||||
RI_EDIT_RESET: 'ri_edit_reset',
|
||||
|
@ -288,5 +294,13 @@ export default {
|
|||
NP_DESKTOPNOTIFICATIONDURATION: 'np_desktopnotificationduration',
|
||||
NP_DESKTOPNOTIFICATIONDURATION_F: 'np_desktopnotificationduration_f',
|
||||
NP_EMAILNOTIFICATIONS: 'np_email_notifications',
|
||||
NP_EMAILNOTIFICATIONS_F: 'np_email_notifications_f'
|
||||
NP_EMAILNOTIFICATIONS_F: 'np_email_notifications_f',
|
||||
|
||||
// E2E SAVE YOUR PASSWORD VIEW
|
||||
E2E_SAVE_PW_SAVED: 'e2e_save_pw_saved',
|
||||
E2E_SAVE_PW_COPY: 'e2e_save_pw_copy',
|
||||
E2E_SAVE_PW_HOW_IT_WORKS: 'e2e_save_pw_how_it_works',
|
||||
|
||||
// E2E ENTER YOUR PASSWORD VIEW
|
||||
E2E_ENTER_PW_SUBMIT: 'e2e_enter_pw_submit'
|
||||
};
|
||||
|
|
|
@ -85,6 +85,7 @@ class CreateChannelView extends React.Component {
|
|||
error: PropTypes.object,
|
||||
failure: PropTypes.bool,
|
||||
isFetching: PropTypes.bool,
|
||||
e2eEnabled: PropTypes.bool,
|
||||
users: PropTypes.array.isRequired,
|
||||
user: PropTypes.shape({
|
||||
id: PropTypes.string,
|
||||
|
@ -97,14 +98,17 @@ class CreateChannelView extends React.Component {
|
|||
channelName: '',
|
||||
type: true,
|
||||
readOnly: false,
|
||||
encrypted: false,
|
||||
broadcast: false
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
const {
|
||||
channelName, type, readOnly, broadcast
|
||||
channelName, type, readOnly, broadcast, encrypted
|
||||
} = this.state;
|
||||
const { users, isFetching, theme } = this.props;
|
||||
const {
|
||||
users, isFetching, e2eEnabled, theme
|
||||
} = this.props;
|
||||
if (nextProps.theme !== theme) {
|
||||
return true;
|
||||
}
|
||||
|
@ -117,12 +121,18 @@ class CreateChannelView extends React.Component {
|
|||
if (nextState.readOnly !== readOnly) {
|
||||
return true;
|
||||
}
|
||||
if (nextState.encrypted !== encrypted) {
|
||||
return true;
|
||||
}
|
||||
if (nextState.broadcast !== broadcast) {
|
||||
return true;
|
||||
}
|
||||
if (nextProps.isFetching !== isFetching) {
|
||||
return true;
|
||||
}
|
||||
if (nextProps.e2eEnabled !== e2eEnabled) {
|
||||
return true;
|
||||
}
|
||||
if (!equal(nextProps.users, users)) {
|
||||
return true;
|
||||
}
|
||||
|
@ -147,7 +157,7 @@ class CreateChannelView extends React.Component {
|
|||
|
||||
submit = () => {
|
||||
const {
|
||||
channelName, type, readOnly, broadcast
|
||||
channelName, type, readOnly, broadcast, encrypted
|
||||
} = this.state;
|
||||
const { users: usersProps, isFetching, create } = this.props;
|
||||
|
||||
|
@ -160,7 +170,7 @@ class CreateChannelView extends React.Component {
|
|||
|
||||
// create channel
|
||||
create({
|
||||
name: channelName, users, type, readOnly, broadcast
|
||||
name: channelName, users, type, readOnly, broadcast, encrypted
|
||||
});
|
||||
|
||||
Review.pushPositiveEvent();
|
||||
|
@ -198,7 +208,8 @@ class CreateChannelView extends React.Component {
|
|||
label: 'Private_Channel',
|
||||
onValueChange: (value) => {
|
||||
logEvent(events.CREATE_CHANNEL_TOGGLE_TYPE);
|
||||
this.setState({ type: value });
|
||||
// If we set the channel as public, encrypted status should be false
|
||||
this.setState(({ encrypted }) => ({ type: value, encrypted: value && encrypted }));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -217,6 +228,26 @@ class CreateChannelView extends React.Component {
|
|||
});
|
||||
}
|
||||
|
||||
renderEncrypted() {
|
||||
const { type, encrypted } = this.state;
|
||||
const { e2eEnabled } = this.props;
|
||||
|
||||
if (!e2eEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.renderSwitch({
|
||||
id: 'encrypted',
|
||||
value: encrypted,
|
||||
label: 'Encrypted',
|
||||
onValueChange: (value) => {
|
||||
logEvent(events.CREATE_CHANNEL_TOGGLE_ENCRYPTED);
|
||||
this.setState({ encrypted: value });
|
||||
},
|
||||
disabled: !type
|
||||
});
|
||||
}
|
||||
|
||||
renderBroadcast() {
|
||||
const { broadcast, readOnly } = this.state;
|
||||
return this.renderSwitch({
|
||||
|
@ -315,6 +346,8 @@ class CreateChannelView extends React.Component {
|
|||
{this.renderFormSeparator()}
|
||||
{this.renderReadOnly()}
|
||||
{this.renderFormSeparator()}
|
||||
{this.renderEncrypted()}
|
||||
{this.renderFormSeparator()}
|
||||
{this.renderBroadcast()}
|
||||
</View>
|
||||
<View style={styles.invitedHeader}>
|
||||
|
@ -333,6 +366,7 @@ class CreateChannelView extends React.Component {
|
|||
const mapStateToProps = state => ({
|
||||
baseUrl: state.server.server,
|
||||
isFetching: state.createChannel.isFetching,
|
||||
e2eEnabled: state.settings.E2E_Enable,
|
||||
users: state.selectedUsers.users,
|
||||
user: getUserSelector(state)
|
||||
});
|
||||
|
|
|
@ -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));
|
|
@ -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);
|
|
@ -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));
|
|
@ -1,7 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import {
|
||||
View, SectionList, Text, Alert, Share
|
||||
View, SectionList, Text, Alert, Share, Switch
|
||||
} from 'react-native';
|
||||
import { connect } from 'react-redux';
|
||||
import _ from 'lodash';
|
||||
|
@ -21,13 +21,16 @@ import scrollPersistTaps from '../../utils/scrollPersistTaps';
|
|||
import { CustomIcon } from '../../lib/Icons';
|
||||
import DisclosureIndicator from '../../containers/DisclosureIndicator';
|
||||
import StatusBar from '../../containers/StatusBar';
|
||||
import { themes } from '../../constants/colors';
|
||||
import { themes, SWITCH_TRACK_COLOR } from '../../constants/colors';
|
||||
import { withTheme } from '../../theme';
|
||||
import { CloseModalButton } from '../../containers/HeaderButton';
|
||||
import { getUserSelector } from '../../selectors/login';
|
||||
import Markdown from '../../containers/markdown';
|
||||
import { showConfirmationAlert, showErrorAlert } from '../../utils/info';
|
||||
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 {
|
||||
static navigationOptions = ({ navigation, isMasterDetail }) => {
|
||||
|
@ -50,6 +53,7 @@ class RoomActionsView extends React.Component {
|
|||
}),
|
||||
leaveRoom: PropTypes.func,
|
||||
jitsiEnabled: PropTypes.bool,
|
||||
e2eEnabled: PropTypes.bool,
|
||||
setLoadingInvite: PropTypes.func,
|
||||
closeRoom: PropTypes.func,
|
||||
theme: PropTypes.string
|
||||
|
@ -72,7 +76,8 @@ class RoomActionsView extends React.Component {
|
|||
canAddUser: false,
|
||||
canInviteUser: false,
|
||||
canForwardGuest: false,
|
||||
canReturnQueue: false
|
||||
canReturnQueue: false,
|
||||
canEdit: false
|
||||
};
|
||||
if (room && room.observe && room.rid) {
|
||||
this.roomObservable = room.observe();
|
||||
|
@ -120,6 +125,7 @@ class RoomActionsView extends React.Component {
|
|||
|
||||
this.canAddUser();
|
||||
this.canInviteUser();
|
||||
this.canEdit();
|
||||
|
||||
// livechat permissions
|
||||
if (room.t === 'l') {
|
||||
|
@ -184,6 +190,15 @@ class RoomActionsView extends React.Component {
|
|||
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() => {
|
||||
const { room } = this.state;
|
||||
const { rid, t, broadcast } = room;
|
||||
|
@ -227,11 +242,11 @@ class RoomActionsView extends React.Component {
|
|||
|
||||
get sections() {
|
||||
const {
|
||||
room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue
|
||||
room, member, membersCount, canViewMembers, canAddUser, canInviteUser, joined, canAutoTranslate, canForwardGuest, canReturnQueue, canEdit
|
||||
} = this.state;
|
||||
const { jitsiEnabled } = this.props;
|
||||
const { jitsiEnabled, e2eEnabled } = this.props;
|
||||
const {
|
||||
rid, t, blocker
|
||||
rid, t, blocker, encrypted
|
||||
} = room;
|
||||
const isGroupChat = RocketChat.isGroupChat(room);
|
||||
|
||||
|
@ -240,7 +255,8 @@ class RoomActionsView extends React.Component {
|
|||
name: I18n.t('Notifications'),
|
||||
route: 'NotificationPrefView',
|
||||
params: { rid, room },
|
||||
testID: 'room-actions-notifications'
|
||||
testID: 'room-actions-notifications',
|
||||
right: this.renderDisclosure
|
||||
};
|
||||
|
||||
const jitsiActions = jitsiEnabled ? [
|
||||
|
@ -248,13 +264,15 @@ class RoomActionsView extends React.Component {
|
|||
icon: 'phone',
|
||||
name: I18n.t('Voice_call'),
|
||||
event: () => RocketChat.callJitsi(rid, true),
|
||||
testID: 'room-actions-voice'
|
||||
testID: 'room-actions-voice',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'camera',
|
||||
name: I18n.t('Video_call'),
|
||||
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'),
|
||||
route: 'MessagesView',
|
||||
params: { rid, t, name: 'Files' },
|
||||
testID: 'room-actions-files'
|
||||
testID: 'room-actions-files',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'mention',
|
||||
name: I18n.t('Mentions'),
|
||||
route: 'MessagesView',
|
||||
params: { rid, t, name: 'Mentions' },
|
||||
testID: 'room-actions-mentioned'
|
||||
testID: 'room-actions-mentioned',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'star',
|
||||
name: I18n.t('Starred'),
|
||||
route: 'MessagesView',
|
||||
params: { rid, t, name: 'Starred' },
|
||||
testID: 'room-actions-starred'
|
||||
testID: 'room-actions-starred',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'search',
|
||||
name: I18n.t('Search'),
|
||||
route: 'SearchMessagesView',
|
||||
params: { rid },
|
||||
testID: 'room-actions-search'
|
||||
params: { rid, encrypted },
|
||||
testID: 'room-actions-search',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'share',
|
||||
name: I18n.t('Share'),
|
||||
event: this.handleShare,
|
||||
testID: 'room-actions-share'
|
||||
testID: 'room-actions-share',
|
||||
right: this.renderDisclosure
|
||||
},
|
||||
{
|
||||
icon: 'pin',
|
||||
name: I18n.t('Pinned'),
|
||||
route: 'MessagesView',
|
||||
params: { rid, t, name: 'Pinned' },
|
||||
testID: 'room-actions-pinned'
|
||||
testID: 'room-actions-pinned',
|
||||
right: this.renderDisclosure
|
||||
}
|
||||
],
|
||||
renderItem: this.renderItem
|
||||
|
@ -327,7 +351,8 @@ class RoomActionsView extends React.Component {
|
|||
name: I18n.t('Auto_Translate'),
|
||||
route: 'AutoTranslateView',
|
||||
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,
|
||||
route: 'RoomMembersView',
|
||||
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`),
|
||||
type: 'danger',
|
||||
event: this.toggleBlockUser,
|
||||
testID: 'room-actions-block-user'
|
||||
testID: 'room-actions-block-user',
|
||||
right: this.renderDisclosure
|
||||
}
|
||||
],
|
||||
renderItem: this.renderItem
|
||||
|
@ -366,7 +393,8 @@ class RoomActionsView extends React.Component {
|
|||
description: membersCount > 0 ? `${ membersCount } ${ I18n.t('members') }` : null,
|
||||
route: 'RoomMembersView',
|
||||
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'),
|
||||
nextAction: this.addUser
|
||||
},
|
||||
testID: 'room-actions-add-user'
|
||||
testID: 'room-actions-add-user',
|
||||
right: this.renderDisclosure
|
||||
});
|
||||
}
|
||||
if (canInviteUser) {
|
||||
|
@ -391,7 +420,8 @@ class RoomActionsView extends React.Component {
|
|||
params: {
|
||||
rid
|
||||
},
|
||||
testID: 'room-actions-invite-user'
|
||||
testID: 'room-actions-invite-user',
|
||||
right: this.renderDisclosure
|
||||
});
|
||||
}
|
||||
sections[2].data = [...actions, ...sections[2].data];
|
||||
|
@ -405,7 +435,8 @@ class RoomActionsView extends React.Component {
|
|||
name: I18n.t('Leave_channel'),
|
||||
type: 'danger',
|
||||
event: this.leaveChannel,
|
||||
testID: 'room-actions-leave-channel'
|
||||
testID: 'room-actions-leave-channel',
|
||||
right: this.renderDisclosure
|
||||
}
|
||||
],
|
||||
renderItem: this.renderItem
|
||||
|
@ -418,7 +449,8 @@ class RoomActionsView extends React.Component {
|
|||
sections[2].data.push({
|
||||
icon: 'close',
|
||||
name: I18n.t('Close'),
|
||||
event: this.closeLivechat
|
||||
event: this.closeLivechat,
|
||||
right: this.renderDisclosure
|
||||
});
|
||||
|
||||
if (canForwardGuest) {
|
||||
|
@ -426,7 +458,8 @@ class RoomActionsView extends React.Component {
|
|||
icon: 'user-forward',
|
||||
name: I18n.t('Forward'),
|
||||
route: 'ForwardLivechatView',
|
||||
params: { rid }
|
||||
params: { rid },
|
||||
right: this.renderDisclosure
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -434,7 +467,8 @@ class RoomActionsView extends React.Component {
|
|||
sections[2].data.push({
|
||||
icon: 'undo',
|
||||
name: I18n.t('Return'),
|
||||
event: this.returnLivechat
|
||||
event: this.returnLivechat,
|
||||
right: this.renderDisclosure
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -442,7 +476,8 @@ class RoomActionsView extends React.Component {
|
|||
icon: 'history',
|
||||
name: I18n.t('Navigation_history'),
|
||||
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;
|
||||
}
|
||||
|
||||
renderDisclosure = () => {
|
||||
const { theme } = this.props;
|
||||
return <DisclosureIndicator theme={theme} />;
|
||||
}
|
||||
|
||||
renderSeparator = () => {
|
||||
const { theme } = this.props;
|
||||
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 = () => {
|
||||
const { room: { rid } } = this.state;
|
||||
const { closeRoom } = this.props;
|
||||
|
@ -514,19 +582,58 @@ class RoomActionsView extends React.Component {
|
|||
}
|
||||
}
|
||||
|
||||
toggleBlockUser = () => {
|
||||
toggleBlockUser = async() => {
|
||||
logEvent(events.RA_TOGGLE_BLOCK_USER);
|
||||
const { room } = this.state;
|
||||
const { rid, blocker } = room;
|
||||
const { member } = this.state;
|
||||
try {
|
||||
RocketChat.toggleBlockUser(rid, member._id, !blocker);
|
||||
await RocketChat.toggleBlockUser(rid, member._id, !blocker);
|
||||
} catch (e) {
|
||||
logEvent(events.RA_TOGGLE_BLOCK_USER_F);
|
||||
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 = () => {
|
||||
logEvent(events.RA_SHARE);
|
||||
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 }]} />
|
||||
<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}
|
||||
<DisclosureIndicator theme={theme} />
|
||||
{item?.right?.()}
|
||||
</>
|
||||
);
|
||||
return this.renderTouchableItem(subview, item);
|
||||
|
@ -679,7 +786,8 @@ class RoomActionsView extends React.Component {
|
|||
const mapStateToProps = state => ({
|
||||
user: getUserSelector(state),
|
||||
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 => ({
|
||||
|
|
|
@ -58,5 +58,8 @@ export default StyleSheet.create({
|
|||
paddingRight: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center'
|
||||
},
|
||||
encryptedSwitch: {
|
||||
marginHorizontal: 16
|
||||
}
|
||||
});
|
||||
|
|
|
@ -56,6 +56,7 @@ class RoomInfoEditView extends React.Component {
|
|||
route: PropTypes.object,
|
||||
deleteRoom: PropTypes.func,
|
||||
serverVersion: PropTypes.string,
|
||||
e2eEnabled: PropTypes.bool,
|
||||
theme: PropTypes.string
|
||||
};
|
||||
|
||||
|
@ -76,7 +77,8 @@ class RoomInfoEditView extends React.Component {
|
|||
reactWhenReadOnly: false,
|
||||
archived: false,
|
||||
systemMessages: [],
|
||||
enableSysMes: false
|
||||
enableSysMes: false,
|
||||
encrypted: false
|
||||
};
|
||||
this.loadRoom();
|
||||
}
|
||||
|
@ -123,7 +125,7 @@ class RoomInfoEditView extends React.Component {
|
|||
|
||||
init = (room) => {
|
||||
const {
|
||||
description, topic, announcement, t, ro, reactWhenReadOnly, joinCodeRequired, sysMes
|
||||
description, topic, announcement, t, ro, reactWhenReadOnly, joinCodeRequired, sysMes, encrypted
|
||||
} = room;
|
||||
// fake password just to user knows about it
|
||||
this.randomValue = random(15);
|
||||
|
@ -139,7 +141,8 @@ class RoomInfoEditView extends React.Component {
|
|||
joinCode: joinCodeRequired ? this.randomValue : '',
|
||||
archived: room.archived,
|
||||
systemMessages: sysMes,
|
||||
enableSysMes: sysMes && sysMes.length > 0
|
||||
enableSysMes: sysMes && sysMes.length > 0,
|
||||
encrypted
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -157,7 +160,7 @@ class RoomInfoEditView extends React.Component {
|
|||
|
||||
formIsChanged = () => {
|
||||
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;
|
||||
const { joinCodeRequired } = room;
|
||||
return !(room.name === name
|
||||
|
@ -170,6 +173,7 @@ class RoomInfoEditView extends React.Component {
|
|||
&& room.reactWhenReadOnly === reactWhenReadOnly
|
||||
&& isEqual(room.sysMes, systemMessages)
|
||||
&& enableSysMes === (room.sysMes && room.sysMes.length > 0)
|
||||
&& room.encrypted === encrypted
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -177,7 +181,7 @@ class RoomInfoEditView extends React.Component {
|
|||
logEvent(events.RI_EDIT_SAVE);
|
||||
Keyboard.dismiss();
|
||||
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.setState({ saving: true });
|
||||
|
@ -231,6 +235,11 @@ class RoomInfoEditView extends React.Component {
|
|||
params.joinCode = joinCode;
|
||||
}
|
||||
|
||||
// Encrypted
|
||||
if (room.encrypted !== encrypted) {
|
||||
params.encrypted = encrypted;
|
||||
}
|
||||
|
||||
try {
|
||||
await RocketChat.saveRoomSettings(room.rid, params);
|
||||
} catch (e) {
|
||||
|
@ -340,7 +349,7 @@ class RoomInfoEditView extends React.Component {
|
|||
|
||||
toggleRoomType = (value) => {
|
||||
logEvent(events.RI_EDIT_TOGGLE_ROOM_TYPE);
|
||||
this.setState({ t: value });
|
||||
this.setState(({ encrypted }) => ({ t: value, encrypted: value && encrypted }));
|
||||
}
|
||||
|
||||
toggleReadOnly = (value) => {
|
||||
|
@ -358,11 +367,16 @@ class RoomInfoEditView extends React.Component {
|
|||
this.setState(({ systemMessages }) => ({ enableSysMes: value, systemMessages: value ? systemMessages : [] }));
|
||||
}
|
||||
|
||||
toggleEncrypted = (value) => {
|
||||
logEvent(events.RI_EDIT_TOGGLE_ENCRYPTED);
|
||||
this.setState({ encrypted: value });
|
||||
}
|
||||
|
||||
render() {
|
||||
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;
|
||||
const { serverVersion, theme } = this.props;
|
||||
const { serverVersion, e2eEnabled, theme } = this.props;
|
||||
const { dangerColor } = themes[theme];
|
||||
|
||||
return (
|
||||
|
@ -487,6 +501,19 @@ class RoomInfoEditView extends React.Component {
|
|||
{this.renderSystemMessages()}
|
||||
</SwitchContainer>
|
||||
) : 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
|
||||
style={[
|
||||
styles.buttonContainer,
|
||||
|
@ -577,7 +604,8 @@ class RoomInfoEditView extends React.Component {
|
|||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
serverVersion: state.server.version
|
||||
serverVersion: state.server.version,
|
||||
e2eEnabled: state.settings.E2E_Enable || false
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -17,6 +17,7 @@ import {
|
|||
import List from './List';
|
||||
import database from '../../lib/database';
|
||||
import RocketChat from '../../lib/rocketchat';
|
||||
import { Encryption } from '../../lib/encryption';
|
||||
import Message from '../../containers/message';
|
||||
import MessageActions from '../../containers/MessageActions';
|
||||
import MessageErrorActions from '../../containers/MessageErrorActions';
|
||||
|
@ -55,6 +56,7 @@ import Navigation from '../../lib/Navigation';
|
|||
import SafeAreaView from '../../containers/SafeAreaView';
|
||||
import { withDimensions } from '../../dimensions';
|
||||
import { getHeaderTitlePosition } from '../../containers/Header';
|
||||
import { E2E_MESSAGE_TYPE, E2E_STATUS } from '../../lib/encryption/constants';
|
||||
|
||||
import { takeInquiry } from '../../ee/omnichannel/lib';
|
||||
|
||||
|
@ -582,6 +584,18 @@ class RoomView extends React.Component {
|
|||
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) => {
|
||||
const { navigation } = this.props;
|
||||
navigation.push('RoomView', {
|
||||
|
@ -616,8 +630,12 @@ class RoomView extends React.Component {
|
|||
if (!item.tmsg) {
|
||||
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', {
|
||||
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) {
|
||||
navigation.push('RoomView', {
|
||||
|
@ -723,7 +741,8 @@ class RoomView extends React.Component {
|
|||
});
|
||||
});
|
||||
} 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.batch(
|
||||
threadCollection.prepareCreate((t) => {
|
||||
|
@ -858,6 +877,7 @@ class RoomView extends React.Component {
|
|||
onReactionPress={this.onReactionPress}
|
||||
onReactionLongPress={this.onReactionLongPress}
|
||||
onLongPress={this.onMessageLongPress}
|
||||
onEncryptedPress={this.onEncryptedPress}
|
||||
onDiscussionPress={this.onDiscussionPress}
|
||||
onThreadPress={this.onThreadPress}
|
||||
showAttachment={this.showAttachment}
|
||||
|
|
|
@ -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);
|
|
@ -2,6 +2,7 @@ import React from 'react';
|
|||
import PropTypes from 'prop-types';
|
||||
|
||||
import Sort from './Sort';
|
||||
import Encryption from './Encryption';
|
||||
|
||||
import OmnichannelStatus from '../../../ee/omnichannel/containers/OmnichannelStatus';
|
||||
|
||||
|
@ -9,12 +10,15 @@ const ListHeader = React.memo(({
|
|||
searching,
|
||||
sortBy,
|
||||
toggleSort,
|
||||
goEncryption,
|
||||
goQueue,
|
||||
queueSize,
|
||||
inquiryEnabled,
|
||||
encryptionBanner,
|
||||
user
|
||||
}) => (
|
||||
<>
|
||||
<Encryption searching={searching} goEncryption={goEncryption} encryptionBanner={encryptionBanner} />
|
||||
<Sort searching={searching} sortBy={sortBy} toggleSort={toggleSort} />
|
||||
<OmnichannelStatus searching={searching} goQueue={goQueue} inquiryEnabled={inquiryEnabled} queueSize={queueSize} user={user} />
|
||||
</>
|
||||
|
@ -24,9 +28,11 @@ ListHeader.propTypes = {
|
|||
searching: PropTypes.bool,
|
||||
sortBy: PropTypes.string,
|
||||
toggleSort: PropTypes.func,
|
||||
goEncryption: PropTypes.func,
|
||||
goQueue: PropTypes.func,
|
||||
queueSize: PropTypes.number,
|
||||
inquiryEnabled: PropTypes.bool,
|
||||
encryptionBanner: PropTypes.string,
|
||||
user: PropTypes.object
|
||||
};
|
||||
|
||||
|
|
|
@ -63,6 +63,7 @@ import SafeAreaView from '../../containers/SafeAreaView';
|
|||
import Header, { getHeaderTitlePosition } from '../../containers/Header';
|
||||
import { withDimensions } from '../../dimensions';
|
||||
import { showErrorAlert, showConfirmationAlert } from '../../utils/info';
|
||||
import { E2E_BANNER_TYPE } from '../../lib/encryption/constants';
|
||||
|
||||
import { getInquiryQueueSelector } from '../../ee/omnichannel/selectors/inquiry';
|
||||
import { changeLivechatStatus, isOmnichannelStatusAvailable } from '../../ee/omnichannel/lib';
|
||||
|
@ -98,7 +99,8 @@ const shouldUpdateProps = [
|
|||
'isMasterDetail',
|
||||
'refreshing',
|
||||
'queueSize',
|
||||
'inquiryEnabled'
|
||||
'inquiryEnabled',
|
||||
'encryptionBanner'
|
||||
];
|
||||
const getItemLayout = (data, index) => ({
|
||||
length: ROW_HEIGHT,
|
||||
|
@ -143,7 +145,8 @@ class RoomsListView extends React.Component {
|
|||
width: PropTypes.number,
|
||||
insets: PropTypes.object,
|
||||
queueSize: PropTypes.number,
|
||||
inquiryEnabled: PropTypes.bool
|
||||
inquiryEnabled: PropTypes.bool,
|
||||
encryptionBanner: PropTypes.string
|
||||
};
|
||||
|
||||
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 }) => {
|
||||
const { navigation, server, isMasterDetail } = this.props;
|
||||
const { input } = event;
|
||||
|
@ -848,17 +865,18 @@ class RoomsListView extends React.Component {
|
|||
renderListHeader = () => {
|
||||
const { searching } = this.state;
|
||||
const {
|
||||
sortBy, queueSize, inquiryEnabled, user
|
||||
sortBy, queueSize, inquiryEnabled, encryptionBanner, user
|
||||
} = this.props;
|
||||
return (
|
||||
<ListHeader
|
||||
searching={searching}
|
||||
sortBy={sortBy}
|
||||
toggleSort={this.toggleSort}
|
||||
goDirectory={this.goDirectory}
|
||||
goEncryption={this.goEncryption}
|
||||
goQueue={this.goQueue}
|
||||
queueSize={queueSize}
|
||||
inquiryEnabled={inquiryEnabled}
|
||||
encryptionBanner={encryptionBanner}
|
||||
user={user}
|
||||
/>
|
||||
);
|
||||
|
@ -1028,7 +1046,8 @@ const mapStateToProps = state => ({
|
|||
StoreLastMessage: state.settings.Store_Last_Message,
|
||||
rooms: state.room.rooms,
|
||||
queueSize: getInquiryQueueSelector(state).length,
|
||||
inquiryEnabled: state.inquiry.enabled
|
||||
inquiryEnabled: state.inquiry.enabled,
|
||||
encryptionBanner: state.encryption.banner
|
||||
});
|
||||
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
|
|
|
@ -126,6 +126,21 @@ export default StyleSheet.create({
|
|||
height: StyleSheet.hairlineWidth,
|
||||
marginLeft: 72
|
||||
},
|
||||
encryptionButton: {
|
||||
width: '100%',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
padding: 12
|
||||
},
|
||||
encryptionIcon: {
|
||||
...sharedStyles.textMedium
|
||||
},
|
||||
encryptionText: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
marginHorizontal: 16,
|
||||
...sharedStyles.textMedium
|
||||
},
|
||||
omnichannelToggle: {
|
||||
marginRight: 12
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { View, FlatList, Text } from 'react-native';
|
||||
import { Q } from '@nozbe/watermelondb';
|
||||
import { connect } from 'react-redux';
|
||||
import equal from 'deep-equal';
|
||||
|
||||
|
@ -20,6 +21,7 @@ import { withTheme } from '../../theme';
|
|||
import { getUserSelector } from '../../selectors/login';
|
||||
import SafeAreaView from '../../containers/SafeAreaView';
|
||||
import { CloseModalButton } from '../../containers/HeaderButton';
|
||||
import database from '../../lib/database';
|
||||
|
||||
class SearchMessagesView extends React.Component {
|
||||
static navigationOptions = ({ navigation, route }) => {
|
||||
|
@ -50,6 +52,7 @@ class SearchMessagesView extends React.Component {
|
|||
searchText: ''
|
||||
};
|
||||
this.rid = props.route.params?.rid;
|
||||
this.encrypted = props.route.params?.encrypted;
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
|
@ -71,21 +74,40 @@ class SearchMessagesView extends React.Component {
|
|||
}
|
||||
|
||||
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) => {
|
||||
this.setState({ searchText, loading: true, messages: [] });
|
||||
|
||||
try {
|
||||
const result = await RocketChat.searchMessages(this.rid, searchText);
|
||||
if (result.success) {
|
||||
const messages = await this.searchMessages(searchText);
|
||||
this.setState({
|
||||
messages: result.messages || [],
|
||||
messages: messages || [],
|
||||
loading: false
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
this.setState({ loading: false });
|
||||
log(e);
|
||||
|
|
|
@ -13,7 +13,7 @@ async function waitForToast() {
|
|||
// await expect(element(by.id('toast'))).toBeVisible();
|
||||
// await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000);
|
||||
// await expect(element(by.id('toast'))).toBeNotVisible();
|
||||
await sleep(1);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
describe('Profile screen', () => {
|
||||
|
|
|
@ -7,7 +7,7 @@ const data = require('../../data');
|
|||
const testuser = data.users.regular
|
||||
|
||||
async function waitForToast() {
|
||||
await sleep(1);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
describe('Status screen', () => {
|
||||
|
|
|
@ -245,7 +245,7 @@ describe('Room actions screen', () => {
|
|||
//Back into Room Actions
|
||||
await element(by.id('room-view-header-actions')).tap();
|
||||
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 element(by.id('room-actions-pinned')).tap();
|
||||
await waitFor(element(by.id('pinned-messages-view'))).toExist().withTimeout(2000);
|
||||
|
@ -281,6 +281,7 @@ describe('Room actions screen', () => {
|
|||
|
||||
describe('Notification', 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 element(by.id('room-actions-notifications')).tap();
|
||||
await waitFor(element(by.id('notification-preference-view'))).toExist().withTimeout(2000);
|
||||
|
|
|
@ -28,7 +28,7 @@ async function waitForToast() {
|
|||
// await expect(element(by.id('toast'))).toExist();
|
||||
// await waitFor(element(by.id('toast'))).toBeNotVisible().withTimeout(10000);
|
||||
// await expect(element(by.id('toast'))).toBeNotVisible();
|
||||
await sleep(1);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
describe('Room info screen', () => {
|
||||
|
|
|
@ -386,6 +386,9 @@ PODS:
|
|||
- React
|
||||
- react-native-safe-area-context (3.1.1):
|
||||
- React
|
||||
- react-native-simple-crypto (0.3.1):
|
||||
- OpenSSL-Universal
|
||||
- React
|
||||
- react-native-slider (3.0.2):
|
||||
- React
|
||||
- react-native-webview (10.3.2):
|
||||
|
@ -596,6 +599,7 @@ DEPENDENCIES:
|
|||
- react-native-notifications (from `../node_modules/react-native-notifications`)
|
||||
- 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-simple-crypto (from `../node_modules/react-native-simple-crypto`)
|
||||
- "react-native-slider (from `../node_modules/@react-native-community/slider`)"
|
||||
- react-native-webview (from `../node_modules/react-native-webview`)
|
||||
- React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`)
|
||||
|
@ -756,6 +760,8 @@ EXTERNAL SOURCES:
|
|||
:path: "../node_modules/react-native-orientation-locker"
|
||||
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:
|
||||
:path: "../node_modules/@react-native-community/slider"
|
||||
react-native-webview:
|
||||
|
@ -918,6 +924,7 @@ SPEC CHECKSUMS:
|
|||
react-native-notifications: ee8fd739853e72694f3af8b374c8ccb106b7b227
|
||||
react-native-orientation-locker: f0ca1a8e5031dab6b74bfb4ab33a17ed2c2fcb0d
|
||||
react-native-safe-area-context: 344b969c45af3d8464d36e8dea264942992ef033
|
||||
react-native-simple-crypto: 5e4f2877f71675d95baabf5823cd0cc0c379d0e6
|
||||
react-native-slider: 0221b417686c5957f6e77cd9ac22c1478a165355
|
||||
react-native-webview: 679b6f400176e2ea8a785acf7ae16cf282e7d1eb
|
||||
React-RCTActionSheet: 1702a1a85e550b5c36e2e03cb2bd3adea053de95
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Aes.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Hmac.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Pbkdf2.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTAes.h
|
1
ios/Pods/Headers/Private/react-native-simple-crypto/RCTCrypto-Bridging-Header.h
generated
Symbolic link
1
ios/Pods/Headers/Private/react-native-simple-crypto/RCTCrypto-Bridging-Header.h
generated
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTCrypto-Bridging-Header.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTHmac.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTPbkdf2.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTRsa.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTSha.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RNRandomBytes.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Rsa.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/RsaFormatter.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Sha.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Shared.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Aes.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Hmac.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Pbkdf2.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTAes.h
|
1
ios/Pods/Headers/Public/react-native-simple-crypto/RCTCrypto-Bridging-Header.h
generated
Symbolic link
1
ios/Pods/Headers/Public/react-native-simple-crypto/RCTCrypto-Bridging-Header.h
generated
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTCrypto-Bridging-Header.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTHmac.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTPbkdf2.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTRsa.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RCTSha.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/RNRandomBytes.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Rsa.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/RsaFormatter.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Sha.h
|
|
@ -0,0 +1 @@
|
|||
../../../../../node_modules/react-native-simple-crypto/ios/RCTCrypto/lib/Shared.h
|
1
ios/Pods/Headers/Public/react_native_simple_crypto/react-native-simple-crypto-umbrella.h
generated
Symbolic link
1
ios/Pods/Headers/Public/react_native_simple_crypto/react-native-simple-crypto-umbrella.h
generated
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../../Target Support Files/react-native-simple-crypto/react-native-simple-crypto-umbrella.h
|
1
ios/Pods/Headers/Public/react_native_simple_crypto/react-native-simple-crypto.modulemap
generated
Symbolic link
1
ios/Pods/Headers/Public/react_native_simple_crypto/react-native-simple-crypto.modulemap
generated
Symbolic link
|
@ -0,0 +1 @@
|
|||
../../../Target Support Files/react-native-simple-crypto/react-native-simple-crypto.modulemap
|
|
@ -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
Loading…
Reference in New Issue