diff --git a/.meteor/packages b/.meteor/packages
index c8c44738a787..b1fccebabf03 100644
--- a/.meteor/packages
+++ b/.meteor/packages
@@ -190,6 +190,7 @@ steffo:meteor-accounts-saml
todda00:friendly-slugs
yasaricli:slugify
yasinuslu:blaze-meta
+rocketchat:e2e
rocketchat:blockstack
rocketchat:version-check
diff --git a/.meteor/versions b/.meteor/versions
index 79e9480cf978..9a11dd0ee272 100644
--- a/.meteor/versions
+++ b/.meteor/versions
@@ -146,6 +146,7 @@ rocketchat:custom-oauth@1.0.0
rocketchat:custom-sounds@1.0.0
rocketchat:dolphin@0.0.2
rocketchat:drupal@0.0.1
+rocketchat:e2e@0.0.1
rocketchat:emoji@1.0.0
rocketchat:emoji-custom@1.0.0
rocketchat:emoji-emojione@0.0.1
diff --git a/package.json b/package.json
index db211abd1afe..0f2f59825e41 100644
--- a/package.json
+++ b/package.json
@@ -131,6 +131,7 @@
"bugsnag": "^2.4.0",
"bunyan": "^1.8.12",
"busboy": "^0.2.14",
+ "bytebuffer": "5.0.1",
"cas": "https://github.com/kcbanner/node-cas/tarball/fcd27dad333223b3b75a048bce27973fb3ca0f62",
"chart.js": "^2.7.2",
"clipboard": "^2.0.1",
diff --git a/packages/rocketchat-api/server/v1/e2e.js b/packages/rocketchat-api/server/v1/e2e.js
new file mode 100644
index 000000000000..8efdbec00ce5
--- /dev/null
+++ b/packages/rocketchat-api/server/v1/e2e.js
@@ -0,0 +1,46 @@
+RocketChat.API.v1.addRoute('e2e.fetchKeychain', { authRequired: true }, {
+ get() {
+ const { uid } = this.queryParams;
+
+ let result;
+ Meteor.runAsUser(this.userId, () => result = Meteor.call('fetchKeychain', uid));
+
+ return RocketChat.API.v1.success(result);
+ },
+});
+
+RocketChat.API.v1.addRoute('e2e.fetchMyKeys', { authRequired: true }, {
+ get() {
+ let result;
+ Meteor.runAsUser(this.userId, () => result = Meteor.call('fetchMyKeys'));
+
+ return RocketChat.API.v1.success(result);
+ },
+});
+
+RocketChat.API.v1.addRoute('e2e.addKeyToChain', { authRequired: true }, {
+ post() {
+ const { RSAPubKey, RSAEPrivKey } = this.bodyParams;
+
+ Meteor.runAsUser(this.userId, () => {
+ RocketChat.API.v1.success(Meteor.call('addKeyToChain', {
+ public_key: RSAPubKey,
+ private_key: RSAEPrivKey,
+ }));
+ });
+
+ return RocketChat.API.v1.success();
+ },
+});
+
+RocketChat.API.v1.addRoute('e2e.updateGroupE2EKey', { authRequired: true }, {
+ post() {
+ const { uid, rid, key } = this.bodyParams;
+
+ Meteor.runAsUser(this.userId, () => {
+ RocketChat.API.v1.success(Meteor.call('updateGroupE2EKey', rid, uid, key));
+ });
+
+ return RocketChat.API.v1.success();
+ },
+});
diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.html b/packages/rocketchat-channel-settings/client/views/channelSettings.html
index c8d13749a838..68e86137b573 100644
--- a/packages/rocketchat-channel-settings/client/views/channelSettings.html
+++ b/packages/rocketchat-channel-settings/client/views/channelSettings.html
@@ -181,6 +181,24 @@
{{/if}}
{{/with}}
+ {{#with settings.encrypted}}
+ {{#if canView}}
+
diff --git a/packages/rocketchat-channel-settings/client/views/channelSettings.js b/packages/rocketchat-channel-settings/client/views/channelSettings.js
index 1512aa25ccf6..b12fb98e067d 100644
--- a/packages/rocketchat-channel-settings/client/views/channelSettings.js
+++ b/packages/rocketchat-channel-settings/client/views/channelSettings.js
@@ -624,6 +624,25 @@ Template.channelSettingsEditing.onCreated(function() {
);
},
},
+ encrypted: {
+ type: 'boolean',
+ label: 'Encrypted',
+ isToggle: true,
+ processing: new ReactiveVar(false),
+ canView() {
+ return RocketChat.roomTypes.roomTypes[room.t].allowRoomSettingChange(room, RoomSettingsEnum.E2E);
+ },
+ canEdit() {
+ return RocketChat.authz.hasAllPermission('edit-room', room._id);
+ },
+ save(value) {
+ return call('saveRoomSettings', room._id, 'encrypted', value).then(() => {
+ toastr.success(
+ t('Encrypted_setting_changed_successfully')
+ );
+ });
+ },
+ },
};
Object.keys(this.settings).forEach((key) => {
const setting = this.settings[key];
diff --git a/packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js b/packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
index 283c3610626c..adb44bcc21ef 100644
--- a/packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
+++ b/packages/rocketchat-channel-settings/server/methods/saveRoomSettings.js
@@ -1,4 +1,4 @@
-const fields = ['roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionOverrideGlobal'];
+const fields = ['roomName', 'roomTopic', 'roomAnnouncement', 'roomCustomFields', 'roomDescription', 'roomType', 'readOnly', 'reactWhenReadOnly', 'systemMessages', 'default', 'joinCode', 'tokenpass', 'streamingOptions', 'retentionEnabled', 'retentionMaxAge', 'retentionExcludePinned', 'retentionFilesOnly', 'retentionOverrideGlobal', 'encrypted'];
Meteor.methods({
saveRoomSettings(rid, settings, value) {
const userId = Meteor.userId();
@@ -72,6 +72,12 @@ Meteor.methods({
action: 'Change_Room_Type',
});
}
+ if (setting === 'encrypted' && value !== room.encrypted && (room.t !== 'd' && room.t !== 'p')) {
+ throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', {
+ method: 'saveRoomSettings',
+ action: 'Change_Room_Encrypted',
+ });
+ }
if (setting === 'retentionEnabled' && !RocketChat.authz.hasPermission(userId, 'edit-room-retention-policy', rid) && value !== room.retention.enabled) {
throw new Meteor.Error('error-action-not-allowed', 'Editing room retention policy is not allowed', {
@@ -184,6 +190,9 @@ Meteor.methods({
case 'retentionOverrideGlobal':
RocketChat.models.Rooms.saveRetentionOverrideGlobalById(rid, value);
break;
+ case 'encrypted':
+ RocketChat.models.Rooms.saveEncryptedById(rid, value);
+ break;
}
});
diff --git a/packages/rocketchat-e2e/.eslintrc b/packages/rocketchat-e2e/.eslintrc
new file mode 100644
index 000000000000..625a0007ef14
--- /dev/null
+++ b/packages/rocketchat-e2e/.eslintrc
@@ -0,0 +1,4 @@
+{
+ "extends": ["@rocket.chat/eslint-config"],
+ "root": true
+}
diff --git a/packages/rocketchat-e2e/client/helper.js b/packages/rocketchat-e2e/client/helper.js
new file mode 100644
index 000000000000..96dbd1de96dd
--- /dev/null
+++ b/packages/rocketchat-e2e/client/helper.js
@@ -0,0 +1,117 @@
+/* eslint-disable new-cap, no-proto */
+
+import ByteBuffer from 'bytebuffer';
+
+const StaticArrayBufferProto = new ArrayBuffer().__proto__;
+
+export function toString(thing) {
+ if (typeof thing === 'string') {
+ return thing;
+ }
+ return new ByteBuffer.wrap(thing).toString('binary');
+}
+
+export function toArrayBuffer(thing) {
+ if (thing === undefined) {
+ return undefined;
+ }
+ if (thing === Object(thing)) {
+ if (thing.__proto__ === StaticArrayBufferProto) {
+ return thing;
+ }
+ }
+
+ if (typeof thing !== 'string') {
+ throw new Error(`Tried to convert a non-string of type ${ typeof thing } to an array buffer`);
+ }
+ return new ByteBuffer.wrap(thing, 'binary').toArrayBuffer();
+}
+
+export function joinVectorAndEcryptedData(vector, encryptedData) {
+ const cipherText = new Uint8Array(encryptedData);
+ const output = new Uint8Array(vector.length + cipherText.length);
+ output.set(vector, 0);
+ output.set(cipherText, vector.length);
+ return output;
+}
+
+export function splitVectorAndEcryptedData(cipherText) {
+ const vector = cipherText.slice(0, 16);
+ const encryptedData = cipherText.slice(16);
+
+ return [vector, encryptedData];
+}
+
+export async function encryptRSA(key, data) {
+ return await crypto.subtle.encrypt({ name: 'RSA-OAEP' }, key, data);
+}
+
+export async function encryptAES(vector, key, data) {
+ return await crypto.subtle.encrypt({ name: 'AES-CBC', iv: vector }, key, data);
+}
+
+export async function decryptRSA(key, data) {
+ return await crypto.subtle.decrypt({ name: 'RSA-OAEP' }, key, data);
+}
+
+export async function decryptAES(vector, key, data) {
+ return await crypto.subtle.decrypt({ name: 'AES-CBC', iv: vector }, key, data);
+}
+
+export async function generateAESKey() {
+ return await crypto.subtle.generateKey({ name: 'AES-CBC', length: 128 }, true, ['encrypt', 'decrypt']);
+}
+
+export async function generateRSAKey() {
+ return await crypto.subtle.generateKey({ name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: { name: 'SHA-256' } }, true, ['encrypt', 'decrypt']);
+}
+
+export async function exportJWKKey(key) {
+ return await crypto.subtle.exportKey('jwk', key);
+}
+
+export async function importRSAKey(keyData, keyUsages = ['encrypt', 'decrypt']) {
+ return await crypto.subtle.importKey('jwk', keyData, { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([0x01, 0x00, 0x01]), hash: { name: 'SHA-256' } }, true, keyUsages);
+}
+
+export async function importAESKey(keyData, keyUsages = ['encrypt', 'decrypt']) {
+ return await crypto.subtle.importKey('jwk', keyData, { name: 'AES-CBC' }, true, keyUsages);
+}
+
+export async function importRawKey(keyData, keyUsages = ['deriveKey']) {
+ return await crypto.subtle.importKey('raw', keyData, { name: 'PBKDF2' }, false, keyUsages);
+}
+
+export async function deriveKey(salt, baseKey, keyUsages = ['encrypt', 'decrypt']) {
+ const iterations = 1000;
+ const hash = 'SHA-256';
+
+ return await crypto.subtle.deriveKey({ name: 'PBKDF2', salt, iterations, hash }, baseKey, { name: 'AES-CBC', length: 256 }, true, keyUsages);
+}
+
+export async function readFileAsArrayBuffer(file) {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = function(evt) {
+ resolve(evt.target.result);
+ };
+ reader.onerror = function(evt) {
+ reject(evt);
+ };
+ reader.readAsArrayBuffer(file);
+ });
+}
+
+export class Deferred {
+ constructor() {
+ const p = new Promise((resolve, reject) => {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+
+ p.resolve = this.resolve;
+ p.reject = this.reject;
+
+ return p;
+ }
+}
diff --git a/packages/rocketchat-e2e/client/rocketchat.e2e.js b/packages/rocketchat-e2e/client/rocketchat.e2e.js
new file mode 100644
index 000000000000..e47c59524478
--- /dev/null
+++ b/packages/rocketchat-e2e/client/rocketchat.e2e.js
@@ -0,0 +1,380 @@
+/* globals alerts, modal, ChatMessage */
+
+import _ from 'underscore';
+
+import './stylesheets/e2e';
+
+import { Meteor } from 'meteor/meteor';
+import { Random } from 'meteor/random';
+import { ReactiveVar } from 'meteor/reactive-var';
+import { Tracker } from 'meteor/tracker';
+import { EJSON } from 'meteor/ejson';
+
+import { RocketChat, call } from 'meteor/rocketchat:lib';
+import { TAPi18n } from 'meteor/tap:i18n';
+import { E2ERoom } from './rocketchat.e2e.room';
+import {
+ Deferred,
+ toString,
+ toArrayBuffer,
+ joinVectorAndEcryptedData,
+ splitVectorAndEcryptedData,
+ encryptAES,
+ decryptAES,
+ generateRSAKey,
+ exportJWKKey,
+ importRSAKey,
+ importRawKey,
+ deriveKey,
+} from './helper';
+
+class E2E {
+ constructor() {
+ this.started = false;
+ this.enabled = new ReactiveVar(false);
+ this._ready = new ReactiveVar(false);
+ this.instancesByRoomId = {};
+ this.readyPromise = new Deferred();
+ this.readyPromise.then(() => {
+ this._ready.set(true);
+ });
+
+ this.decryptPendingMessagesDeferred = _.debounce(this.decryptPendingMessages.bind(this), 100);
+ }
+
+ isEnabled() {
+ return this.enabled.get();
+ }
+
+ isReady() {
+ return this.enabled.get() && this._ready.get();
+ }
+
+ async ready() {
+ return this.readyPromise;
+ }
+
+ async getInstanceByRoomId(roomId) {
+ if (!this.enabled.get()) {
+ return;
+ }
+
+ const room = RocketChat.models.Rooms.findOne({
+ _id: roomId,
+ });
+
+ if (room.encrypted !== true) {
+ return;
+ }
+
+ if (!this.instancesByRoomId[roomId]) {
+ const subscription = RocketChat.models.Subscriptions.findOne({
+ rid: roomId,
+ });
+
+ if (!subscription || (subscription.t !== 'd' && subscription.t !== 'p')) {
+ return;
+ }
+
+ this.instancesByRoomId[roomId] = new E2ERoom(Meteor.userId(), roomId, subscription.t);
+ }
+
+ const e2eRoom = this.instancesByRoomId[roomId];
+
+ await this.ready();
+
+ if (e2eRoom) {
+ await e2eRoom.handshake();
+ return e2eRoom;
+ }
+ }
+
+ async startClient() {
+ if (this.started) {
+ return;
+ }
+
+ this.started = true;
+ let public_key = localStorage.getItem('public_key');
+ let private_key = localStorage.getItem('private_key');
+
+ await this.loadKeysFromDB();
+
+ if (!public_key && this.db_public_key) {
+ public_key = this.db_public_key;
+ }
+
+ if (!private_key && this.db_private_key) {
+ try {
+ private_key = await this.decodePrivateKey(this.db_private_key);
+ } catch (error) {
+ this.started = false;
+ alerts.open({
+ title: TAPi18n.__('Wasn\'t possible to decode you encryption key to be imported.'),
+ html: '
Your encryption password seems wrong. Click here to try again.
',
+ modifiers: ['large', 'danger'],
+ closable: true,
+ icon: 'key',
+ action: () => {
+ this.startClient();
+ alerts.close();
+ },
+ });
+ return;
+ }
+ }
+
+ if (public_key && private_key) {
+ await this.loadKeys({ public_key, private_key });
+ } else {
+ await this.createAndLoadKeys();
+ }
+
+ // TODO: Split in 2 methods to persist keys
+ if (!this.db_public_key || !this.db_private_key) {
+ await call('addKeyToChain', {
+ public_key: localStorage.getItem('public_key'),
+ private_key: await this.encodePrivateKey(localStorage.getItem('private_key')),
+ });
+ }
+
+ const randomPassword = localStorage.getItem('e2e.randomPassword');
+ if (randomPassword) {
+ alerts.open({
+ title: TAPi18n.__('Save your encryption password'),
+ html: `
${ randomPassword } This password will show up only this time. Click here to know more about it.
`,
+ modifiers: ['large'],
+ closable: false,
+ icon: 'key',
+ action() {
+ modal.open({
+ title: TAPi18n.__('Save your encryption password'),
+ html: true,
+ text: `
+
+ Now you can create encrypted private groups or change your direct messages to be encrypted. This is a end to end encryption so the key to encode/decode your messages will not be saved in our savers, for that reason you need to save this password to be able to transfer your key from this client to your mobile phone or to another browser.
+
+
+ Your password is: ${ randomPassword }
+
+
+ This is a auto generated password and you can setup a new password for your encryption key any time from any browser that already did receive your key.
+
+ This password is stored on this browser only while you don't copy it and click to dismiss this message.
+
+ `,
+ showConfirmButton: true,
+ showCancelButton: true,
+ confirmButtonText: TAPi18n.__('I saved my password, close this message'),
+ cancelButtonText: TAPi18n.__('I\'ll do it later'),
+ }, (confirm) => {
+ if (!confirm) {
+ return;
+ }
+ localStorage.removeItem('e2e.randomPassword');
+ alerts.close();
+ });
+ },
+ });
+ }
+
+ this.readyPromise.resolve();
+
+ this.setupListener();
+
+ this.decryptPendingMessages();
+ }
+
+ setupListener() {
+ RocketChat.Notifications.onUser('e2ekeyRequest', async(roomId, keyId) => {
+ const e2eRoom = await this.getInstanceByRoomId(roomId);
+ if (!e2eRoom) {
+ return;
+ }
+
+ e2eRoom.provideKeyToUser(keyId);
+ });
+ }
+
+ async loadKeysFromDB() {
+ try {
+ const { public_key, private_key } = await call('fetchMyKeys');
+ this.db_public_key = public_key;
+ this.db_private_key = private_key;
+ } catch (error) {
+ return console.error('E2E -> Error fetching RSA keys: ', error);
+ }
+ }
+
+ async loadKeys({ public_key, private_key }) {
+ localStorage.setItem('public_key', public_key);
+
+ try {
+ this.privateKey = await importRSAKey(EJSON.parse(private_key), ['decrypt']);
+
+ localStorage.setItem('private_key', private_key);
+ } catch (error) {
+ return console.error('E2E -> Error importing private key: ', error);
+ }
+ }
+
+ async createAndLoadKeys() {
+ // Could not obtain public-private keypair from server.
+ let key;
+ try {
+ key = await generateRSAKey();
+ this.privateKey = key.privateKey;
+ } catch (error) {
+ return console.error('E2E -> Error generating key: ', error);
+ }
+
+ try {
+ const publicKey = await exportJWKKey(key.publicKey);
+
+ localStorage.setItem('public_key', JSON.stringify(publicKey));
+ } catch (error) {
+ return console.error('E2E -> Error exporting public key: ', error);
+ }
+
+ try {
+ const privateKey = await exportJWKKey(key.privateKey);
+
+ localStorage.setItem('private_key', JSON.stringify(privateKey));
+ } catch (error) {
+ return console.error('E2E -> Error exporting private key: ', error);
+ }
+ }
+
+ async encodePrivateKey(private_key) {
+ const randomPassword = `${ Random.id(3) }-${ Random.id(3) }-${ Random.id(3) }`.toLowerCase();
+ localStorage.setItem('e2e.randomPassword', randomPassword);
+
+ const masterKey = await this.getMasterKey(randomPassword);
+
+ const vector = crypto.getRandomValues(new Uint8Array(16));
+ try {
+ const encodedPrivateKey = await encryptAES(vector, masterKey, toArrayBuffer(private_key));
+
+ return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey));
+ } catch (error) {
+ return console.error('E2E -> Error encrypting encodedPrivateKey: ', error);
+ }
+ }
+
+ async getMasterKey(password) {
+ if (password == null) {
+ alert('You should provide a password');
+ }
+
+ // First, create a PBKDF2 "key" containing the password
+ let baseKey;
+ try {
+ baseKey = await importRawKey(toArrayBuffer(password));
+ } catch (error) {
+ return console.error('E2E -> Error creating a key based on user password: ', error);
+ }
+
+ // Derive a key from the password
+ try {
+ return await deriveKey(toArrayBuffer(Meteor.userId()), baseKey);
+ } catch (error) {
+ return console.error('E2E -> Error deriving baseKey: ', error);
+ }
+ }
+
+ async requestPassword() {
+ return new Promise((resolve) => {
+ modal.open({
+ title: TAPi18n.__('E2E password'),
+ text: TAPi18n.__('Enter E2E password to decode your key'),
+ type: 'input',
+ inputType: 'text',
+ showCancelButton: true,
+ closeOnConfirm: true,
+ confirmButtonText: TAPi18n.__('Decode'),
+ cancelButtonText: TAPi18n.__('Later'),
+ }, (password) => {
+ resolve(password);
+ });
+ });
+ }
+
+ async decodePrivateKey(private_key) {
+ const password = await this.requestPassword();
+
+ const masterKey = await this.getMasterKey(password);
+
+ const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(private_key));
+
+ try {
+ const privKey = await decryptAES(vector, masterKey, cipherText);
+ return toString(privKey);
+ } catch (error) {
+ throw new Error('E2E -> Error decrypting private key');
+ }
+ }
+
+ async decryptPendingMessages() {
+ if (!this.isEnabled()) {
+ return;
+ }
+
+ return await ChatMessage.find({ t: 'e2e', e2e: 'pending' }).forEach(async(item) => {
+ const e2eRoom = await this.getInstanceByRoomId(item.rid);
+
+ if (!e2eRoom) {
+ return;
+ }
+
+ const data = await e2eRoom.decrypt(item.msg);
+ if (!data) {
+ return;
+ }
+
+ item.msg = data.text;
+ item.ack = data.ack;
+ if (data.ts) {
+ item.ts = data.ts;
+ }
+ item.e2e = 'done';
+ ChatMessage.upsert({ _id: item._id }, item);
+ });
+ }
+}
+
+export const e2e = new E2E();
+
+Meteor.startup(function() {
+ Tracker.autorun(function() {
+ if (Meteor.userId()) {
+ if (RocketChat.settings.get('E2E_Enable') && window.crypto) {
+ e2e.startClient();
+ e2e.enabled.set(true);
+ } else {
+ e2e.enabled.set(false);
+ }
+ }
+ });
+
+ // Encrypt messages before sending
+ RocketChat.promises.add('onClientBeforeSendMessage', async function(message) {
+ if (!message.rid) {
+ return Promise.resolve(message);
+ }
+
+ const e2eRoom = await e2e.getInstanceByRoomId(message.rid);
+ if (!e2eRoom) {
+ return Promise.resolve(message);
+ }
+
+ // Should encrypt this message.
+ return e2eRoom
+ .encrypt(message)
+ .then((msg) => {
+ message.msg = msg;
+ message.t = 'e2e';
+ message.e2e = 'pending';
+ return message;
+ });
+ }, RocketChat.promises.priority.HIGH);
+});
diff --git a/packages/rocketchat-e2e/client/rocketchat.e2e.room.js b/packages/rocketchat-e2e/client/rocketchat.e2e.room.js
new file mode 100644
index 000000000000..8cae0ad2918b
--- /dev/null
+++ b/packages/rocketchat-e2e/client/rocketchat.e2e.room.js
@@ -0,0 +1,301 @@
+import _ from 'underscore';
+
+import { Base64 } from 'meteor/base64';
+import { ReactiveVar } from 'meteor/reactive-var';
+import { EJSON } from 'meteor/ejson';
+import { Random } from 'meteor/random';
+import { TimeSync } from 'meteor/mizzao:timesync';
+
+import { RocketChat, call } from 'meteor/rocketchat:lib';
+import { e2e } from './rocketchat.e2e';
+import {
+ Deferred,
+ toString,
+ toArrayBuffer,
+ joinVectorAndEcryptedData,
+ splitVectorAndEcryptedData,
+ encryptRSA,
+ encryptAES,
+ decryptRSA,
+ decryptAES,
+ generateAESKey,
+ exportJWKKey,
+ importAESKey,
+ importRSAKey,
+ readFileAsArrayBuffer,
+} from './helper';
+
+export class E2ERoom {
+ constructor(userId, roomId, t) {
+ this.userId = userId;
+ this.roomId = roomId;
+ this.typeOfRoom = t;
+ this.establishing = new ReactiveVar(false);
+
+ this._ready = new ReactiveVar(false);
+ this.readyPromise = new Deferred();
+ this.readyPromise.then(() => {
+ this._ready.set(true);
+ this.establishing.set(false);
+
+ RocketChat.Notifications.onRoom(this.roomId, 'e2ekeyRequest', async(keyId) => {
+ this.provideKeyToUser(keyId);
+ });
+ });
+ }
+
+ // Initiates E2E Encryption
+ async handshake() {
+ if (!e2e.isReady()) {
+ return;
+ }
+
+ if (this._ready.get()) {
+ return;
+ }
+
+ if (this.establishing.get()) {
+ return await this.readyPromise;
+ }
+
+ console.log('E2E -> Initiating handshake');
+
+ this.establishing.set(true);
+
+ // Cover private groups and direct messages
+ if (!this.isSupportedRoomType(this.typeOfRoom)) {
+ return;
+ }
+
+ // Fetch encrypted session key from subscription model
+ let groupKey;
+ try {
+ groupKey = RocketChat.models.Subscriptions.findOne({ rid: this.roomId }).E2EKey;
+ } catch (error) {
+ return console.error('E2E -> Error fetching group key: ', error);
+ }
+
+ if (groupKey) {
+ await this.importGroupKey(groupKey);
+ this.readyPromise.resolve();
+ return true;
+ }
+
+ const room = RocketChat.models.Rooms.findOne({ _id: this.roomId });
+
+ if (!room.e2eKeyId) {
+ await this.createGroupKey();
+ this.readyPromise.resolve();
+ return true;
+ }
+
+ console.log('E2E -> Requesting room key');
+ // TODO: request group key
+
+ RocketChat.Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId);
+ }
+
+ isSupportedRoomType(type) {
+ return ['d', 'p'].includes(type);
+ }
+
+ async importGroupKey(groupKey) {
+ console.log('E2E -> Importing room key');
+ // Get existing group key
+ // const keyID = groupKey.slice(0, 12);
+ groupKey = groupKey.slice(12);
+ groupKey = Base64.decode(groupKey);
+
+ // Decrypt obtained encrypted session key
+ try {
+ const decryptedKey = await decryptRSA(e2e.privateKey, groupKey);
+ this.sessionKeyExportedString = toString(decryptedKey);
+ } catch (error) {
+ return console.error('E2E -> Error decrypting group key: ', error);
+ }
+
+ this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12);
+
+ // Import session key for use.
+ try {
+ const key = await importAESKey(JSON.parse(this.sessionKeyExportedString));
+ // Key has been obtained. E2E is now in session.
+ this.groupSessionKey = key;
+ } catch (error) {
+ return console.error('E2E -> Error importing group key: ', error);
+ }
+ }
+
+ async createGroupKey() {
+ console.log('E2E -> Creating room key');
+ // Create group key
+ let key;
+ try {
+ key = await generateAESKey();
+ this.groupSessionKey = key;
+ } catch (error) {
+ return console.error('E2E -> Error generating group key: ', error);
+ }
+
+ let sessionKeyExported;
+ try {
+ sessionKeyExported = await exportJWKKey(this.groupSessionKey);
+ } catch (error) {
+ return console.error('E2E -> Error exporting group key: ', error);
+ }
+
+ this.sessionKeyExportedString = JSON.stringify(sessionKeyExported);
+ this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12);
+
+ await call('e2e.setRoomKeyID', this.roomId, this.keyID);
+
+ await this.encryptKeyForOtherParticipants();
+ }
+
+ async encryptKeyForOtherParticipants() {
+ // Encrypt generated session key for every user in room and publish to subscription model.
+ let users;
+ try {
+ users = await call('e2e.getUsersOfRoomWithoutKey', this.roomId);
+ } catch (error) {
+ return console.error('E2E -> Error getting room users: ', error);
+ }
+
+ users.users.forEach((user) => this.encryptForParticipand(user));
+ }
+
+ async encryptForParticipand(user) {
+ if (user.e2e.public_key) {
+ let userKey;
+ try {
+ userKey = await importRSAKey(JSON.parse(user.e2e.public_key), ['encrypt']);
+ } catch (error) {
+ return console.error('E2E -> Error importing user key: ', error);
+ }
+ // const vector = crypto.getRandomValues(new Uint8Array(16));
+
+ // Encrypt session key for this user with his/her public key
+ let encryptedUserKey;
+ try {
+ encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString));
+ } catch (error) {
+ return console.error('E2E -> Error encrypting user key: ', error);
+ }
+
+ // Key has been encrypted. Publish to that user's subscription model for this room.
+ await call('updateGroupE2EKey', this.roomId, user._id, this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)));
+ }
+ }
+
+ // Encrypts files before upload. I/O is in arraybuffers.
+ async encryptFile(file) {
+ if (!this.isSupportedRoomType(this.typeOfRoom)) {
+ return;
+ }
+
+ const fileArrayBuffer = await readFileAsArrayBuffer(file);
+
+ const vector = crypto.getRandomValues(new Uint8Array(16));
+ let result;
+ try {
+ result = await encryptAES(vector, this.groupSessionKey, fileArrayBuffer);
+ } catch (error) {
+ return console.error('E2E -> Error encrypting group key: ', error);
+ }
+
+ const output = joinVectorAndEcryptedData(vector, result);
+
+ const encryptedFile = new File([toArrayBuffer(EJSON.stringify(output))], file.name);
+
+ return encryptedFile;
+ }
+
+ // Decrypt uploaded encrypted files. I/O is in arraybuffers.
+ async decryptFile(message) {
+ if (message[0] !== '{') {
+ return;
+ }
+
+ const [vector, cipherText] = splitVectorAndEcryptedData(EJSON.parse(message));
+
+ try {
+ return await decryptAES(vector, this.groupSessionKey, cipherText);
+ } catch (error) {
+ console.error('E2E -> Error decrypting file: ', error);
+
+ return false;
+ }
+ }
+
+ // Encrypts messages
+ async encryptText(data) {
+ if (!_.isObject(data)) {
+ data = new TextEncoder('UTF-8').encode(EJSON.stringify({ text: data, ack: Random.id((Random.fraction() + 1) * 20) }));
+ }
+
+ if (!this.isSupportedRoomType(this.typeOfRoom)) {
+ return data;
+ }
+
+ const vector = crypto.getRandomValues(new Uint8Array(16));
+ let result;
+ try {
+ result = await encryptAES(vector, this.groupSessionKey, data);
+ } catch (error) {
+ return console.error('E2E -> Error encrypting message: ', error);
+ }
+
+ return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result));
+ }
+
+ // Helper function for encryption of messages
+ encrypt(message) {
+ let ts;
+ if (isNaN(TimeSync.serverOffset())) {
+ ts = new Date();
+ } else {
+ ts = new Date(Date.now() + TimeSync.serverOffset());
+ }
+
+ const data = new TextEncoder('UTF-8').encode(EJSON.stringify({
+ _id: message._id,
+ text: message.msg,
+ userId: this.userId,
+ ts,
+ }));
+ const enc = this.encryptText(data);
+ return enc;
+ }
+
+ // Decrypt messages
+ async decrypt(message) {
+ if (!this.isSupportedRoomType(this.typeOfRoom)) {
+ return message;
+ }
+
+ const keyID = message.slice(0, 12);
+
+ if (keyID !== this.keyID) {
+ return message;
+ }
+
+ message = message.slice(12);
+
+ const [vector, cipherText] = splitVectorAndEcryptedData(Base64.decode(message));
+
+ try {
+ const result = await decryptAES(vector, this.groupSessionKey, cipherText);
+ return EJSON.parse(toString(result));
+ } catch (error) {
+ return console.error('E2E -> Error decrypting message: ', error, message);
+ }
+ }
+
+ provideKeyToUser(keyId) {
+ if (this.keyID !== keyId) {
+ return;
+ }
+
+ this.encryptKeyForOtherParticipants();
+ }
+}
diff --git a/packages/rocketchat-e2e/client/stylesheets/e2e.less b/packages/rocketchat-e2e/client/stylesheets/e2e.less
new file mode 100644
index 000000000000..b026287a731f
--- /dev/null
+++ b/packages/rocketchat-e2e/client/stylesheets/e2e.less
@@ -0,0 +1,13 @@
+.message {
+ &.e2e {
+ .info::before {
+ display: inline-block;
+
+ visibility: visible;
+
+ content: "\e952";
+
+ font-family: 'fontello';
+ }
+ }
+}
diff --git a/packages/rocketchat-e2e/package.js b/packages/rocketchat-e2e/package.js
new file mode 100644
index 000000000000..84ff43a160fe
--- /dev/null
+++ b/packages/rocketchat-e2e/package.js
@@ -0,0 +1,18 @@
+/* globals Package: false */
+
+Package.describe({
+ name: 'rocketchat:e2e',
+ version: '0.0.1',
+ summary: 'End-to-End encrypted conversations for Rocket.Chat',
+ git: '',
+});
+
+Package.onUse(function(api) {
+ api.use('ecmascript');
+ api.use('less');
+ api.use('mizzao:timesync');
+
+ api.mainModule('client/rocketchat.e2e.js', 'client');
+
+ api.mainModule('server/index.js', 'server');
+});
diff --git a/packages/rocketchat-e2e/server/index.js b/packages/rocketchat-e2e/server/index.js
new file mode 100644
index 000000000000..ff0b4cf56474
--- /dev/null
+++ b/packages/rocketchat-e2e/server/index.js
@@ -0,0 +1,16 @@
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+import './settings';
+import './models/Users';
+import './models/Rooms';
+import './models/Subscriptions';
+import './methods/addKeyToChain';
+import './methods/getUsersOfRoomWithoutKey';
+import './methods/fetchKeychain';
+import './methods/updateGroupE2EKey';
+import './methods/setRoomKeyID';
+import './methods/fetchMyKeys';
+
+RocketChat.callbacks.add('afterJoinRoom', (user, room) => {
+ RocketChat.Notifications.notifyRoom('e2e.keyRequest', room._id, room.e2eKeyId);
+});
diff --git a/packages/rocketchat-e2e/server/methods/addKeyToChain.js b/packages/rocketchat-e2e/server/methods/addKeyToChain.js
new file mode 100644
index 000000000000..9a05a0d8f3a9
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/addKeyToChain.js
@@ -0,0 +1,14 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ addKeyToChain(key) {
+ const userId = Meteor.userId();
+
+ if (!userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'addKeyToChain' });
+ }
+
+ return RocketChat.models.Users.addKeyToChainByUserId(userId, key);
+ },
+});
diff --git a/packages/rocketchat-e2e/server/methods/fetchKeychain.js b/packages/rocketchat-e2e/server/methods/fetchKeychain.js
new file mode 100644
index 000000000000..c414582ffd7a
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/fetchKeychain.js
@@ -0,0 +1,11 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ fetchKeychain(userId) {
+ if (!Meteor.userId()) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'fetchKeychain' });
+ }
+ return JSON.stringify(RocketChat.models.Users.fetchKeychain(userId));
+ },
+});
diff --git a/packages/rocketchat-e2e/server/methods/fetchMyKeys.js b/packages/rocketchat-e2e/server/methods/fetchMyKeys.js
new file mode 100644
index 000000000000..ce3e81889d26
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/fetchMyKeys.js
@@ -0,0 +1,12 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ fetchMyKeys() {
+ const userId = Meteor.userId();
+ if (!userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'fetchMyKeys' });
+ }
+ return RocketChat.models.Users.fetchKeysByUserId(userId);
+ },
+});
diff --git a/packages/rocketchat-e2e/server/methods/getUsersOfRoomWithoutKey.js b/packages/rocketchat-e2e/server/methods/getUsersOfRoomWithoutKey.js
new file mode 100644
index 000000000000..26cc598f975b
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/getUsersOfRoomWithoutKey.js
@@ -0,0 +1,26 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ 'e2e.getUsersOfRoomWithoutKey'(rid) {
+ const userId = Meteor.userId();
+ if (!userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.getUsersOfRoomWithoutKey' });
+ }
+
+ const room = Meteor.call('canAccessRoom', rid, userId);
+ if (!room) {
+ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.getUsersOfRoomWithoutKey' });
+ }
+
+ const subscriptions = RocketChat.models.Subscriptions.findByRidWithoutE2EKey(rid, { fields: { 'u._id': 1 } }).fetch();
+ const userIds = subscriptions.map((s) => s.u._id);
+ const options = { fields: { 'e2e.public_key': 1 } };
+
+ const users = RocketChat.models.Users.findByIdsWithPublicE2EKey(userIds, options).fetch();
+
+ return {
+ users,
+ };
+ },
+});
diff --git a/packages/rocketchat-e2e/server/methods/setRoomKeyID.js b/packages/rocketchat-e2e/server/methods/setRoomKeyID.js
new file mode 100644
index 000000000000..d31aaf492e35
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/setRoomKeyID.js
@@ -0,0 +1,22 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ 'e2e.setRoomKeyID'(rid, keyID) {
+ const userId = Meteor.userId();
+ if (!userId) {
+ throw new Meteor.Error('error-invalid-user', 'Invalid user', { method: 'e2e.setRoomKeyID' });
+ }
+
+ const room = Meteor.call('canAccessRoom', rid, userId);
+ if (!room) {
+ throw new Meteor.Error('error-invalid-room', 'Invalid room', { method: 'e2e.setRoomKeyID' });
+ }
+
+ if (room.e2eKeyId) {
+ throw new Meteor.Error('error-room-e2e-key-already-exists', 'E2E Key ID already exists', { method: 'e2e.setRoomKeyID' });
+ }
+
+ return RocketChat.models.Rooms.setE2eKeyId(room._id, keyID);
+ },
+});
diff --git a/packages/rocketchat-e2e/server/methods/updateGroupE2EKey.js b/packages/rocketchat-e2e/server/methods/updateGroupE2EKey.js
new file mode 100644
index 000000000000..d8d1fcd45b95
--- /dev/null
+++ b/packages/rocketchat-e2e/server/methods/updateGroupE2EKey.js
@@ -0,0 +1,14 @@
+import { Meteor } from 'meteor/meteor';
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+Meteor.methods({
+ updateGroupE2EKey(rid, uid, key) {
+ const mySub = RocketChat.models.Subscriptions.findOneByRoomIdAndUserId(rid, Meteor.userId());
+ if (mySub) { // I have a subscription to this room
+ const userSub = RocketChat.models.Subscriptions.findOneByRoomIdAndUserId(rid, uid);
+ if (userSub) { // uid also has subscription to this room
+ return RocketChat.models.Subscriptions.updateGroupE2EKey(userSub._id, key);
+ }
+ }
+ },
+});
diff --git a/packages/rocketchat-e2e/server/models/Rooms.js b/packages/rocketchat-e2e/server/models/Rooms.js
new file mode 100644
index 000000000000..549be9048000
--- /dev/null
+++ b/packages/rocketchat-e2e/server/models/Rooms.js
@@ -0,0 +1,15 @@
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+RocketChat.models.Rooms.setE2eKeyId = function(_id, e2eKeyId, options) {
+ const query = {
+ _id,
+ };
+
+ const update = {
+ $set: {
+ e2eKeyId,
+ },
+ };
+
+ return this.update(query, update, options);
+};
diff --git a/packages/rocketchat-e2e/server/models/Subscriptions.js b/packages/rocketchat-e2e/server/models/Subscriptions.js
new file mode 100644
index 000000000000..88af4c7d357b
--- /dev/null
+++ b/packages/rocketchat-e2e/server/models/Subscriptions.js
@@ -0,0 +1,19 @@
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+RocketChat.models.Subscriptions.updateGroupE2EKey = function(_id, key) {
+ const query = { _id };
+ const update = { $set: { E2EKey: key } };
+ this.update(query, update);
+ return this.findOne({ _id });
+};
+
+RocketChat.models.Subscriptions.findByRidWithoutE2EKey = function(rid, options) {
+ const query = {
+ rid,
+ E2EKey: {
+ $exists: false,
+ },
+ };
+
+ return this.find(query, options);
+};
diff --git a/packages/rocketchat-e2e/server/models/Users.js b/packages/rocketchat-e2e/server/models/Users.js
new file mode 100644
index 000000000000..1343d2d8195b
--- /dev/null
+++ b/packages/rocketchat-e2e/server/models/Users.js
@@ -0,0 +1,56 @@
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+RocketChat.models.Users.addKeyToChainByUserId = function(userId, key) {
+ this.update({ _id: userId }, {
+ $set: {
+ 'e2e.public_key': key.public_key,
+ 'e2e.private_key': key.private_key,
+ },
+ });
+};
+
+RocketChat.models.Users.fetchKeychain = function(userId) {
+ const user = this.findOne({ _id: userId }, { fields: { 'e2e.public_key': 1 } });
+
+ if (!user || !user.e2e || !user.e2e.public_key) {
+ return;
+ }
+
+ return {
+ public_key: user.e2e.public_key,
+ };
+};
+
+RocketChat.models.Users.fetchKeysByUserId = function(userId) {
+ const user = this.findOne({ _id: userId }, { fields: { e2e: 1 } });
+
+ if (!user || !user.e2e || !user.e2e.public_key) {
+ return {};
+ }
+
+ return {
+ public_key: user.e2e.public_key,
+ private_key: user.e2e.private_key,
+ };
+};
+
+RocketChat.models.Users.emptyKeychainByUserId = function(userId) {
+ this.update({ _id: userId }, {
+ $unset: {
+ e2e: 1,
+ },
+ });
+};
+
+RocketChat.models.Users.findByIdsWithPublicE2EKey = function(ids, options) {
+ const query = {
+ _id: {
+ $in: ids,
+ },
+ 'e2e.public_key': {
+ $exists: 1,
+ },
+ };
+
+ return this.find(query, options);
+};
diff --git a/packages/rocketchat-e2e/server/settings.js b/packages/rocketchat-e2e/server/settings.js
new file mode 100644
index 000000000000..48e946583641
--- /dev/null
+++ b/packages/rocketchat-e2e/server/settings.js
@@ -0,0 +1,9 @@
+import { RocketChat } from 'meteor/rocketchat:lib';
+
+RocketChat.settings.addGroup('E2E Encryption', function() {
+ this.add('E2E_Enable', false, {
+ type: 'boolean',
+ i18nLabel: 'Enabled',
+ public: true,
+ });
+});
diff --git a/packages/rocketchat-file-upload/package.js b/packages/rocketchat-file-upload/package.js
index 9f8f8749bde5..26bec0342c39 100644
--- a/packages/rocketchat-file-upload/package.js
+++ b/packages/rocketchat-file-upload/package.js
@@ -20,6 +20,7 @@ Package.onUse(function(api) {
api.use('accounts-base');
api.use('tracker');
api.use('webapp');
+ api.use('rocketchat:e2e');
api.addFiles('globalFileRestrictions.js');
diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json
index abc641dbd5fe..7c2ceb8aed3e 100644
--- a/packages/rocketchat-i18n/i18n/en.i18n.json
+++ b/packages/rocketchat-i18n/i18n/en.i18n.json
@@ -1024,6 +1024,8 @@
"Enable_Svg_Favicon": "Enable SVG favicon",
"Enable_two-factor_authentication": "Enable two-factor authentication",
"Enabled": "Enabled",
+ "Encrypted": "Encrypted",
+ "Encrypted_channel_Description": "End to end encrypted channel. Search will not work with encrypted channels and notifications may not show the messages content.",
"Encrypted_message": "Encrypted message",
"End_OTR": "End OTR",
"Enter_a_name": "Enter a name",
diff --git a/packages/rocketchat-lib/lib/RoomTypeConfig.js b/packages/rocketchat-lib/lib/RoomTypeConfig.js
index 5b89c0d052da..068404195161 100644
--- a/packages/rocketchat-lib/lib/RoomTypeConfig.js
+++ b/packages/rocketchat-lib/lib/RoomTypeConfig.js
@@ -9,6 +9,7 @@ export const RoomSettingsEnum = {
JOIN_CODE: 'joinCode',
BROADCAST: 'broadcast',
SYSTEM_MESSAGES: 'systemMessages',
+ E2E: 'encrypted',
};
export const UiTextContext = {
diff --git a/packages/rocketchat-lib/lib/roomTypes/direct.js b/packages/rocketchat-lib/lib/roomTypes/direct.js
index 1709555e4af5..dac14d8bfc99 100644
--- a/packages/rocketchat-lib/lib/roomTypes/direct.js
+++ b/packages/rocketchat-lib/lib/roomTypes/direct.js
@@ -92,6 +92,8 @@ export class DirectMessageRoomType extends RoomTypeConfig {
case RoomSettingsEnum.ARCHIVE_OR_UNARCHIVE:
case RoomSettingsEnum.JOIN_CODE:
return false;
+ case RoomSettingsEnum.E2E:
+ return RocketChat.settings.get('E2E_Enable') === true;
default:
return true;
}
diff --git a/packages/rocketchat-lib/lib/roomTypes/private.js b/packages/rocketchat-lib/lib/roomTypes/private.js
index ee4293ba9f4f..b407a111c1db 100644
--- a/packages/rocketchat-lib/lib/roomTypes/private.js
+++ b/packages/rocketchat-lib/lib/roomTypes/private.js
@@ -66,6 +66,8 @@ export class PrivateRoomType extends RoomTypeConfig {
case RoomSettingsEnum.REACT_WHEN_READ_ONLY:
return !room.broadcast && room.ro;
case RoomSettingsEnum.SYSTEM_MESSAGES:
+ case RoomSettingsEnum.E2E:
+ return RocketChat.settings.get('E2E_Enable') === true;
default:
return true;
}
diff --git a/packages/rocketchat-lib/server/functions/notifications/email.js b/packages/rocketchat-lib/server/functions/notifications/email.js
index dbf06a1891f2..6459dcd1c4bc 100644
--- a/packages/rocketchat-lib/server/functions/notifications/email.js
+++ b/packages/rocketchat-lib/server/functions/notifications/email.js
@@ -26,6 +26,11 @@ function getEmailContent({ message, user, room }) {
if (message.msg !== '') {
let messageContent = s.escapeHTML(message.msg);
+
+ if (message.t === 'e2e') {
+ messageContent = TAPi18n.__('Encrypted_message', { lng });
+ }
+
message = RocketChat.callbacks.run('renderMessage', message);
if (message.tokens && message.tokens.length > 0) {
message.tokens.forEach((token) => {
diff --git a/packages/rocketchat-lib/server/functions/notifications/index.js b/packages/rocketchat-lib/server/functions/notifications/index.js
index a246a0a9ea34..c948bdbc4435 100644
--- a/packages/rocketchat-lib/server/functions/notifications/index.js
+++ b/packages/rocketchat-lib/server/functions/notifications/index.js
@@ -12,6 +12,12 @@ export function parseMessageTextPerUser(messageText, message, receiver) {
return message.attachments[0].image_type ? TAPi18n.__('User_uploaded_image', { lng }) : TAPi18n.__('User_uploaded_file', { lng });
}
+ if (message.msg && message.t === 'e2e') {
+ const lng = receiver.language || RocketChat.settings.get('language') || 'en';
+
+ return TAPi18n.__('Encrypted_message', { lng });
+ }
+
return messageText;
}
diff --git a/packages/rocketchat-lib/server/models/Rooms.js b/packages/rocketchat-lib/server/models/Rooms.js
index 75aad3b509a0..6f895e3df3fa 100644
--- a/packages/rocketchat-lib/server/models/Rooms.js
+++ b/packages/rocketchat-lib/server/models/Rooms.js
@@ -686,6 +686,18 @@ class ModelRooms extends RocketChat.models._Base {
return this.update(query, update);
}
+ saveEncryptedById(_id, value) {
+ const query = { _id };
+
+ const update = {
+ $set: {
+ encrypted: value === true,
+ },
+ };
+
+ return this.update(query, update);
+ }
+
setTopicAndTagsById(_id, topic, tags) {
const setData = {};
const unsetData = {};
diff --git a/packages/rocketchat-message-attachments/package.js b/packages/rocketchat-message-attachments/package.js
index bdc4989d3c7f..05b1944ed304 100644
--- a/packages/rocketchat-message-attachments/package.js
+++ b/packages/rocketchat-message-attachments/package.js
@@ -11,6 +11,7 @@ Package.onUse(function(api) {
'ecmascript',
'rocketchat:lib',
'rocketchat:lazy-load',
+ 'rocketchat:e2e',
]);
api.addFiles('client/messageAttachment.html', 'client');
diff --git a/packages/rocketchat-ui-message/client/message.html b/packages/rocketchat-ui-message/client/message.html
index c7c93aed5575..94a1c298fa7b 100644
--- a/packages/rocketchat-ui-message/client/message.html
+++ b/packages/rocketchat-ui-message/client/message.html
@@ -62,7 +62,11 @@
{{#if isSnippet}}
{{_ "Snippet_name"}}: {{snippetName}}
{{/if}}
- {{{body}}}
+ {{#if isDecrypting}}
+
******
+ {{else}}
+ {{{body}}}
+ {{/if}}
{{#if hasOembed}}
{{#each urls}}
{{injectIndex . @index}} {{> oembedBaseWidget}}
diff --git a/packages/rocketchat-ui-message/client/message.js b/packages/rocketchat-ui-message/client/message.js
index d2b1201989ef..2c553832df29 100644
--- a/packages/rocketchat-ui-message/client/message.js
+++ b/packages/rocketchat-ui-message/client/message.js
@@ -17,6 +17,9 @@ Template.message.helpers({
ignoredClass() {
return this.ignored ? 'message--ignored' : '';
},
+ isDecrypting() {
+ return this.e2e === 'pending';
+ },
isBot() {
if (this.bot != null) {
return 'bot';
diff --git a/packages/rocketchat-ui/client/components/header/header.html b/packages/rocketchat-ui/client/components/header/header.html
index 7b056a27815b..354d0d810d0b 100644
--- a/packages/rocketchat-ui/client/components/header/header.html
+++ b/packages/rocketchat-ui/client/components/header/header.html
@@ -15,6 +15,9 @@
{{#if tokenAccessChannel}}
{{/if}}
+ {{#if encryptedChannel}}
+
+ {{/if}}
{{#if isChannel}}
+ {{#if e2eEnabled}}
+
+
+
+
+
+
+
+ {{_"Encrypted"}}
+
+
+
+ {{_"Encrypted_channel_Description"}}
+
+
+ {{/if}}
diff --git a/packages/rocketchat-ui/client/views/app/createChannel.js b/packages/rocketchat-ui/client/views/app/createChannel.js
index 7c9b55c15a96..232c745ab9c4 100644
--- a/packages/rocketchat-ui/client/views/app/createChannel.js
+++ b/packages/rocketchat-ui/client/views/app/createChannel.js
@@ -86,6 +86,15 @@ Template.createChannel.helpers({
broadcast() {
return Template.instance().broadcast.get();
},
+ encrypted() {
+ return Template.instance().encrypted.get();
+ },
+ encryptedDisabled() {
+ return Template.instance().type.get() !== 'p' || Template.instance().broadcast.get();
+ },
+ e2eEnabled() {
+ return RocketChat.settings.get('E2E_Enable');
+ },
readOnly() {
return Template.instance().readOnly.get();
},
@@ -159,13 +168,17 @@ Template.createChannel.events({
t.change();
},
'change [name="type"]'(e, t) {
- t.type.set(e.target.checked ? e.target.value : 'd');
+ t.type.set(e.target.checked ? e.target.value : 'c');
t.change();
},
'change [name="broadcast"]'(e, t) {
t.broadcast.set(e.target.checked);
t.change();
},
+ 'change [name="encrypted"]'(e, t) {
+ t.encrypted.set(e.target.checked);
+ t.change();
+ },
'change [name="readOnly"]'(e, t) {
t.readOnly.set(e.target.checked);
},
@@ -201,6 +214,7 @@ Template.createChannel.events({
const type = instance.type.get();
const readOnly = instance.readOnly.get();
const broadcast = instance.broadcast.get();
+ const encrypted = instance.encrypted.get();
const isPrivate = type === 'p';
if (instance.invalid.get() || instance.inUse.get()) {
@@ -211,7 +225,7 @@ Template.createChannel.events({
}
const extraData = Object.keys(instance.extensions_submits)
- .reduce((result, key) => ({ ...result, ...instance.extensions_submits[key](instance) }), { broadcast });
+ .reduce((result, key) => ({ ...result, ...instance.extensions_submits[key](instance) }), { broadcast, encrypted });
Meteor.call(isPrivate ? 'createPrivateGroup' : 'createChannel', name, instance.selectedUsers.get().map((user) => user.username), readOnly, {}, extraData, function(err, result) {
if (err) {
@@ -261,6 +275,7 @@ Template.createChannel.onCreated(function() {
this.type = new ReactiveVar(RocketChat.authz.hasAllPermission(['create-p']) ? 'p' : 'c');
this.readOnly = new ReactiveVar(false);
this.broadcast = new ReactiveVar(false);
+ this.encrypted = new ReactiveVar(false);
this.inUse = new ReactiveVar(undefined);
this.invalid = new ReactiveVar(false);
this.extensions_invalid = new ReactiveVar(false);
@@ -274,6 +289,12 @@ Template.createChannel.onCreated(function() {
const broadcast = this.broadcast.get();
if (broadcast) {
this.readOnly.set(true);
+ this.encrypted.set(false);
+ }
+
+ const type = this.type.get();
+ if (type !== 'p') {
+ this.encrypted.set(false);
}
});
diff --git a/packages/rocketchat-ui/package.js b/packages/rocketchat-ui/package.js
index 1e1a038fd092..0a246d6db4d1 100644
--- a/packages/rocketchat-ui/package.js
+++ b/packages/rocketchat-ui/package.js
@@ -25,6 +25,7 @@ Package.onUse(function(api) {
'rocketchat:push',
'raix:ui-dropped-event',
'rocketchat:lazy-load',
+ 'rocketchat:e2e',
]);
api.use('kadira:flow-router', 'client');
diff --git a/server/publications/room.js b/server/publications/room.js
index 8fb958b4bf47..d93e173d6254 100644
--- a/server/publications/room.js
+++ b/server/publications/room.js
@@ -38,6 +38,8 @@ const fields = {
tokenpass: 1,
streamingOptions: 1,
broadcast: 1,
+ encrypted: 1,
+ e2eKeyId: 1,
};
const roomMap = (record) => {
diff --git a/server/publications/subscription.js b/server/publications/subscription.js
index 2eaba10b1ec8..d0c731cd7ffd 100644
--- a/server/publications/subscription.js
+++ b/server/publications/subscription.js
@@ -31,6 +31,7 @@ const fields = {
hideUnreadStatus: 1,
muteGroupMentions: 1,
ignored: 1,
+ E2EKey: 1,
};
Meteor.methods({