[NEW] Encrypted Discussions and new Encryption Permissions (#20201)

pull/20313/head^2
pierre-lehnen-rc 5 years ago committed by GitHub
parent 4127ef5ec8
commit 64d77c99b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 7
      app/api/server/v1/rooms.js
  2. 1
      app/authorization/server/startup.js
  3. 20
      app/channel-settings/server/functions/saveRoomEncrypted.ts
  4. 26
      app/channel-settings/server/methods/saveRoomSettings.js
  5. 17
      app/discussion/client/views/creationDialog/CreateDiscussion.html
  6. 18
      app/discussion/client/views/creationDialog/CreateDiscussion.js
  7. 36
      app/discussion/server/methods/createDiscussion.js
  8. 258
      app/e2e/client/rocketchat.e2e.js
  9. 357
      app/e2e/client/rocketchat.e2e.room.js
  10. 5
      app/e2e/client/tabbar.ts
  11. 6
      app/e2e/server/beforeCreateRoom.js
  12. 28
      app/lib/lib/MessageTypes.js
  13. 2
      app/ui/client/views/app/createChannel.js
  14. 2
      client/views/room/Header/ToolBox/ToolBox.tsx
  15. 4
      client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.js
  16. 2
      client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js
  17. 6
      packages/rocketchat-i18n/i18n/en.i18n.json
  18. 1
      server/startup/migrations/index.js
  19. 11
      server/startup/migrations/v214.js

@ -234,7 +234,7 @@ API.v1.addRoute('rooms.leave', { authRequired: true }, {
API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, {
post() {
const { prid, pmid, reply, t_name, users } = this.bodyParams;
const { prid, pmid, reply, t_name, users, encrypted } = this.bodyParams;
if (!prid) {
return API.v1.failure('Body parameter "prid" is required.');
}
@ -245,12 +245,17 @@ API.v1.addRoute('rooms.createDiscussion', { authRequired: true }, {
return API.v1.failure('Body parameter "users" must be an array.');
}
if (encrypted !== undefined && typeof encrypted !== 'boolean') {
return API.v1.failure('Body parameter "encrypted" must be a boolean when included.');
}
const discussion = Meteor.runAsUser(this.userId, () => Meteor.call('createDiscussion', {
prid,
pmid,
t_name,
reply,
users: users || [],
encrypted,
}));
return API.v1.success({ discussion });

@ -120,6 +120,7 @@ Meteor.startup(function() {
{ _id: 'edit-livechat-room-customfields', roles: ['livechat-manager', 'livechat-agent', 'admin'] },
{ _id: 'send-omnichannel-chat-transcript', roles: ['livechat-manager', 'admin'] },
{ _id: 'mail-messages', roles: ['admin'] },
{ _id: 'toggle-room-e2e-encryption', roles: ['owner'] },
];
for (const permission of permissions) {

@ -0,0 +1,20 @@
import { Meteor } from 'meteor/meteor';
import { Match } from 'meteor/check';
import type { WriteOpResult } from 'mongodb';
import { Rooms, Messages } from '../../../models/server';
import type { IUser } from '../../../../definition/IUser';
export const saveRoomEncrypted = function(rid: string, encrypted: boolean, user: IUser, sendMessage = true): Promise<WriteOpResult> {
if (!Match.test(rid, String)) {
throw new Meteor.Error('invalid-room', 'Invalid room', {
function: 'RocketChat.saveRoomEncrypted',
});
}
const update = Rooms.saveEncryptedById(rid, encrypted);
if (update && sendMessage) {
Messages.createRoomSettingsChangedWithTypeRoomIdMessageAndUser(`room_e2e_${ encrypted ? 'enabled' : 'disabled' }`, rid, user.username, user, {});
}
return update;
};

@ -15,6 +15,7 @@ import { saveRoomReadOnly } from '../functions/saveRoomReadOnly';
import { saveReactWhenReadOnly } from '../functions/saveReactWhenReadOnly';
import { saveRoomSystemMessages } from '../functions/saveRoomSystemMessages';
import { saveRoomTokenpass } from '../functions/saveRoomTokens';
import { saveRoomEncrypted } from '../functions/saveRoomEncrypted';
import { saveStreamingOptions } from '../functions/saveStreamingOptions';
import { RoomSettingsEnum, roomTypes } from '../../../utils';
@ -56,12 +57,21 @@ const validators = {
});
}
},
encrypted({ value, room }) {
if (value !== room.encrypted && !roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) {
throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', {
method: 'saveRoomSettings',
action: 'Change_Room_Encrypted',
});
encrypted({ userId, value, room, rid }) {
if (value !== room.encrypted) {
if (!roomTypes.getConfig(room.t).allowRoomSettingChange(room, RoomSettingsEnum.E2E)) {
throw new Meteor.Error('error-action-not-allowed', 'Only groups or direct channels can enable encryption', {
method: 'saveRoomSettings',
action: 'Change_Room_Encrypted',
});
}
if (room.t !== 'd' && !hasPermission(userId, 'toggle-room-e2e-encryption', rid)) {
throw new Meteor.Error('error-action-not-allowed', 'You do not have permission to toggle E2E encryption', {
method: 'saveRoomSettings',
action: 'Change_Room_Encrypted',
});
}
}
},
retentionEnabled({ userId, value, room, rid }) {
@ -198,8 +208,8 @@ const settingSavers = {
retentionOverrideGlobal({ value, rid }) {
Rooms.saveRetentionOverrideGlobalById(rid, value);
},
encrypted({ value, rid }) {
Rooms.saveEncryptedById(rid, value);
encrypted({ value, room, rid, user }) {
saveRoomEncrypted(rid, value, user, Boolean(room.encrypted) !== Boolean(value));
},
favorite({ value, rid }) {
Rooms.saveFavoriteById(rid, value.favorite, value.defaultValue);

@ -32,7 +32,17 @@
</label>
</div>
{{/unless}}
<div class="rc-switch">
<label class="rc-switch__label" tabindex="-1">
<input type="checkbox" class="rc-switch__input" id="encrypted" name="encrypted" checked={{encrypted}}>
<span class="rc-switch__button">
<span class="rc-switch__button-inside"></span>
</span>
<span class="rc-switch__text">
{{_ "Encrypted"}}
</span>
</label>
</div>
<div class="create-channel__inputs">
<div class="rc-input">
<label class="rc-input__label">
@ -67,9 +77,12 @@
<label class="rc-input__label">
<div class="rc-input__title">{{_ "Discussion_first_message_title"}}</div>
<div class="rc-input__wrapper">
<textarea rows="5" name="discussion_message" id="discussion_message" class="rc-input__element" placeholder="{{_ 'New_discussion_first_message'}}"
<textarea rows="5" {{messageDisable}} name="discussion_message" id="discussion_message" class="rc-input__element" placeholder="{{_ 'New_discussion_first_message'}}"
maxlength="{{maxMessageLength}}"></textarea>
</div>
{{#if encrypted}}
<div class="rc-input__title">{{_ "Discussion_first_message_disabled_due_to_e2e"}}</div>
{{/if}}
</label>
</div>
</div>

@ -14,9 +14,17 @@ import { AutoComplete } from '../../../../meteor-autocomplete/client';
import './CreateDiscussion.html';
Template.CreateDiscussion.helpers({
encrypted() {
return Template.instance().encrypted.get();
},
onSelectUser() {
return Template.instance().onSelectUser;
},
messageDisable() {
if (Template.instance().encrypted.get()) {
return 'disabled';
}
},
disabled() {
if (Template.instance().selectParent.get()) {
return 'disabled';
@ -90,6 +98,9 @@ Template.CreateDiscussion.events({
'input #discussion_name'(e, t) {
t.discussionName.set(e.target.value);
},
'input #encrypted'(e, t) {
t.encrypted.set(!t.encrypted.get());
},
'input #discussion_message'(e, t) {
const { value } = e.target;
t.reply.set(value);
@ -101,15 +112,17 @@ Template.CreateDiscussion.events({
const { pmid } = instance;
const t_name = instance.discussionName.get();
const users = instance.selectedUsers.get().map(({ username }) => username).filter((value, index, self) => self.indexOf(value) === index);
const encrypted = instance.encrypted.get();
const prid = instance.parentChannelId.get();
const reply = instance.reply.get();
const reply = encrypted ? undefined : instance.reply.get();
if (!prid) {
const errorText = TAPi18n.__('Invalid_room_name', `${ parentChannel }...`);
return toastr.error(errorText);
}
const result = await call('createDiscussion', { prid, pmid, t_name, reply, users });
const result = await call('createDiscussion', { prid, pmid, t_name, users, encrypted, reply });
// callback to enable tracking
callbacks.run('afterDiscussion', Meteor.user(), result);
@ -144,6 +157,7 @@ Template.CreateDiscussion.onCreated(function() {
this.pmid = msg && msg._id;
this.encrypted = new ReactiveVar(room?.encrypted || false);
this.parentChannel = new ReactiveVar(roomName);
this.parentChannelId = new ReactiveVar(room && room.rid);

@ -1,5 +1,7 @@
import { Meteor } from 'meteor/meteor';
import { Random } from 'meteor/random';
import { Match } from 'meteor/check';
import { TAPi18n } from 'meteor/rocketchat:tap-i18n';
import { hasAtLeastOnePermission, canSendMessage } from '../../../authorization/server';
import { Messages, Rooms } from '../../../models/server';
@ -35,7 +37,7 @@ const mentionMessage = (rid, { _id, username, name }, message_embedded) => {
return Messages.insert(welcomeMessage);
};
const create = ({ prid, pmid, t_name, reply, users, user }) => {
const create = ({ prid, pmid, t_name, reply, users, user, encrypted }) => {
// if you set both, prid and pmid, and the rooms doesnt match... should throw an error)
let message = false;
if (pmid) {
@ -67,6 +69,22 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => {
throw new Meteor.Error('error-nested-discussion', 'Cannot create nested discussions', { method: 'DiscussionCreation' });
}
if (!Match.Maybe(encrypted, Boolean)) {
throw new Meteor.Error('error-invalid-arguments', 'Invalid encryption state', {
method: 'DiscussionCreation',
});
}
if (typeof encrypted !== 'boolean') {
encrypted = p_room.encrypted;
}
if (encrypted && reply) {
throw new Meteor.Error('error-invalid-arguments', 'Encrypted discussions must not receive an initial reply.', {
method: 'DiscussionCreation',
});
}
if (pmid) {
const discussionAlreadyExists = Rooms.findOne({
prid,
@ -86,11 +104,15 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => {
const invitedUsers = message ? [message.u.username, ...users] : users;
const type = roomTypes.getConfig(p_room.t).getDiscussionType();
const description = p_room.encrypted ? '' : message.msg;
const topic = p_room.name;
const discussion = createRoom(type, name, user.username, [...new Set(invitedUsers)], false, {
fname: t_name,
description: message.msg, // TODO discussions remove
topic: p_room.name, // TODO discussions remove
description, // TODO discussions remove
topic, // TODO discussions remove
prid,
encrypted,
}, {
// overrides name validation to allow anything, because discussion's name is randomly generated
nameValidationRegex: /.*/,
@ -98,6 +120,9 @@ const create = ({ prid, pmid, t_name, reply, users, user }) => {
let discussionMsg;
if (pmid) {
if (p_room.encrypted) {
message.msg = TAPi18n.__('Encrypted_message');
}
mentionMessage(discussion._id, user, attachMessage(message, p_room));
discussionMsg = createDiscussionMessage(message.rid, user, discussion._id, t_name, attachMessage(message, p_room));
@ -122,8 +147,9 @@ Meteor.methods({
* @param {string} reply - The reply, optional
* @param {string} t_name - discussion name
* @param {string[]} users - users to be added
* @param {boolean} encrypted - if the discussion's e2e encryption should be enabled.
*/
createDiscussion({ prid, pmid, t_name, reply, users }) {
createDiscussion({ prid, pmid, t_name, reply, users, encrypted }) {
if (!settings.get('Discussion_enabled')) {
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
@ -137,6 +163,6 @@ Meteor.methods({
throw new Meteor.Error('error-action-not-allowed', 'You are not allowed to create a discussion', { method: 'createDiscussion' });
}
return create({ uid, prid, pmid, t_name, reply, users, user: Meteor.user() });
return create({ uid, prid, pmid, t_name, reply, users, user: Meteor.user(), encrypted });
},
});

@ -24,7 +24,7 @@ import {
import { Rooms, Subscriptions, Messages } from '../../models';
import { promises } from '../../promises/client';
import { settings } from '../../settings';
import { Notifications } from '../../notifications';
import { Notifications } from '../../notifications/client';
import { Layout, call, modal, alerts } from '../../ui-utils';
import './events.js';
@ -33,6 +33,13 @@ import './tabbar';
let failedToDecodeKey = false;
let showingE2EAlert = false;
const waitUntilFind = (fn) => new Promise((resolve) => {
Tracker.autorun((c) => {
const result = fn();
return result && resolve(result) && c.stop();
});
});
class E2E {
constructor() {
this.started = false;
@ -45,6 +52,15 @@ class E2E {
});
}
log(...msg) {
console.log('[E2E]', ...msg);
}
error(...msg) {
console.error('[E2E]', ...msg);
}
isEnabled() {
return this.enabled.get();
}
@ -57,43 +73,32 @@ class E2E {
return this.readyPromise;
}
getE2ERoom(rid) {
return this.instancesByRoomId[rid];
}
removeInstanceByRoomId(rid) {
delete this.instancesByRoomId[rid];
}
async getInstanceByRoomId(roomId) {
if (!this.enabled.get()) {
return;
}
await this.ready();
const room = Rooms.findOne({
const room = await waitUntilFind(() => Rooms.findOne({
_id: roomId,
});
}));
if (!room) {
if (room.t !== 'd' && room.t !== 'p') {
return;
}
if (room.encrypted !== true && room.e2eKeyId == null) {
if (room.encrypted !== true && !room.e2eKeyId) {
return;
}
if (!this.instancesByRoomId[roomId]) {
const subscription = 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();
this.instancesByRoomId[roomId] = this.instancesByRoomId[roomId] ?? new E2ERoom(Meteor.userId(), roomId, room.t);
if (e2eRoom) {
await e2eRoom.handshake();
return e2eRoom;
}
return this.instancesByRoomId[roomId];
}
async startClient() {
@ -101,6 +106,8 @@ class E2E {
return;
}
this.log('startClient -> STARTED');
this.started = true;
let public_key = Meteor._localStorage.getItem('public_key');
let private_key = Meteor._localStorage.getItem('private_key');
@ -180,15 +187,15 @@ class E2E {
}
this.readyPromise.resolve();
this.log('startClient -> Done');
this.log('decryptPendingSubscriptions');
this.setupListeners();
this.decryptPendingMessages();
this.decryptPendingSubscriptions();
this.log('decryptPendingSubscriptions -> Done');
}
async stopClient() {
console.log('E2E -> Stop Client');
this.log('-> Stop Client');
// This flag is used to avoid closing unrelated alerts.
if (showingE2EAlert) {
alerts.close();
@ -208,27 +215,6 @@ class E2E {
});
}
setupListeners() {
Notifications.onUser('e2ekeyRequest', async (roomId, keyId) => {
const e2eRoom = await this.getInstanceByRoomId(roomId);
if (!e2eRoom) {
return;
}
e2eRoom.provideKeyToUser(keyId);
});
Subscriptions.after.update((userId, doc) => {
this.decryptSubscription(doc);
});
Subscriptions.after.insert((userId, doc) => {
this.decryptSubscription(doc);
});
promises.add('onClientMessageReceived', (msg) => this.decryptMessage(msg), promises.priority.HIGH);
}
async changePassword(newPassword) {
await call('e2e.setUserPublicAndPrivateKeys', {
public_key: Meteor._localStorage.getItem('public_key'),
@ -247,7 +233,7 @@ class E2E {
this.db_public_key = public_key;
this.db_private_key = private_key;
} catch (error) {
return console.error('E2E -> Error fetching RSA keys: ', error);
return this.error('Error fetching RSA keys: ', error);
}
}
@ -259,7 +245,7 @@ class E2E {
Meteor._localStorage.setItem('private_key', private_key);
} catch (error) {
return console.error('E2E -> Error importing private key: ', error);
return this.error('Error importing private key: ', error);
}
}
@ -270,7 +256,7 @@ class E2E {
key = await generateRSAKey();
this.privateKey = key.privateKey;
} catch (error) {
return console.error('E2E -> Error generating key: ', error);
return this.error('Error generating key: ', error);
}
try {
@ -278,7 +264,7 @@ class E2E {
Meteor._localStorage.setItem('public_key', JSON.stringify(publicKey));
} catch (error) {
return console.error('E2E -> Error exporting public key: ', error);
return this.error('Error exporting public key: ', error);
}
try {
@ -286,7 +272,7 @@ class E2E {
Meteor._localStorage.setItem('private_key', JSON.stringify(privateKey));
} catch (error) {
return console.error('E2E -> Error exporting private key: ', error);
return this.error('Error exporting private key: ', error);
}
this.requestSubscriptionKeys();
@ -311,7 +297,7 @@ class E2E {
return EJSON.stringify(joinVectorAndEcryptedData(vector, encodedPrivateKey));
} catch (error) {
return console.error('E2E -> Error encrypting encodedPrivateKey: ', error);
return this.error('Error encrypting encodedPrivateKey: ', error);
}
}
@ -325,14 +311,14 @@ class E2E {
try {
baseKey = await importRawKey(toArrayBuffer(password));
} catch (error) {
return console.error('E2E -> Error creating a key based on user password: ', error);
return this.error('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);
return this.error('Error deriving baseKey: ', error);
}
}
@ -399,15 +385,11 @@ class E2E {
}
async decryptMessage(message) {
if (!this.isEnabled()) {
return message;
}
if (message.t !== 'e2e' || message.e2e === 'done') {
return message;
}
const e2eRoom = await this.getInstanceByRoomId(message.rid);
const e2eRoom = this.getE2ERoom(message.rid);
if (!e2eRoom) {
return message;
@ -427,52 +409,21 @@ class E2E {
}
async decryptPendingMessages() {
if (!this.isEnabled()) {
return;
}
return Messages.find({ t: 'e2e', e2e: 'pending' }).forEach(async ({ _id, ...msg }) => {
Messages.direct.update({ _id }, await this.decryptMessage(msg));
});
}
async decryptSubscription(subscription) {
if (!this.isEnabled()) {
return;
}
if (!subscription.lastMessage || subscription.lastMessage.t !== 'e2e' || subscription.lastMessage.e2e === 'done') {
return;
}
const e2eRoom = await this.getInstanceByRoomId(subscription.rid);
if (!e2eRoom) {
return;
}
const data = await e2eRoom.decrypt(subscription.lastMessage.msg);
if (!data) {
return;
}
Subscriptions.direct.update({
_id: subscription._id,
}, {
$set: {
'lastMessage.msg': data.text,
'lastMessage.e2e': 'done',
},
});
async decryptSubscription(rid) {
const e2eRoom = await this.getInstanceByRoomId(rid);
this.log('decryptPendingSubscriptions ->', rid);
e2eRoom?.decryptPendingSubscription();
}
async decryptPendingSubscriptions() {
Subscriptions.find({
'lastMessage.t': 'e2e',
'lastMessage.e2e': {
$ne: 'done',
},
}).forEach(this.decryptSubscription.bind(this));
encrypted: true,
}).forEach((room) => this.decryptSubscription(room._id));
}
openAlert(config) {
@ -490,6 +441,15 @@ class E2E {
export const e2e = new E2E();
const handle = async (roomId, keyId) => {
const e2eRoom = await e2e.getInstanceByRoomId(roomId);
if (!e2eRoom) {
return;
}
e2eRoom.provideKeyToUser(keyId);
};
Meteor.startup(function() {
Tracker.autorun(function() {
if (Meteor.userId()) {
@ -505,33 +465,81 @@ Meteor.startup(function() {
}
});
// Encrypt messages before sending
promises.add('onClientBeforeSendMessage', async function(message) {
if (!message.rid) {
return Promise.resolve(message);
let observable = null;
Tracker.autorun(() => {
if (!e2e.isReady()) {
promises.remove('onClientMessageReceived', 'e2e-decript-message');
Notifications.unUser('e2ekeyRequest', handle);
observable?.stop();
return promises.remove('onClientBeforeSendMessage', 'e2e');
}
const room = Rooms.findOne({
_id: message.rid,
});
if (!room || room.encrypted !== true) {
return Promise.resolve(message);
}
Notifications.onUser('e2ekeyRequest', handle);
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';
observable = Subscriptions.find().observe({
changed: async (doc) => {
if (!doc.encrypted && !doc.E2EKey) {
return e2e.removeInstanceByRoomId(doc.rid);
}
const e2eRoom = await e2e.getInstanceByRoomId(doc.rid);
if (!e2eRoom) {
return;
}
doc.encrypted ? e2eRoom.enable() : e2eRoom.pause();
// Cover private groups and direct messages
if (!e2eRoom.isSupportedRoomType(doc.t)) {
return e2eRoom.disable();
}
if (doc.E2EKey && e2eRoom.isWaitingKeys()) {
return e2eRoom.keyReceived();
}
if (!e2eRoom.isReady()) {
return;
}
e2eRoom.decryptPendingSubscription();
},
added: async (doc) => {
if (!doc.encrypted) {
return;
}
return e2e.getInstanceByRoomId(doc.rid);
},
removed: (doc) => {
e2e.removeInstanceByRoomId(doc.rid);
},
});
promises.add('onClientMessageReceived', (msg) => {
const e2eRoom = e2e.getE2ERoom(msg.rid);
if (!e2eRoom || !e2eRoom.shouldConvertReceivedMessages()) {
return msg;
}
return e2e.decryptMessage(msg);
}, promises.priority.HIGH, 'e2e-decript-message');
// Encrypt messages before sending
promises.add('onClientBeforeSendMessage', async function(message) {
const e2eRoom = e2e.getE2ERoom(message.rid);
if (!e2eRoom || !e2eRoom.shouldConvertSentMessages()) {
return message;
});
}, promises.priority.HIGH);
}
// Should encrypt this message.
return e2eRoom
.encrypt(message)
.then((msg) => {
message.msg = msg;
message.t = 'e2e';
message.e2e = 'pending';
return message;
});
}, promises.priority.HIGH, 'e2e');
});
});

@ -1,13 +1,13 @@
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 { Session } from 'meteor/session';
import { TimeSync } from 'meteor/mizzao:timesync';
import { Emitter } from '@rocket.chat/emitter';
import { e2e } from './rocketchat.e2e';
import {
Deferred,
toString,
toArrayBuffer,
joinVectorAndEcryptedData,
@ -22,79 +22,230 @@ import {
importRSAKey,
readFileAsArrayBuffer,
} from './helper';
import { Notifications } from '../../notifications';
import { Rooms, Subscriptions } from '../../models';
import { Notifications } from '../../notifications/client';
import { Rooms, Subscriptions, Messages } from '../../models';
import { call } from '../../ui-utils';
import { roomTypes, RoomSettingsEnum } from '../../utils';
export class E2ERoom {
export const E2E_ROOM_STATES = {
NO_PASSWORD_SET: 'NO_PASSWORD_SET',
NOT_STARTED: 'NOT_STARTED',
DISABLED: 'DISABLED',
PAUSED: 'PAUSED',
HANDSHAKE: 'HANDSHAKE',
ESTABLISHING: 'ESTABLISHING',
CREATING_KEYS: 'CREATING_KEYS',
WAITING_KEYS: 'WAITING_KEYS',
KEYS_RECEIVED: 'KEYS_RECEIVED',
READY: 'READY',
ERROR: 'ERROR',
};
const KEY_ID = Symbol('keyID');
const reduce = (prev, next) => {
if (prev === next) {
return next === E2E_ROOM_STATES.ERROR;
}
switch (next) {
case E2E_ROOM_STATES.READY:
if (prev === E2E_ROOM_STATES.PAUSED) {
return E2E_ROOM_STATES.READY;
}
return E2E_ROOM_STATES.DISABLED;
case E2E_ROOM_STATES.PAUSED:
if (prev === E2E_ROOM_STATES.READY) {
return E2E_ROOM_STATES.PAUSED;
}
return E2E_ROOM_STATES.DISABLED;
}
switch (prev) {
case E2E_ROOM_STATES.PAUSED:
if (next === E2E_ROOM_STATES.READY) {
return E2E_ROOM_STATES.READY;
}
return false;
case E2E_ROOM_STATES.NOT_STARTED:
return [E2E_ROOM_STATES.ESTABLISHING, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.KEYS_RECEIVED].includes(next) && next;
case E2E_ROOM_STATES.READY:
return [E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED].includes(next) && next;
case E2E_ROOM_STATES.ERROR:
return [E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.NOT_STARTED].includes(next) && next;
case E2E_ROOM_STATES.WAITING_KEYS:
return [E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED].includes(next) && next;
case E2E_ROOM_STATES.ESTABLISHING:
return [E2E_ROOM_STATES.READY, E2E_ROOM_STATES.KEYS_RECEIVED, E2E_ROOM_STATES.ERROR, E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.DISABLED, E2E_ROOM_STATES.WAITING_KEYS].includes(next) && next;
default:
return next;
}
};
export class E2ERoom extends Emitter {
log(...msg) {
if (this.roomId === Session.get('openedRoom')) {
console.log('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg);
}
}
error(...msg) {
if (this.roomId === Session.get('openedRoom')) {
console.error('[E2E ROOM]', `[STATE: ${ this.state }]`, `[RID: ${ this.roomId }]`, ...msg);
}
}
setState(state) {
const prev = this.state;
const next = reduce(prev, state);
if (!next) {
this.error(`invalid state ${ prev } -> ${ state }`);
return;
}
this.state = state;
this.emit('STATE_CHANGED', prev, next, this);
this.emit(state, this);
}
constructor(userId, roomId, t) {
super();
this.state = undefined;
// this.error = undefined;
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);
this.typeOfRoom = t;
Notifications.onRoom(this.roomId, 'e2ekeyRequest', async (keyId) => {
this.provideKeyToUser(keyId);
});
this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingMessages());
this.once(E2E_ROOM_STATES.READY, () => this.decryptPendingSubscription());
this.on('STATE_CHANGED', (prev) => {
if (this.roomId === Session.get('openedRoom')) {
this.log(`[PREV: ${ prev }]`, 'State CHANGED');
}
});
this.on('STATE_CHANGED', () => this.handshake());
this.setState(E2E_ROOM_STATES.NOT_STARTED);
}
// Initiates E2E Encryption
async handshake() {
if (!e2e.isReady()) {
return;
}
if (this._ready.get()) {
return;
}
disable() {
this.setState(E2E_ROOM_STATES.DISABLED);
}
if (this.establishing.get()) {
return this.readyPromise;
}
keyReceived() {
this.setState(E2E_ROOM_STATES.KEYS_RECEIVED);
}
console.log('E2E -> Initiating handshake');
pause() {
this.setState(this.state === E2E_ROOM_STATES.READY ? E2E_ROOM_STATES.PAUSED : E2E_ROOM_STATES.DISABLED);
}
this.establishing.set(true);
enable() {
this.setState(E2E_ROOM_STATES.READY);
}
// Cover private groups and direct messages
if (!this.isSupportedRoomType(this.typeOfRoom)) {
return;
}
shouldConvertSentMessages() {
return this.isReady() && ! this.isPaused();
}
// Fetch encrypted session key from subscription model
let groupKey;
try {
groupKey = Subscriptions.findOne({ rid: this.roomId }).E2EKey;
} catch (error) {
return console.error('E2E -> Error fetching group key: ', error);
}
shouldConvertReceivedMessages() {
return this.isReady();
}
if (groupKey) {
await this.importGroupKey(groupKey);
this.readyPromise.resolve();
return true;
}
isDisabled() {
return [E2E_ROOM_STATES.DISABLED].includes(this.state);
}
isPaused() {
return [E2E_ROOM_STATES.PAUSED].includes(this.state);
}
wait(state) {
return new Promise((resolve) => (state === this.state ? resolve(this) : this.once(state, () => resolve(this)))).then((el) => {
this.log(this.state, el);
return el;
});
}
isReady() {
return [E2E_ROOM_STATES.PAUSED, E2E_ROOM_STATES.READY].includes(this.state);
}
isWaitingKeys() {
return this.state === E2E_ROOM_STATES.WAITING_KEYS;
}
get keyID() {
return this[KEY_ID];
}
set keyID(keyID) {
this[KEY_ID] = keyID;
}
const room = Rooms.findOne({ _id: this.roomId });
async decryptPendingSubscription() {
const subscription = Subscriptions.findOne({
rid: this.roomId,
});
if (!room.e2eKeyId) {
await this.createGroupKey();
this.readyPromise.resolve();
return true;
const data = await (subscription.lastMessage?.msg && this.decrypt(subscription.lastMessage.msg));
if (!data?.text) {
this.log('decryptPendingSubscriptions nothing to do');
return;
}
console.log('E2E -> Requesting room key');
// TODO: request group key
Subscriptions.direct.update({
_id: subscription._id,
}, {
$set: {
'lastMessage.msg': data.text,
'lastMessage.e2e': 'done',
},
});
this.log('decryptPendingSubscriptions Done');
}
async decryptPendingMessages() {
return Messages.find({ rid: this.roomId, t: 'e2e', e2e: 'pending' }).forEach(async ({ _id, ...msg }) => {
Messages.direct.update({ _id }, await this.decryptMessage(msg));
});
}
Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId);
// Initiates E2E Encryption
async handshake() {
switch (this.state) {
case E2E_ROOM_STATES.KEYS_RECEIVED:
case E2E_ROOM_STATES.NOT_STARTED:
this.setState(E2E_ROOM_STATES.ESTABLISHING);
try {
const groupKey = Subscriptions.findOne({ rid: this.roomId }).E2EKey;
if (groupKey) {
await this.importGroupKey(groupKey);
return this.setState(E2E_ROOM_STATES.READY);
}
} catch (error) {
this.setState(E2E_ROOM_STATES.ERROR);
// this.error = error;
return this.error('Error fetching group key: ', error);
}
try {
const room = Rooms.findOne({ _id: this.roomId });
if (!room.e2eKeyId) { // TODO CHECK_PERMISSION
this.setState(E2E_ROOM_STATES.CREATING_KEYS);
await this.createGroupKey();
return this.setState(E2E_ROOM_STATES.READY);
}
this.setState(E2E_ROOM_STATES.WAITING_KEYS);
this.log('Requesting room key');
Notifications.notifyUsersOfRoom(this.roomId, 'e2ekeyRequest', this.roomId, room.e2eKeyId);
} catch (error) {
// this.error = error;
this.setState(E2E_ROOM_STATES.ERROR);
}
}
}
isSupportedRoomType(type) {
@ -102,7 +253,7 @@ export class E2ERoom {
}
async importGroupKey(groupKey) {
console.log('E2E -> Importing room key');
this.log('Importing room key ->', this.roomId);
// Get existing group key
// const keyID = groupKey.slice(0, 12);
groupKey = groupKey.slice(12);
@ -113,7 +264,7 @@ export class E2ERoom {
const decryptedKey = await decryptRSA(e2e.privateKey, groupKey);
this.sessionKeyExportedString = toString(decryptedKey);
} catch (error) {
return console.error('E2E -> Error decrypting group key: ', error);
return this.error('Error decrypting group key: ', error);
}
this.keyID = Base64.encode(this.sessionKeyExportedString).slice(0, 12);
@ -124,68 +275,59 @@ export class E2ERoom {
// Key has been obtained. E2E is now in session.
this.groupSessionKey = key;
} catch (error) {
return console.error('E2E -> Error importing group key: ', error);
return this.error('Error importing group key: ', error);
}
}
async createGroupKey() {
console.log('E2E -> Creating room key');
this.log('Creating room key');
// Create group key
let key;
try {
key = await generateAESKey();
this.groupSessionKey = key;
this.groupSessionKey = await generateAESKey();
} catch (error) {
return console.error('E2E -> Error generating group key: ', error);
console.error('Error generating group key: ', error);
throw error;
}
let sessionKeyExported;
try {
sessionKeyExported = await exportJWKKey(this.groupSessionKey);
const sessionKeyExported = await exportJWKKey(this.groupSessionKey);
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();
} catch (error) {
return console.error('E2E -> Error exporting group key: ', error);
this.error('Error exporting group key: ', error);
throw 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);
const { users } = await call('e2e.getUsersOfRoomWithoutKey', this.roomId);
users.forEach((user) => this.encryptForParticipant(user));
} catch (error) {
return console.error('E2E -> Error getting room users: ', error);
return this.error('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);
}
async encryptForParticipant(user) {
let userKey;
try {
userKey = await importRSAKey(JSON.parse(user.e2e.public_key), ['encrypt']);
} catch (error) {
return this.error('Error importing user key: ', error);
}
// const vector = crypto.getRandomValues(new Uint8Array(16));
// Encrypt session key for this user with his/her public key
try {
const encryptedUserKey = await encryptRSA(userKey, toArrayBuffer(this.sessionKeyExportedString));
// Key has been encrypted. Publish to that user's subscription model for this room.
await call('e2e.updateGroupKey', this.roomId, user._id, this.keyID + Base64.encode(new Uint8Array(encryptedUserKey)));
} catch (error) {
return this.error('Error encrypting user key: ', error);
}
}
@ -202,7 +344,7 @@ export class E2ERoom {
try {
result = await encryptAES(vector, this.groupSessionKey, fileArrayBuffer);
} catch (error) {
return console.error('E2E -> Error encrypting group key: ', error);
return this.error('Error encrypting group key: ', error);
}
const output = joinVectorAndEcryptedData(vector, result);
@ -223,7 +365,7 @@ export class E2ERoom {
try {
return await decryptAES(vector, this.groupSessionKey, cipherText);
} catch (error) {
console.error('E2E -> Error decrypting file: ', error);
this.error('Error decrypting file: ', error);
return false;
}
@ -244,7 +386,7 @@ export class E2ERoom {
try {
result = await encryptAES(vector, this.groupSessionKey, data);
} catch (error) {
return console.error('E2E -> Error encrypting message: ', error);
return this.error('Error encrypting message: ', error);
}
return this.keyID + Base64.encode(joinVectorAndEcryptedData(vector, result));
@ -265,11 +407,30 @@ export class E2ERoom {
userId: this.userId,
ts,
}));
const enc = this.encryptText(data);
return enc;
return this.encryptText(data);
}
// Decrypt messages
async decryptMessage(message) {
if (message.t !== 'e2e' || message.e2e === 'done') {
return message;
}
const data = await this.decrypt(message.msg);
if (!data?.text) {
return message;
}
return {
...message,
msg: data.text,
e2e: 'done',
};
}
async decrypt(message) {
if (!this.isSupportedRoomType(this.typeOfRoom)) {
return message;
@ -289,7 +450,7 @@ export class E2ERoom {
const result = await decryptAES(vector, this.groupSessionKey, cipherText);
return EJSON.parse(new TextDecoder('UTF-8').decode(new Uint8Array(result)));
} catch (error) {
return console.error('E2E -> Error decrypting message: ', error, message);
return this.error('Error decrypting message: ', error, message);
}
}

@ -5,10 +5,13 @@ import { addAction } from '../../../client/views/room/lib/Toolbox';
import { useSetting } from '../../../client/contexts/SettingsContext';
import { usePermission } from '../../../client/contexts/AuthorizationContext';
import { useMethod } from '../../../client/contexts/ServerContext';
import { e2e } from './rocketchat.e2e';
addAction('e2e', ({ room }) => {
const e2eEnabled = useSetting('E2E_Enable');
const hasPermission = usePermission('edit-room', room._id);
const e2eReady = e2e.isReady() || room.encrypted;
const e2ePermission = room.t === 'd' || usePermission('toggle-room-e2e-encryption', room._id);
const hasPermission = usePermission('edit-room', room._id) && e2ePermission && e2eReady;
const toggleE2E = useMethod('saveRoomSettings');
const action = useMutableCallback(() => {

@ -3,9 +3,9 @@ import { settings } from '../../settings/server';
callbacks.add('beforeCreateRoom', ({ type, extraData }) => {
if (
(type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms'))
|| (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms'))
settings.get('E2E_Enabled') && ((type === 'd' && settings.get('E2E_Enabled_Default_DirectRooms'))
|| (type === 'p' && settings.get('E2E_Enabled_Default_PrivateRooms')))
) {
extraData.encrypted = true;
extraData.encrypted = extraData.encrypted ?? true;
}
});

@ -159,6 +159,26 @@ Meteor.startup(function() {
};
},
});
MessageTypes.registerType({
id: 'room_e2e_enabled',
system: true,
message: 'This_room_encryption_has_been_enabled_by__username_',
data(message) {
return {
username: message.u.username,
};
},
});
MessageTypes.registerType({
id: 'room_e2e_disabled',
system: true,
message: 'This_room_encryption_has_been_disabled_by__username_',
data(message) {
return {
username: message.u.username,
};
},
});
});
export const MessageTypesValues = [
@ -210,4 +230,12 @@ export const MessageTypesValues = [
key: 'room_changed_avatar',
i18nLabel: 'Message_HideType_room_changed_avatar',
},
{
key: 'room_e2e_enabled',
i18nLabel: 'Message_HideType_room_enabled_encryption',
},
{
key: 'room_e2e_disabled',
i18nLabel: 'Message_HideType_room_disabled_encryption',
},
];

@ -298,7 +298,7 @@ Template.createChannel.onCreated(function() {
this.type = new ReactiveVar(hasAllPermission(['create-p']) ? 'p' : 'c');
this.readOnly = new ReactiveVar(false);
this.broadcast = new ReactiveVar(false);
this.encrypted = new ReactiveVar(false);
this.encrypted = new ReactiveVar(settings.get('E2E_Enabled_Default_PrivateRooms'));
this.inUse = new ReactiveVar(undefined);
this.invalid = new ReactiveVar(false);
this.extensions_invalid = new ReactiveVar(false);

@ -32,10 +32,10 @@ const ToolBox = ({ className }: { className: BoxProps['className'] }): JSX.Eleme
hiddenActionRenderers.current = { ...hiddenActionRenderers.current, [item.id]: item.renderOption || renderMenuOption };
return [item.id, {
label: { title: t(item.title), icon: item.icon },
// ...item,
action: (): void => {
openTabBar(item.id);
},
...item,
}];
}));

@ -31,6 +31,7 @@ import { usePermission, useAtLeastOnePermission, useRole } from '../../../../../
import { useEndpointActionExperimental } from '../../../../../hooks/useEndpointAction';
import { useUserRoom } from '../../../hooks/useUserRoom';
import { useTabBarClose } from '../../../providers/ToolboxProvider';
import { e2e } from '../../../../../../app/e2e/client/rocketchat.e2e';
const typeMap = {
c: 'Channels',
@ -228,6 +229,7 @@ function EditChannel({ room, onClickClose, onClickBack }) {
const canEditPrivilegedSetting = usePermission('edit-privileged-setting', room._id);
const canArchiveOrUnarchive = useAtLeastOnePermission(useMemo(() => ['archive-room', 'unarchive-room'], []));
const canDelete = usePermission(`delete-${ room.t }`);
const canToggleEncryption = usePermission('toggle-room-e2e-encryption', room._id) && (room.encrypted || e2e.isReady());
const changeArchivation = archived !== !!room.archived;
const archiveSelector = room.archived ? 'unarchive' : 'archive';
@ -369,7 +371,7 @@ function EditChannel({ room, onClickClose, onClickBack }) {
<Box display='flex' flexDirection='row' justifyContent='space-between' flexGrow={1}>
<Field.Label>{t('Encrypted')}</Field.Label>
<Field.Row>
<ToggleSwitch checked={encrypted} onChange={handleEncrypted}/>
<ToggleSwitch disabled={!canToggleEncryption} checked={encrypted} onChange={handleEncrypted}/>
</Field.Row>
</Box>
</Field>}

@ -45,7 +45,7 @@ export const RoomInfoIcon = ({ name }) => <Icon name={name} size='x22' />;
export const Title = (props) => <UserCard.Username {...props}/>;
export const RoomInfo = function RoomInfo({
name,
fname: name,
description,
archived,
broadcast,

@ -5,6 +5,8 @@
"__count__empty_rooms_will_be_removed_automatically__rooms__": "__count__ empty rooms will be removed automatically:<br/> __rooms__.",
"__username__is_no_longer__role__defined_by__user_by_": "__username__ is no longer __role__ by __user_by__",
"__username__was_set__role__by__user_by_": "__username__ was set __role__ by __user_by__",
"This_room_encryption_has_been_enabled_by__username_": "This room's encryption has been enabled by __username__",
"This_room_encryption_has_been_disabled_by__username_": "This room's encryption has been disabled by __username__",
"@username": "@username",
"@username_message": "@username <message>",
"#channel": "#channel",
@ -1306,6 +1308,7 @@
"Disconnect": "Disconnect",
"Discussion": "Discussion",
"Discussion_description": "Help keeping an overview about what's going on! By creating a discussion, a sub-channel of the one you selected is created and both are linked.",
"Discussion_first_message_disabled_due_to_e2e": "You can start sending End-to-End encrypted messages in this discussion after its creation.",
"Discussion_first_message_title": "Your message",
"Discussion_name": "Discussion name",
"Discussion_start": "Start a Discussion",
@ -2597,6 +2600,8 @@
"Message_HideType_room_archived": "Hide \"Room Archived\" messages",
"Message_HideType_room_changed_avatar": "Hide \"Room avatar changed\" messages",
"Message_HideType_room_changed_privacy": "Hide \"Room type changed\" messages",
"Message_HideType_room_enabled_encryption": "Hide \"Room encryption enabled\" messages",
"Message_HideType_room_disabled_encryption": "Hide \"Room encryption disabled\" messages",
"Message_HideType_room_unarchived": "Hide \"Room Unarchived\" messages",
"Message_HideType_ru": "Hide \"User Removed\" messages",
"Message_HideType_subscription_role_added": "Hide \"Was Set Role\" messages",
@ -3740,6 +3745,7 @@
"To_users": "To Users",
"Today": "Today",
"Toggle_original_translated": "Toggle original/translated",
"toggle-room-e2e-encryption": "Toggle Room E2E Encryption",
"Token": "Token",
"Token_Access": "Token Access",
"Token_Controlled_Access": "Token Controlled Access",

@ -210,4 +210,5 @@ import './v210';
import './v211';
import './v212';
import './v213';
import './v214';
import './xrun';

@ -0,0 +1,11 @@
import { Migrations } from '../../../app/migrations/server';
import { Permissions } from '../../../app/models/server';
const roleName = 'admin';
Migrations.add({
version: 214,
up() {
Permissions.update({ _id: 'toggle-room-e2e-encryption' }, { $addToSet: { roles: roleName } });
},
});
Loading…
Cancel
Save