[NEW] E2E Encryption (#2394)

* Add E2EKey to Subscription Model

* Install react-native-simple-crypto

* Install bytebuffer

* Add translations

* CreateChannel Encrypted toggle

* Request E2E_Enabled setting

* Add some E2E API methods

* POC E2E Encryption

* Garbage remove

* Remove keys cleaner

* Android cast JWK -> PKCS1

* Initialize E2E when Login Success

* Add some translations

* Add e2e property to Message model

* Send Encrypted messages

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

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

* Create an encrypted channel

* Fix app crashing on RoomsList

* Create room key

* Set Room E2E Key (Android)

* Edit room encrypted

* Show encrypted icon on messages

* logEvents

* Decrypt pending subscriptions & messages

* Handle user cancel e2e password entry

* E2ESavePasswordView

* Update Snapshot

* Add encrypted props to message on Send

* Thread messages encryption

* E2E -> Encryption

* Share Extension: Share encrypted text

* (POC) Search messages on Encrypted room

* Provide room key to new users

* Request roomKey on stream-notify-room-users

* Add e2eKeyId to Room Model

* (WIP) E2E Encryption Screens

* Remove encryption subscription file

* Move E2E_Enable to Server Model

* Encryption List Banner

* Move Encryption init to Sagas

* Show banner only when enabled

* Use RocketChat/react-native-simple-crypto

* Search on WM only when is an Encrypted channel

* (WIP) Encryption Banner

* Encryption banner

* Patch -> Fork

* Improve send encrypted message

* Update simple-crypto

* Not decrypt already decrypted messages

* Add comments

* Change eslint disable to inline

* Improve code

* Remove comment

* Some fixes

* (WIP) Encryption Screens

* Improve sub find

* Resend an encrypted message

* Fix comment

* Code improvements

* Hide e2e buttons on features if it is not enabled

* InApp notifications of a encrypted room

* Encryption stop logic

* Edit encrypted message

* DB batch on decryptPending

* Encryption ready client

* Comments

* Handle getRoomInstance errors

* Multiple messages decrypt

* Remove unnecessary try/catch

* Fix decrypt all messages history

* Just add a questionmark

* Fix some subscriptions missing decrypt

* Disable request key logic

* Fix unicode emojis

* Fix e2ekey request

* roomId -> subscription

* Decrypt subscription after merge

* E2ERoom -> EncryptionRoom

* Fix infinite loading

* Handle import key errors

* Handle request key errors

* Move e2eRequestRoomKey to Rocket.Chat

* WIP handshake when key should be requested

* Add search messages explanation

* Remove some TODO and update comments

* Improvements

* Dont show message hash to user

* Handle key request & prevent multiple calls

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

* Insert decrypted subscription

* Fix crash after login

* Decrypt sub when receive the key

* Decrypt pending messages of a room

* Encrypted as a switch

* Buffer to Base64 URI Safe

* Add a relevant comment

* Prevent import key without a privateKey

* Prevent create a new instance when client is not ready

* Update simple-crypto & remove replace trick

* More comments

* Remove useless comment

* Remove useless try/catch

* I18n all E2E screens

* E2ESavePassword -> E2ESaveYourPassword

* Prevent multiple views on message when is not encrypted

* Fix encryption toggle not working sometimes

* follow some suggestions

* dont rotate icons

* remove unnecessary condition

* remove unreachable event

* create channel comment

* disable no-bitwise rule for entire file

* loadKeys -> persistKeys

* getMasterKey -> generateMasterKey

* explicit difference between E2EKey & e2eKeyId

* roomId -> rid

* group columns

* Remove server selector

* missing log events

* remove comment

* use stored public key

* update simple-crypto & remove base64-js patch

* add some logs

* remove unreachable condition

* log errors

* handle errors on provide key directly on subscription

* Downgrade RocketChat/react-native-simple-crypto

* improve get room instance

* migration of older apps

* check encrypted status before send a message

* wait client ready

* use our own base64-js

* add more jest tests

* explain return

* remove unncessary stop

* thrown error to caller

* remove superfluous checks

* use Encryption property

* change ready state logic

* ready -> establishing

* encryption.room -> encryptionRoom

* EncryptionRoom -> Room

* add documentation

* wait establishing before provide a room key

* remove superfluous condition

* improve error handling logic

* fallback e2ekey set

* remove no longer necessary check

* remove e.g.

* improve getRoomInstance

* import from index

* use batch

* fix a comment

* decrypt tmsg

* dont show hash when message is encrypted

* Fix detox

* Apply suggestions from code review

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

View File

@ -3333,6 +3333,165 @@ exports[`Storyshots Message list message 1`] = `
>
Edited
</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 [

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,29 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import Touchable from './Touchable';
import { E2E_MESSAGE_TYPE } from '../../lib/encryption/constants';
import { CustomIcon } from '../../lib/Icons';
import { themes } from '../../constants/colors';
import { BUTTON_HIT_SLOP } from './utils';
import MessageContext from './Context';
import styles from './styles';
const Encrypted = React.memo(({ type, theme }) => {
if (type !== E2E_MESSAGE_TYPE) {
return null;
}
const { onEncryptedPress } = useContext(MessageContext);
return (
<Touchable onPress={onEncryptedPress} style={styles.encrypted} hitSlop={BUTTON_HIT_SLOP}>
<CustomIcon name='encrypted' size={16} color={themes[theme].auxiliaryText} />
</Touchable>
);
});
Encrypted.propTypes = {
type: PropTypes.string,
theme: PropTypes.string
};
export default Encrypted;

View File

@ -8,9 +8,10 @@ import { CustomIcon } from '../../lib/Icons';
import DisclosureIndicator from '../DisclosureIndicator';
import 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';

View File

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

View File

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

View File

@ -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',

View File

@ -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',

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -129,6 +129,43 @@ export default schemaMigrations({
]
})
]
},
{
toVersion: 10,
steps: [
addColumns({
table: 'subscriptions',
columns: [
{ name: 'e2e_key', type: 'string', isOptional: true },
{ name: 'encrypted', type: 'boolean', isOptional: true },
{ name: 'e2e_key_id', type: 'string', isOptional: true }
]
}),
addColumns({
table: 'messages',
columns: [
{ name: 'e2e', type: 'string', isOptional: true }
]
}),
addColumns({
table: 'thread_messages',
columns: [
{ name: 'e2e', type: 'string', isOptional: true }
]
}),
addColumns({
table: 'threads',
columns: [
{ name: 'e2e', type: 'string', isOptional: true }
]
}),
addColumns({
table: 'rooms',
columns: [
{ name: 'e2e_key_id', type: 'string', isOptional: true }
]
})
]
}
]
});

View File

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

View File

@ -1,7 +1,7 @@
import { appSchema, tableSchema } from '@nozbe/watermelondb';
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({

View File

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

View File

@ -0,0 +1,28 @@
# Rocket.Chat Mobile
## E2E Encryption
> Note: This feature is currently in beta. Uploads will not be encrypted in this version.
You can check [this documentation](https://docs.rocket.chat/guides/user-guides/end-to-end-encryption) for further information about the web client.
### How it works
- Each user has a public and private key (asymmetric cryptography).
- The user private key is stored encrypted on the server and it can be decrypted on clients only using the user E2E encryption password.
- A room key is generated using the public key of each room member (symmetric cryptography).
- Users can decrypt the room key using their private key.
- Each room has a unique identifier which make users able to request a room key.
- The room unique identifier is called `e2eKeyId` and it's a property of the `room` collection.
- The room key is called `E2EKey` and it's a property of the `subscription` collection.
- After the room key is decrypted, the user is able to encrypt and decrypt messages of the room.
### User keys
* If the user doesn't have keys neither locally nor on the server, we create and encrypt them using a random password. These encrypted keys are sent to the server (so other clients can fetch) and saved locally.
* If the user have keys stored on server, but doesn't have them stored locally, we fetch them from the server and request a password to decrypt the keys.
### Room keys
* If the room has a `E2EKey`, we decrypt it using the user key.
* If the room doesn't have a `E2EKey`, but has a `e2eKeyId`, we *emit an event* on _stream-notify-room-users_ sending the `roomId` and the `e2eKeyId` requesting the `E2EKey` from any online room member.
* If the room have none of them, we create new ones and send them back to the server.

View File

@ -0,0 +1,17 @@
export const E2E_MESSAGE_TYPE = 'e2e';
export const E2E_PUBLIC_KEY = 'RC_E2E_PUBLIC_KEY';
export const E2E_PRIVATE_KEY = 'RC_E2E_PRIVATE_KEY';
export const E2E_RANDOM_PASSWORD_KEY = 'RC_E2E_RANDOM_PASSWORD_KEY';
export const E2E_REFRESH_MESSAGES_KEY = 'E2E_REFRESH_MESSAGES_KEY';
export const E2E_STATUS = {
PENDING: 'pending',
DONE: 'done'
};
export const E2E_BANNER_TYPE = {
REQUEST_PASSWORD: 'REQUEST_PASSWORD',
SAVE_PASSWORD: 'SAVE_PASSWORD'
};
export const E2E_ROOM_TYPES = {
d: 'd',
p: 'p'
};

View File

@ -0,0 +1,452 @@
import EJSON from 'ejson';
import SimpleCrypto from 'react-native-simple-crypto';
import { sanitizedRaw } from '@nozbe/watermelondb/RawRecord';
import { Q } from '@nozbe/watermelondb';
import {
toString,
utf8ToBuffer,
splitVectorData,
joinVectorData,
randomPassword
} from './utils';
import {
E2E_PUBLIC_KEY,
E2E_PRIVATE_KEY,
E2E_RANDOM_PASSWORD_KEY,
E2E_STATUS,
E2E_MESSAGE_TYPE,
E2E_BANNER_TYPE
} from './constants';
import RocketChat from '../rocketchat';
import { EncryptionRoom } from './index';
import UserPreferences from '../userPreferences';
import database from '../database';
import protectedFunction from '../methods/helpers/protectedFunction';
import Deferred from '../../utils/deferred';
import log from '../../utils/log';
import store from '../createStore';
class Encryption {
constructor() {
this.ready = false;
this.privateKey = null;
this.roomInstances = {};
this.readyPromise = new Deferred();
this.readyPromise
.then(() => {
this.ready = true;
})
.catch(() => {
this.ready = false;
});
}
// Initialize Encryption client
initialize = () => {
this.roomInstances = {};
// Don't await these promises
// so they can run parallelized
this.decryptPendingSubscriptions();
this.decryptPendingMessages();
// Mark Encryption client as ready
this.readyPromise.resolve();
}
get establishing() {
const { banner } = store.getState().encryption;
// If the password was not inserted yet
if (banner === E2E_BANNER_TYPE.REQUEST_PASSWORD) {
// We can't decrypt/encrypt, so, reject this try
return Promise.reject();
}
// Wait the client ready state
return this.readyPromise;
}
// Stop Encryption client
stop = () => {
this.privateKey = null;
this.roomInstances = {};
// Cancel ongoing encryption/decryption requests
this.readyPromise.reject();
// Reset Deferred
this.ready = false;
this.readyPromise = new Deferred();
this.readyPromise
.then(() => {
this.ready = true;
})
.catch(() => {
this.ready = false;
});
}
// When a new participant join and request a new room encryption key
provideRoomKeyToUser = async(keyId, rid) => {
// If the client is not ready
if (!this.ready) {
try {
// Wait for ready status
await this.establishing;
} catch {
// If it can't be initialized (missing password)
// return and don't provide a key
return;
}
}
const roomE2E = await this.getRoomInstance(rid);
return roomE2E.provideKeyToUser(keyId);
}
// Persist keys on UserPreferences
persistKeys = async(server, publicKey, privateKey) => {
this.privateKey = await SimpleCrypto.RSA.importKey(EJSON.parse(privateKey));
await UserPreferences.setStringAsync(`${ server }-${ E2E_PUBLIC_KEY }`, EJSON.stringify(publicKey));
await UserPreferences.setStringAsync(`${ server }-${ E2E_PRIVATE_KEY }`, privateKey);
}
// Could not obtain public-private keypair from server.
createKeys = async(userId, server) => {
// Generate new keys
const key = await SimpleCrypto.RSA.generateKeys(2048);
// Cast these keys to the properly server format
const publicKey = await SimpleCrypto.RSA.exportKey(key.public);
const privateKey = await SimpleCrypto.RSA.exportKey(key.private);
// Persist these new keys
this.persistKeys(server, publicKey, EJSON.stringify(privateKey));
// Create a password to encode the private key
const password = await this.createRandomPassword(server);
// Encode the private key
const encodedPrivateKey = await this.encodePrivateKey(EJSON.stringify(privateKey), password, userId);
// Send the new keys to the server
await RocketChat.e2eSetUserPublicAndPrivateKeys(EJSON.stringify(publicKey), encodedPrivateKey);
// Request e2e keys of all encrypted rooms
await RocketChat.e2eRequestSubscriptionKeys();
}
// Encode a private key before send it to the server
encodePrivateKey = async(privateKey, password, userId) => {
const masterKey = await this.generateMasterKey(password, userId);
const vector = await SimpleCrypto.utils.randomBytes(16);
const data = await SimpleCrypto.AES.encrypt(
utf8ToBuffer(privateKey),
masterKey,
vector
);
return EJSON.stringify(new Uint8Array(joinVectorData(vector, data)));
}
// Decode a private key fetched from server
decodePrivateKey = async(privateKey, password, userId) => {
const masterKey = await this.generateMasterKey(password, userId);
const [vector, cipherText] = splitVectorData(EJSON.parse(privateKey));
const privKey = await SimpleCrypto.AES.decrypt(
cipherText,
masterKey,
vector
);
return toString(privKey);
}
// Generate a user master key, this is based on userId and a password
generateMasterKey = async(password, userId) => {
const iterations = 1000;
const hash = 'SHA256';
const keyLen = 32;
const passwordBuffer = utf8ToBuffer(password);
const saltBuffer = utf8ToBuffer(userId);
const masterKey = await SimpleCrypto.PBKDF2.hash(
passwordBuffer,
saltBuffer,
iterations,
keyLen,
hash
);
return masterKey;
}
// Create a random password to local created keys
createRandomPassword = async(server) => {
const password = randomPassword();
await UserPreferences.setStringAsync(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`, password);
return password;
}
// get a encryption room instance
getRoomInstance = async(rid) => {
// Prevent handshake again
if (this.roomInstances[rid]?.ready) {
return this.roomInstances[rid];
}
// If doesn't have a instance of this room
if (!this.roomInstances[rid]) {
this.roomInstances[rid] = new EncryptionRoom(rid);
}
const roomE2E = this.roomInstances[rid];
// Start Encryption Room instance handshake
await roomE2E.handshake();
return roomE2E;
}
// Logic to decrypt all pending messages/threads/threadMessages
// after initialize the encryption client
decryptPendingMessages = async(roomId) => {
const db = database.active;
const messagesCollection = db.collections.get('messages');
const threadsCollection = db.collections.get('threads');
const threadMessagesCollection = db.collections.get('thread_messages');
// e2e status is null or 'pending' and message type is 'e2e'
const whereClause = [
Q.where('t', E2E_MESSAGE_TYPE),
Q.or(
Q.where('e2e', null),
Q.where('e2e', E2E_STATUS.PENDING)
)
];
// decrypt messages of a room
if (roomId) {
whereClause.push(Q.where('rid', roomId));
}
try {
// Find all messages/threads/threadsMessages that have pending e2e status
const messagesToDecrypt = await messagesCollection.query(...whereClause).fetch();
const threadsToDecrypt = await threadsCollection.query(...whereClause).fetch();
const threadMessagesToDecrypt = await threadMessagesCollection.query(...whereClause).fetch();
// Concat messages/threads/threadMessages
let toDecrypt = [...messagesToDecrypt, ...threadsToDecrypt, ...threadMessagesToDecrypt];
toDecrypt = await Promise.all(toDecrypt.map(async(message) => {
const { t, msg, tmsg } = message;
const { id: rid } = message.subscription;
// WM Object -> Plain Object
const newMessage = await this.decryptMessage({
t,
rid,
msg,
tmsg
});
if (message._hasPendingUpdate) {
console.log(message);
return;
}
return message.prepareUpdate(protectedFunction((m) => {
Object.assign(m, newMessage);
}));
}));
await db.action(async() => {
await db.batch(...toDecrypt);
});
} catch (e) {
log(e);
}
}
// Logic to decrypt all pending subscriptions
// after initialize the encryption client
decryptPendingSubscriptions = async() => {
const db = database.active;
const subCollection = db.collections.get('subscriptions');
try {
// Find all rooms that can have a lastMessage encrypted
// If we select only encrypted rooms we can miss some room that changed their encrypted status
const subsEncrypted = await subCollection.query(Q.where('e2e_key_id', Q.notEq(null))).fetch();
// We can't do this on database level since lastMessage is not a database object
const subsToDecrypt = subsEncrypted.filter(sub => (
// Encrypted message
sub?.lastMessage?.t === E2E_MESSAGE_TYPE
// Message pending decrypt
&& sub?.lastMessage?.e2e === E2E_STATUS.PENDING
));
await Promise.all(subsToDecrypt.map(async(sub) => {
const { rid, lastMessage } = sub;
const newSub = await this.decryptSubscription({ rid, lastMessage });
if (sub._hasPendingUpdate) {
console.log(sub);
return;
}
return sub.prepareUpdate(protectedFunction((m) => {
Object.assign(m, newSub);
}));
}));
await db.action(async() => {
await db.batch(...subsToDecrypt);
});
} catch (e) {
log(e);
}
}
// Decrypt a subscription lastMessage
decryptSubscription = async(subscription) => {
// If the subscription doesn't have a lastMessage just return
if (!subscription?.lastMessage) {
return subscription;
}
const { lastMessage } = subscription;
const { t, e2e } = lastMessage;
// If it's not a encrypted message or was decrypted before
if (t !== E2E_MESSAGE_TYPE || e2e === E2E_STATUS.DONE) {
return subscription;
}
// If the client is not ready
if (!this.ready) {
try {
// Wait for ready status
await this.establishing;
} catch {
// If it can't be initialized (missing password)
// return the encrypted message
return subscription;
}
}
const { rid } = subscription;
const db = database.active;
const subCollection = db.collections.get('subscriptions');
let subRecord;
try {
subRecord = await subCollection.find(rid);
} catch {
// Do nothing
}
try {
const batch = [];
// If the subscription doesn't exists yet
if (!subRecord) {
// Let's create the subscription with the data received
batch.push(subCollection.prepareCreate((s) => {
s._raw = sanitizedRaw({ id: rid }, subCollection.schema);
Object.assign(s, subscription);
}));
// If the subscription already exists but doesn't have the E2EKey yet
} else if (!subRecord.E2EKey && subscription.E2EKey) {
if (!subRecord._hasPendingUpdate) {
// Let's update the subscription with the received E2EKey
batch.push(subRecord.prepareUpdate((s) => {
s.E2EKey = subscription.E2EKey;
}));
}
}
// If batch has some operation
if (batch.length) {
await db.action(async() => {
await db.batch(...batch);
});
}
} catch {
// Abort the decryption process
// Return as received
return subscription;
}
// Get a instance using the subscription
const roomE2E = await this.getRoomInstance(rid);
const decryptedMessage = await roomE2E.decrypt(lastMessage);
return {
...subscription,
lastMessage: decryptedMessage
};
}
// Encrypt a message
encryptMessage = async(message) => {
const { rid } = message;
const db = database.active;
const subCollection = db.collections.get('subscriptions');
try {
// Find the subscription
const subRecord = await subCollection.find(rid);
// Subscription is not encrypted at the moment
if (!subRecord.encrypted) {
// Send a non encrypted message
return message;
}
// If the client is not ready
if (!this.ready) {
// Wait for ready status
await this.establishing;
}
const roomE2E = await this.getRoomInstance(rid);
return roomE2E.encrypt(message);
} catch {
// Subscription not found
// or client can't be initialized (missing password)
}
// Send a non encrypted message
return message;
}
// Decrypt a message
decryptMessage = async(message) => {
const { t, e2e } = message;
// Prevent create a new instance if this room was encrypted sometime ago
if (t !== E2E_MESSAGE_TYPE || e2e === E2E_STATUS.DONE) {
return message;
}
// If the client is not ready
if (!this.ready) {
try {
// Wait for ready status
await this.establishing;
} catch {
// If it can't be initialized (missing password)
// return the encrypted message
return message;
}
}
const { rid } = message;
const roomE2E = await this.getRoomInstance(rid);
return roomE2E.decrypt(message);
}
// Decrypt multiple messages
decryptMessages = messages => Promise.all(messages.map(m => this.decryptMessage(m)))
// Decrypt multiple subscriptions
decryptSubscriptions = subscriptions => Promise.all(subscriptions.map(s => this.decryptSubscription(s)))
}
const encryption = new Encryption();
export default encryption;

View File

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

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

@ -0,0 +1,255 @@
import EJSON from 'ejson';
import { Base64 } from 'js-base64';
import SimpleCrypto from 'react-native-simple-crypto';
import {
toString,
b64ToBuffer,
bufferToUtf8,
bufferToB64,
bufferToB64URI,
utf8ToBuffer,
splitVectorData,
joinVectorData
} from './utils';
import { E2E_MESSAGE_TYPE, E2E_STATUS } from './constants';
import RocketChat from '../rocketchat';
import Deferred from '../../utils/deferred';
import debounce from '../../utils/debounce';
import { Encryption } from './index';
import database from '../database';
import log from '../../utils/log';
export default class EncryptionRoom {
constructor(roomId) {
this.ready = false;
this.roomId = roomId;
this.establishing = false;
this.readyPromise = new Deferred();
this.readyPromise.then(() => {
// Mark as ready
this.ready = true;
// Mark as established
this.establishing = false;
});
}
// Initialize the E2E room
handshake = async() => {
// If it's already ready we don't need to handshake again
if (this.ready) {
return;
}
// If it's already establishing
if (this.establishing) {
// Return the ready promise to wait this client ready
return this.readyPromise;
}
const db = database.active;
const subCollection = db.collections.get('subscriptions');
try {
// Find the subscription
const subscription = await subCollection.find(this.roomId);
const { E2EKey, e2eKeyId } = subscription;
// If this room has a E2EKey, we import it
if (E2EKey) {
// We're establishing a new room encryption client
this.establishing = true;
await this.importRoomKey(E2EKey, Encryption.privateKey);
this.readyPromise.resolve();
return;
}
// If it doesn't have a e2eKeyId, we need to create keys to the room
if (!e2eKeyId) {
// We're establishing a new room encryption client
this.establishing = true;
await this.createRoomKey();
this.readyPromise.resolve();
return;
}
// Request a E2EKey for this room to other users
await this.requestRoomKey(e2eKeyId);
} catch (e) {
log(e);
}
}
// Import roomKey as an AES Decrypt key
importRoomKey = async(E2EKey, privateKey) => {
const roomE2EKey = E2EKey.slice(12);
const decryptedKey = await SimpleCrypto.RSA.decrypt(roomE2EKey, privateKey);
this.sessionKeyExportedString = toString(decryptedKey);
this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12);
// Extract K from Web Crypto Secret Key
// K is a base64URL encoded array of bytes
// Web Crypto API uses this as a private key to decrypt/encrypt things
// Reference: https://www.javadoc.io/doc/com.nimbusds/nimbus-jose-jwt/5.1/com/nimbusds/jose/jwk/OctetSequenceKey.html
const { k } = EJSON.parse(this.sessionKeyExportedString);
this.roomKey = b64ToBuffer(k);
}
// Create a key to a room
createRoomKey = async() => {
const key = await SimpleCrypto.utils.randomBytes(16);
this.roomKey = key;
// Web Crypto format of a Secret Key
const sessionKeyExported = {
// Type of Secret Key
kty: 'oct',
// Algorithm
alg: 'A128CBC',
// Base64URI encoded array of bytes
k: bufferToB64URI(this.roomKey),
// Specific Web Crypto properties
ext: true,
key_ops: ['encrypt', 'decrypt']
};
this.sessionKeyExportedString = EJSON.stringify(sessionKeyExported);
this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12);
await RocketChat.e2eSetRoomKeyID(this.roomId, this.keyID);
await this.encryptRoomKey();
}
// Request a key to this room
// We're debouncing this function to avoid multiple calls
// when you join a room with a lot of messages and nobody
// can send the encryption key at the moment.
// Each time you see a encrypted message of a room that you don't have a key
// this will be called again and run once in 5 seconds
requestRoomKey = debounce(async(e2eKeyId) => {
await RocketChat.e2eRequestRoomKey(this.roomId, e2eKeyId);
}, 5000, true)
// Create an encrypted key for this room based on users
encryptRoomKey = async() => {
const result = await RocketChat.e2eGetUsersOfRoomWithoutKey(this.roomId);
if (result.success) {
const { users } = result;
await Promise.all(users.map(user => this.encryptRoomKeyForUser(user)));
}
}
// Encrypt the room key to each user in
encryptRoomKeyForUser = async(user) => {
if (user?.e2e?.public_key) {
const { public_key: publicKey } = user.e2e;
const userKey = await SimpleCrypto.RSA.importKey(EJSON.parse(publicKey));
const encryptedUserKey = await SimpleCrypto.RSA.encrypt(this.sessionKeyExportedString, userKey);
await RocketChat.e2eUpdateGroupKey(user?._id, this.roomId, this.keyID + encryptedUserKey);
}
}
// Provide this room key to a user
provideKeyToUser = async(keyId) => {
// Don't provide a key if the keyId received
// is different than the current one
if (this.keyID !== keyId) {
return;
}
await this.encryptRoomKey();
}
// Encrypt text
encryptText = async(text) => {
text = utf8ToBuffer(text);
const vector = await SimpleCrypto.utils.randomBytes(16);
const data = await SimpleCrypto.AES.encrypt(
text,
this.roomKey,
vector
);
return this.keyID + bufferToB64(joinVectorData(vector, data));
}
// Encrypt messages
encrypt = async(message) => {
if (!this.ready) {
return message;
}
try {
const msg = await this.encryptText(EJSON.stringify({
_id: message._id,
text: message.msg,
userId: this.userId,
ts: new Date()
}));
return {
...message,
t: E2E_MESSAGE_TYPE,
e2e: E2E_STATUS.PENDING,
msg
};
} catch {
// Do nothing
}
return message;
}
// Decrypt text
decryptText = async(msg) => {
msg = b64ToBuffer(msg.slice(12));
const [vector, cipherText] = splitVectorData(msg);
const decrypted = await SimpleCrypto.AES.decrypt(
cipherText,
this.roomKey,
vector
);
const m = EJSON.parse(bufferToUtf8(decrypted));
return m.text;
}
// Decrypt messages
decrypt = async(message) => {
if (!this.ready) {
return message;
}
try {
const { t, e2e } = message;
// If message type is e2e and it's encrypted still
if (t === E2E_MESSAGE_TYPE && e2e !== E2E_STATUS.DONE) {
let { msg, tmsg } = message;
// Decrypt msg
msg = await this.decryptText(msg);
// Decrypt tmsg
if (tmsg) {
tmsg = await this.decryptText(tmsg);
}
return {
...message,
tmsg,
msg,
e2e: E2E_STATUS.DONE
};
}
} catch {
// Do nothing
}
return message;
}
}

View File

@ -0,0 +1,60 @@
/* eslint-disable no-bitwise */
import ByteBuffer from 'bytebuffer';
import SimpleCrypto from 'react-native-simple-crypto';
import random from '../../utils/random';
import { fromByteArray, toByteArray } from '../../utils/base64-js';
const BASE64URI = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
export const b64ToBuffer = base64 => toByteArray(base64).buffer;
export const utf8ToBuffer = SimpleCrypto.utils.convertUtf8ToArrayBuffer;
export const bufferToB64 = arrayBuffer => fromByteArray(new Uint8Array(arrayBuffer));
// ArrayBuffer -> Base64 URI Safe
// https://github.com/herrjemand/Base64URL-ArrayBuffer/blob/master/lib/base64url-arraybuffer.js
export const bufferToB64URI = (buffer) => {
const uintArray = new Uint8Array(buffer);
const len = uintArray.length;
let base64 = '';
for (let i = 0; i < len; i += 3) {
base64 += BASE64URI[uintArray[i] >> 2];
base64 += BASE64URI[((uintArray[i] & 3) << 4) | (uintArray[i + 1] >> 4)];
base64 += BASE64URI[((uintArray[i + 1] & 15) << 2) | (uintArray[i + 2] >> 6)];
base64 += BASE64URI[uintArray[i + 2] & 63];
}
if ((len % 3) === 2) {
base64 = base64.substring(0, base64.length - 1);
} else if (len % 3 === 1) {
base64 = base64.substring(0, base64.length - 2);
}
return base64;
};
// SimpleCrypto.utils.convertArrayBufferToUtf8 is not working with unicode emoji
export const bufferToUtf8 = (buffer) => {
const uintArray = new Uint8Array(buffer);
const encodedString = String.fromCharCode.apply(null, uintArray);
const decodedString = decodeURIComponent(escape(encodedString));
return decodedString;
};
export const splitVectorData = (text) => {
const vector = text.slice(0, 16);
const data = text.slice(16);
return [vector, data];
};
export const joinVectorData = (vector, data) => {
const output = new Uint8Array(vector.byteLength + data.byteLength);
output.set(new Uint8Array(vector), 0);
output.set(new Uint8Array(data), vector.byteLength);
return output.buffer;
};
export const toString = (thing) => {
if (typeof thing === 'string') {
return thing;
}
// eslint-disable-next-line new-cap
return new ByteBuffer.wrap(thing).toString('binary');
};
export const randomPassword = () => `${ random(3) }-${ random(3) }-${ random(3) }`.toLowerCase();

View File

@ -11,7 +11,16 @@ import protectedFunction from './helpers/protectedFunction';
import fetch from '../../utils/fetch';
import { 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;
}, {});

View File

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

View File

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

View File

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

View File

@ -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 }) {

View File

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

View File

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

View File

@ -16,6 +16,8 @@ import EventEmitter from '../../../utils/events';
import { removedRoom } from '../../../actions/room';
import { setUser } from '../../../actions/login';
import { INAPP_NOTIFICATION_EMITTER } from '../../../containers/InAppNotification';
import { Encryption } from '../../encryption';
import { E2E_MESSAGE_TYPE } from '../../encryption/constants';
const removeListener = listener => listener.stop();
@ -79,7 +81,10 @@ const createOrUpdateSubscription = async(subscription, room) => {
departmentId: s.departmentId,
servedBy: s.servedBy,
livechatData: s.livechatData,
tags: s.tags
tags: s.tags,
encrypted: s.encrypted,
e2eKeyId: s.e2eKeyId,
E2EKey: s.E2EKey
};
} catch (error) {
try {
@ -107,6 +112,7 @@ const createOrUpdateSubscription = async(subscription, room) => {
tags: r.tags,
servedBy: r.servedBy,
encrypted: r.encrypted,
e2eKeyId: r.e2eKeyId,
broadcast: r.broadcast,
customFields: r.customFields,
departmentId: r.departmentId,
@ -117,73 +123,91 @@ const createOrUpdateSubscription = async(subscription, room) => {
}
}
const tmp = merge(subscription, room);
await db.action(async() => {
let sub;
let tmp = merge(subscription, room);
tmp = await Encryption.decryptSubscription(tmp);
let sub;
try {
sub = await subCollection.find(tmp.rid);
} catch (error) {
// Do nothing
}
// If we're receiving a E2EKey of a room
if (sub && !sub.E2EKey && subscription?.E2EKey) {
// Assing info from database subscription to tmp
// It should be a plain object
tmp = Object.assign(tmp, {
rid: sub.rid,
encrypted: sub.encrypted,
lastMessage: sub.lastMessage,
E2EKey: subscription.E2EKey,
e2eKeyId: sub.e2eKeyId
});
// Decrypt lastMessage using the received E2EKey
tmp = await Encryption.decryptSubscription(tmp);
// Decrypt all pending messages of this room in parallel
Encryption.decryptPendingMessages(tmp.rid);
}
const batch = [];
if (sub) {
try {
sub = await subCollection.find(tmp.rid);
const update = sub.prepareUpdate((s) => {
Object.assign(s, tmp);
if (subscription.announcement) {
if (subscription.announcement !== sub.announcement) {
s.bannerClosed = false;
}
}
});
batch.push(update);
} catch (e) {
console.log(e);
}
} else {
try {
const create = subCollection.prepareCreate((s) => {
s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema);
Object.assign(s, tmp);
if (s.roomUpdatedAt) {
s.roomUpdatedAt = new Date();
}
});
batch.push(create);
} catch (e) {
console.log(e);
}
}
const { rooms } = store.getState().room;
if (tmp.lastMessage && !rooms.includes(tmp.rid)) {
const lastMessage = buildMessage(tmp.lastMessage);
const messagesCollection = db.collections.get('messages');
let messageRecord;
try {
messageRecord = await messagesCollection.find(lastMessage._id);
} catch (error) {
// Do nothing
}
const batch = [];
if (sub) {
try {
const update = sub.prepareUpdate((s) => {
Object.assign(s, tmp);
if (subscription.announcement) {
if (subscription.announcement !== sub.announcement) {
s.bannerClosed = false;
}
}
});
batch.push(update);
} catch (e) {
console.log(e);
}
if (messageRecord) {
batch.push(
messageRecord.prepareUpdate(() => {
Object.assign(messageRecord, lastMessage);
})
);
} else {
try {
const create = subCollection.prepareCreate((s) => {
s._raw = sanitizedRaw({ id: tmp.rid }, subCollection.schema);
Object.assign(s, tmp);
if (s.roomUpdatedAt) {
s.roomUpdatedAt = new Date();
}
});
batch.push(create);
} catch (e) {
console.log(e);
}
}
const { rooms } = store.getState().room;
if (tmp.lastMessage && !rooms.includes(tmp.rid)) {
const lastMessage = buildMessage(tmp.lastMessage);
const messagesCollection = db.collections.get('messages');
let messageRecord;
try {
messageRecord = await messagesCollection.find(lastMessage._id);
} catch (error) {
// Do nothing
}
if (messageRecord) {
batch.push(
messageRecord.prepareUpdate(() => {
Object.assign(messageRecord, lastMessage);
})
);
} else {
batch.push(
messagesCollection.prepareCreate((m) => {
m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema);
m.subscription.id = lastMessage.rid;
return Object.assign(m, lastMessage);
})
);
}
batch.push(
messagesCollection.prepareCreate((m) => {
m._raw = sanitizedRaw({ id: lastMessage._id }, messagesCollection.schema);
m.subscription.id = lastMessage.rid;
return Object.assign(m, lastMessage);
})
);
}
}
await db.action(async() => {
await db.batch(...batch);
});
} catch (e) {
@ -320,12 +344,25 @@ export default function subscribeRooms() {
if (/notification/.test(ev)) {
const [notification] = ddpMessage.fields.args;
try {
const { payload: { rid } } = notification;
const { payload: { rid, message, sender } } = notification;
const room = await RocketChat.getRoom(rid);
notification.title = RocketChat.getRoomTitle(room);
notification.avatar = RocketChat.getRoomAvatar(room);
// If it's from a encrypted room
if (message.t === E2E_MESSAGE_TYPE) {
// Decrypt this message content
const { msg } = await Encryption.decryptMessage({ ...message, rid });
// If it's a direct the content is the message decrypted
if (room.t === 'd') {
notification.text = msg;
// If it's a private group we should add the sender name
} else {
notification.text = `${ RocketChat.getSenderName(sender) }: ${ msg }`;
}
}
} catch (e) {
// do nothing
log(e);
}
EventEmitter.emit(INAPP_NOTIFICATION_EMITTER, notification);
}
@ -333,6 +370,14 @@ export default function subscribeRooms() {
const { type: eventType, ...args } = type;
handlePayloadUserInteraction(eventType, args);
}
if (/e2ekeyRequest/.test(ev)) {
const [roomId, keyId] = ddpMessage.fields.args;
try {
await Encryption.provideRoomKeyToUser(keyId, roomId);
} catch (e) {
log(e);
}
}
});
const stop = () => {

View File

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

View File

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

View File

@ -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') {

View File

@ -0,0 +1,19 @@
import { ENCRYPTION } from '../actions/actionsTypes';
const initialState = {
banner: null
};
export default function encryption(state = initialState, action) {
switch (action.type) {
case ENCRYPTION.SET_BANNER:
return {
...state,
banner: action.banner
};
case ENCRYPTION.INIT:
return initialState;
default:
return state;
}
}

View File

@ -17,6 +17,7 @@ import usersTyping from './usersTyping';
import inviteLinks from './inviteLinks';
import 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
});

View File

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

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

@ -0,0 +1,122 @@
import EJSON from 'ejson';
import { takeLatest, select, put } from 'redux-saga/effects';
import { ENCRYPTION } from '../actions/actionsTypes';
import { encryptionSetBanner } from '../actions/encryption';
import { Encryption } from '../lib/encryption';
import Navigation from '../lib/Navigation';
import {
E2E_PUBLIC_KEY,
E2E_PRIVATE_KEY,
E2E_BANNER_TYPE,
E2E_RANDOM_PASSWORD_KEY
} from '../lib/encryption/constants';
import database from '../lib/database';
import RocketChat from '../lib/rocketchat';
import UserPreferences from '../lib/userPreferences';
import { getUserSelector } from '../selectors/login';
import { showErrorAlert } from '../utils/info';
import I18n from '../i18n';
import log from '../utils/log';
const getServer = state => state.share.server || state.server.server;
const handleEncryptionInit = function* handleEncryptionInit() {
try {
const server = yield select(getServer);
const user = yield select(getUserSelector);
// Fetch server info to check E2E enable
const serversDB = database.servers;
const serversCollection = serversDB.collections.get('servers');
const serverInfo = yield serversCollection.find(server);
// If E2E is disabled on server, skip
if (!serverInfo?.E2E_Enable) {
return;
}
// Fetch stored private e2e key for this server
const storedPrivateKey = yield UserPreferences.getStringAsync(`${ server }-${ E2E_PRIVATE_KEY }`);
// Fetch server stored e2e keys
const keys = yield RocketChat.e2eFetchMyKeys();
// A private key was received from the server, but it's not saved locally yet
// Show the banner asking for the password
if (!storedPrivateKey && keys?.privateKey) {
yield put(encryptionSetBanner(E2E_BANNER_TYPE.REQUEST_PASSWORD));
return;
}
// If the user has a private key stored, but never entered the password
const storedRandomPassword = yield UserPreferences.getStringAsync(`${ server }-${ E2E_RANDOM_PASSWORD_KEY }`);
if (storedRandomPassword) {
yield put(encryptionSetBanner(E2E_BANNER_TYPE.SAVE_PASSWORD));
}
// Fetch stored public e2e key for this server
let storedPublicKey = yield UserPreferences.getStringAsync(`${ server }-${ E2E_PUBLIC_KEY }`);
// Prevent parse undefined
if (storedPublicKey) {
storedPublicKey = EJSON.parse(storedPublicKey);
}
if (storedPublicKey && storedPrivateKey) {
// Persist these keys
yield Encryption.persistKeys(server, storedPublicKey, storedPrivateKey);
} else {
// Create new keys since the user doesn't have any
yield Encryption.createKeys(user.id, server);
yield put(encryptionSetBanner(E2E_BANNER_TYPE.SAVE_PASSWORD));
}
// Decrypt all pending messages/subscriptions
Encryption.initialize();
} catch (e) {
log(e);
}
};
const handleEncryptionStop = function* handleEncryptionStop() {
// Hide encryption banner
yield put(encryptionSetBanner());
// Stop Encryption client
Encryption.stop();
};
const handleEncryptionDecodeKey = function* handleEncryptionDecodeKey({ password }) {
try {
const server = yield select(getServer);
const user = yield select(getUserSelector);
// Fetch server stored e2e keys
const keys = yield RocketChat.e2eFetchMyKeys();
const publicKey = EJSON.parse(keys?.publicKey);
// Decode the current server key
const privateKey = yield Encryption.decodePrivateKey(keys?.privateKey, password, user.id);
// Persist these decrypted keys
yield Encryption.persistKeys(server, publicKey, privateKey);
// Decrypt all pending messages/subscriptions
Encryption.initialize();
// Hide encryption banner
yield put(encryptionSetBanner());
Navigation.back();
} catch {
// Can't decrypt user private key
showErrorAlert(I18n.t('Encryption_error_desc'), I18n.t('Encryption_error_title'));
}
};
const root = function* root() {
yield takeLatest(ENCRYPTION.INIT, handleEncryptionInit);
yield takeLatest(ENCRYPTION.STOP, handleEncryptionStop);
yield takeLatest(ENCRYPTION.DECODE_KEY, handleEncryptionDecodeKey);
};
export default root;

View File

@ -10,6 +10,7 @@ import state from './state';
import deepLinking from './deepLinking';
import 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()
]);
};

View File

@ -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) {

View File

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

View File

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

View File

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

View File

@ -0,0 +1,144 @@
/* eslint-disable no-undef */
/* eslint-disable no-bitwise */
// https://github.com/beatgammit/base64-js/tree/master/test
import {
byteLength,
toByteArray,
fromByteArray
} from './index';
const map = (arr, callback) => {
const res = [];
let kValue;
let mappedValue;
for (let k = 0, len = arr.length; k < len; k += 1) {
if ((typeof arr === 'string' && !!arr.charAt(k))) {
kValue = arr.charAt(k);
mappedValue = callback(kValue, k, arr);
res[k] = mappedValue;
} else if (typeof arr !== 'string' && k in arr) {
kValue = arr[k];
mappedValue = callback(kValue, k, arr);
res[k] = mappedValue;
}
}
return res;
};
expect.extend({
toBeEqual(a, b) {
let i;
const { length } = a;
if (length !== b.length) {
return {
pass: false
};
}
for (i = 0; i < length; i += 1) {
if ((a[i] & 0xFF) !== (b[i] & 0xFF)) {
return {
pass: false
};
}
}
return {
pass: true
};
}
});
test('decode url-safe style base64 strings', () => {
const expected = [0xff, 0xff, 0xbe, 0xff, 0xef, 0xbf, 0xfb, 0xef, 0xff];
let str = '//++/++/++//';
let actual = toByteArray(str);
for (let i = 0; i < actual.length; i += 1) {
expect(actual[i]).toBe(expected[i]);
}
expect(byteLength(str)).toBe(actual.length);
str = '__--_--_--__';
actual = toByteArray(str);
for (let i = 0; i < actual.length; i += 1) {
expect(actual[i]).toBe(expected[i]);
}
expect(byteLength(str)).toBe(actual.length);
});
test('padding bytes found inside base64 string', () => {
// See https://github.com/beatgammit/base64-js/issues/42
const str = 'SQ==QU0=';
expect(toByteArray(str)).toEqual(new Uint8Array([73]));
expect(byteLength(str)).toBe(1);
});
const checks = [
'a',
'aa',
'aaa',
'hi',
'hi!',
'hi!!',
'sup',
'sup?',
'sup?!'
];
test('convert to base64 and back', () => {
for (let i = 0; i < checks.length; i += 1) {
const check = checks[i];
const b64Str = fromByteArray(map(check, char => char.charCodeAt(0)));
const arr = toByteArray(b64Str);
const str = map(arr, byte => String.fromCharCode(byte)).join('');
expect(check).toBe(str);
expect(byteLength(b64Str)).toBe(arr.length);
}
});
const data = [
[[0, 0, 0], 'AAAA'],
[[0, 0, 1], 'AAAB'],
[[0, 1, -1], 'AAH/'],
[[1, 1, 1], 'AQEB'],
[[0, -73, 23], 'ALcX']
];
test('convert known data to string', () => {
for (let i = 0; i < data.length; i += 1) {
const bytes = data[i][0];
const expected = data[i][1];
const actual = fromByteArray(bytes);
expect(actual).toBe(expected);
}
});
test('convert known data from string', () => {
for (let i = 0; i < data.length; i += 1) {
const expected = data[i][0];
const string = data[i][1];
const actual = toByteArray(string);
expect(actual).toBeEqual(expected);
const length = byteLength(string);
expect(length).toBe(expected.length);
}
});
test('convert big data to base64', () => {
let i;
let length;
const big = new Uint8Array(64 * 1024 * 1024);
for (i = 0, length = big.length; i < length; i += 1) {
big[i] = i % 256;
}
const b64str = fromByteArray(big);
const arr = toByteArray(b64str);
expect(arr).toBeEqual(big);
expect(byteLength(b64str)).toBe(arr.length);
});

View File

@ -0,0 +1,141 @@
/* eslint-disable no-bitwise */
// https://github.com/beatgammit/base64-js/blob/master/index.js
const lookup = [];
const revLookup = [];
const Arr = typeof Uint8Array !== 'undefined' ? Uint8Array : Array;
const code = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
for (let i = 0, len = code.length; i < len; i += 1) {
lookup[i] = code[i];
revLookup[code.charCodeAt(i)] = i;
}
// Support decoding URL-safe base64 strings, as Node.js does.
// See: https://en.wikipedia.org/wiki/Base64#URL_applications
revLookup['-'.charCodeAt(0)] = 62;
revLookup['_'.charCodeAt(0)] = 63;
const getLens = (b64) => {
const len = b64.length;
// We're encoding some strings not multiple of 4, so, disable this check
// if (len % 4 > 0) {
// throw new Error('Invalid string. Length must be a multiple of 4');
// }
// Trim off extra bytes after placeholder bytes are found
// See: https://github.com/beatgammit/base64-js/issues/42
let validLen = b64.indexOf('=');
if (validLen === -1) { validLen = len; }
const placeHoldersLen = validLen === len
? 0
: 4 - (validLen % 4);
return [validLen, placeHoldersLen];
};
// base64 is 4/3 + up to two characters of the original data
export const byteLength = (b64) => {
const lens = getLens(b64);
const validLen = lens[0];
const placeHoldersLen = lens[1];
return (((validLen + placeHoldersLen) * 3) / 4) - placeHoldersLen;
};
const _byteLength = (b64, validLen, placeHoldersLen) => (((validLen + placeHoldersLen) * 3) / 4) - placeHoldersLen;
export const toByteArray = (b64) => {
let tmp;
const lens = getLens(b64);
const validLen = lens[0];
const placeHoldersLen = lens[1];
const arr = new Arr(_byteLength(b64, validLen, placeHoldersLen));
let curByte = 0;
// if there are placeholders, only get up to the last complete 4 chars
const len = placeHoldersLen > 0
? validLen - 4
: validLen;
let i;
for (i = 0; i < len; i += 4) {
tmp = (revLookup[b64.charCodeAt(i)] << 18)
| (revLookup[b64.charCodeAt(i + 1)] << 12)
| (revLookup[b64.charCodeAt(i + 2)] << 6)
| revLookup[b64.charCodeAt(i + 3)];
arr[curByte] = (tmp >> 16) & 0xFF;
curByte += 1;
arr[curByte] = (tmp >> 8) & 0xFF;
curByte += 1;
arr[curByte] = tmp & 0xFF;
curByte += 1;
}
if (placeHoldersLen === 2) {
tmp = (revLookup[b64.charCodeAt(i)] << 2)
| (revLookup[b64.charCodeAt(i + 1)] >> 4);
arr[curByte] = tmp & 0xFF;
curByte += 1;
}
if (placeHoldersLen === 1) {
tmp = (revLookup[b64.charCodeAt(i)] << 10)
| (revLookup[b64.charCodeAt(i + 1)] << 4)
| (revLookup[b64.charCodeAt(i + 2)] >> 2);
arr[curByte] = (tmp >> 8) & 0xFF;
curByte += 1;
arr[curByte] = tmp & 0xFF;
curByte += 1;
}
return arr;
};
const tripletToBase64 = num => lookup[(num >> 18) & 0x3F]
+ lookup[(num >> 12) & 0x3F]
+ lookup[(num >> 6) & 0x3F]
+ lookup[num & 0x3F];
const encodeChunk = (uint8, start, end) => {
let tmp;
const output = [];
for (let i = start; i < end; i += 3) {
tmp = ((uint8[i] << 16) & 0xFF0000) + ((uint8[i + 1] << 8) & 0xFF00) + (uint8[i + 2] & 0xFF);
output.push(tripletToBase64(tmp));
}
return output.join('');
};
export const fromByteArray = (uint8) => {
let tmp;
const len = uint8.length;
const extraBytes = len % 3; // if we have 1 byte left, pad 2 bytes
const parts = [];
const maxChunkLength = 16383; // must be multiple of 3
// go through the array every three bytes, we'll deal with trailing stuff later
for (let i = 0, len2 = len - extraBytes; i < len2; i += maxChunkLength) {
parts.push(encodeChunk(
uint8, i, (i + maxChunkLength) > len2 ? len2 : (i + maxChunkLength)
));
}
// pad the end with zeros, but make sure to not forget the extra bytes
if (extraBytes === 1) {
tmp = uint8[len - 1];
parts.push(
`${ lookup[tmp >> 2] + lookup[(tmp << 4) & 0x3F] }==`
);
} else if (extraBytes === 2) {
tmp = (uint8[len - 2] << 8) + uint8[len - 1];
parts.push(
`${ lookup[tmp >> 10] + lookup[(tmp >> 4) & 0x3F] + lookup[(tmp << 2) & 0x3F] }=`
);
}
return parts.join('');
};

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

@ -0,0 +1,14 @@
// https://developer.mozilla.org/en-US/docs/Mozilla/JavaScript_code_modules/Promise.jsm/Deferred
export default class Deferred {
constructor() {
const promise = new Promise((resolve, reject) => {
this.resolve = resolve;
this.reject = reject;
});
promise.resolve = this.resolve;
promise.reject = this.reject;
return promise;
}
}

View File

@ -58,6 +58,7 @@ export default {
RL_ADD_SERVER: 'rl_add_server',
RL_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'
};

View File

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

View File

@ -0,0 +1,98 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Text, StyleSheet, ScrollView } from 'react-native';
import { connect } from 'react-redux';
import I18n from '../i18n';
import sharedStyles from './Styles';
import { withTheme } from '../theme';
import Button from '../containers/Button';
import { themes } from '../constants/colors';
import TextInput from '../containers/TextInput';
import SafeAreaView from '../containers/SafeAreaView';
import { CloseModalButton } from '../containers/HeaderButton';
import { encryptionDecodeKey as encryptionDecodeKeyAction } from '../actions/encryption';
import scrollPersistTaps from '../utils/scrollPersistTaps';
import KeyboardView from '../presentation/KeyboardView';
import StatusBar from '../containers/StatusBar';
import { logEvent, events } from '../utils/log';
const styles = StyleSheet.create({
container: {
padding: 28
},
info: {
fontSize: 14,
marginVertical: 8,
...sharedStyles.textRegular
}
});
class E2EEnterYourPasswordView extends React.Component {
static navigationOptions = ({ navigation }) => ({
headerLeft: () => <CloseModalButton navigation={navigation} testID='e2e-enter-your-password-view-close' />,
title: I18n.t('Enter_Your_E2E_Password')
})
static propTypes = {
encryptionDecodeKey: PropTypes.func,
theme: PropTypes.string
}
constructor(props) {
super(props);
this.state = {
password: ''
};
}
submit = () => {
logEvent(events.E2E_ENTER_PW_SUBMIT);
const { password } = this.state;
const { encryptionDecodeKey } = this.props;
encryptionDecodeKey(password);
}
render() {
const { password } = this.state;
const { theme } = this.props;
return (
<KeyboardView
style={{ backgroundColor: themes[theme].backgroundColor }}
contentContainerStyle={sharedStyles.container}
keyboardVerticalOffset={128}
>
<StatusBar theme={theme} />
<ScrollView {...scrollPersistTaps} style={sharedStyles.container} contentContainerStyle={[sharedStyles.containerScrollView, styles.scrollView]}>
<SafeAreaView theme={theme} style={[styles.container, { backgroundColor: themes[theme].backgroundColor }]}>
<TextInput
inputRef={(e) => { this.passwordInput = e; }}
placeholder={I18n.t('Password')}
returnKeyType='send'
secureTextEntry
onSubmitEditing={this.submit}
onChangeText={value => this.setState({ password: value })}
testID='e2e-enter-your-password-view-password'
textContentType='password'
autoCompleteType='password'
theme={theme}
/>
<Button
onPress={this.submit}
title={I18n.t('Confirm')}
disabled={!password}
theme={theme}
/>
<Text style={[styles.info, { color: themes[theme].bodyText }]}>{I18n.t('Enter_Your_Encryption_Password_desc1')}</Text>
<Text style={[styles.info, { color: themes[theme].bodyText }]}>{I18n.t('Enter_Your_Encryption_Password_desc2')}</Text>
</SafeAreaView>
</ScrollView>
</KeyboardView>
);
}
}
const mapDispatchToProps = dispatch => ({
encryptionDecodeKey: password => dispatch(encryptionDecodeKeyAction(password))
});
export default connect(null, mapDispatchToProps)(withTheme(E2EEnterYourPasswordView));

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import React from 'react';
import 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 => ({

View File

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

View File

@ -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 => ({

View File

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

View File

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

View File

@ -2,6 +2,7 @@ import React from 'react';
import PropTypes from 'prop-types';
import 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
};

View File

@ -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 => ({

View File

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

View File

@ -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) {
this.setState({
messages: result.messages || [],
loading: false
});
}
const messages = await this.searchMessages(searchText);
this.setState({
messages: messages || [],
loading: false
});
} catch (e) {
this.setState({ loading: false });
log(e);

View File

@ -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', () => {

View File

@ -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', () => {

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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