From 64d77c99b541dce30c58ea6873bbe65cf4ddf40e Mon Sep 17 00:00:00 2001 From: pierre-lehnen-rc <55164754+pierre-lehnen-rc@users.noreply.github.com> Date: Fri, 22 Jan 2021 09:41:45 -0300 Subject: [PATCH] [NEW] Encrypted Discussions and new Encryption Permissions (#20201) --- app/api/server/v1/rooms.js | 7 +- app/authorization/server/startup.js | 1 + .../server/functions/saveRoomEncrypted.ts | 20 + .../server/methods/saveRoomSettings.js | 26 +- .../creationDialog/CreateDiscussion.html | 17 +- .../views/creationDialog/CreateDiscussion.js | 18 +- .../server/methods/createDiscussion.js | 36 +- app/e2e/client/rocketchat.e2e.js | 258 +++++++------ app/e2e/client/rocketchat.e2e.room.js | 357 +++++++++++++----- app/e2e/client/tabbar.ts | 5 +- app/e2e/server/beforeCreateRoom.js | 6 +- app/lib/lib/MessageTypes.js | 28 ++ app/ui/client/views/app/createChannel.js | 2 +- client/views/room/Header/ToolBox/ToolBox.tsx | 2 +- .../Info/EditRoomInfo/EditRoomInfo.js | 4 +- .../contextualBar/Info/RoomInfo/RoomInfo.js | 2 +- packages/rocketchat-i18n/i18n/en.i18n.json | 6 + server/startup/migrations/index.js | 1 + server/startup/migrations/v214.js | 11 + 19 files changed, 558 insertions(+), 249 deletions(-) create mode 100644 app/channel-settings/server/functions/saveRoomEncrypted.ts create mode 100644 server/startup/migrations/v214.js diff --git a/app/api/server/v1/rooms.js b/app/api/server/v1/rooms.js index 35707c919b8..9cd0126c713 100644 --- a/app/api/server/v1/rooms.js +++ b/app/api/server/v1/rooms.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 }); diff --git a/app/authorization/server/startup.js b/app/authorization/server/startup.js index a2015e46816..e769cc2b500 100644 --- a/app/authorization/server/startup.js +++ b/app/authorization/server/startup.js @@ -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) { diff --git a/app/channel-settings/server/functions/saveRoomEncrypted.ts b/app/channel-settings/server/functions/saveRoomEncrypted.ts new file mode 100644 index 00000000000..4160f1f2954 --- /dev/null +++ b/app/channel-settings/server/functions/saveRoomEncrypted.ts @@ -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 { + 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; +}; diff --git a/app/channel-settings/server/methods/saveRoomSettings.js b/app/channel-settings/server/methods/saveRoomSettings.js index a418862d6fb..e524637e8b9 100644 --- a/app/channel-settings/server/methods/saveRoomSettings.js +++ b/app/channel-settings/server/methods/saveRoomSettings.js @@ -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); diff --git a/app/discussion/client/views/creationDialog/CreateDiscussion.html b/app/discussion/client/views/creationDialog/CreateDiscussion.html index 4452789d1d7..9eff9c8e57a 100644 --- a/app/discussion/client/views/creationDialog/CreateDiscussion.html +++ b/app/discussion/client/views/creationDialog/CreateDiscussion.html @@ -32,7 +32,17 @@ {{/unless}} - +
+ +
diff --git a/app/discussion/client/views/creationDialog/CreateDiscussion.js b/app/discussion/client/views/creationDialog/CreateDiscussion.js index 629d1f90d1d..ee4c6ca0d08 100755 --- a/app/discussion/client/views/creationDialog/CreateDiscussion.js +++ b/app/discussion/client/views/creationDialog/CreateDiscussion.js @@ -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); diff --git a/app/discussion/server/methods/createDiscussion.js b/app/discussion/server/methods/createDiscussion.js index e2e0c1f2ca2..b9d28a0d58d 100644 --- a/app/discussion/server/methods/createDiscussion.js +++ b/app/discussion/server/methods/createDiscussion.js @@ -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 }); }, }); diff --git a/app/e2e/client/rocketchat.e2e.js b/app/e2e/client/rocketchat.e2e.js index 173bcc0c6d5..832d97c266f 100644 --- a/app/e2e/client/rocketchat.e2e.js +++ b/app/e2e/client/rocketchat.e2e.js @@ -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'); + }); }); diff --git a/app/e2e/client/rocketchat.e2e.room.js b/app/e2e/client/rocketchat.e2e.room.js index c9776f49fb2..cfcea80eee7 100644 --- a/app/e2e/client/rocketchat.e2e.room.js +++ b/app/e2e/client/rocketchat.e2e.room.js @@ -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); } } diff --git a/app/e2e/client/tabbar.ts b/app/e2e/client/tabbar.ts index b3bdfa7c8cb..8a45bf4bdb1 100644 --- a/app/e2e/client/tabbar.ts +++ b/app/e2e/client/tabbar.ts @@ -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(() => { diff --git a/app/e2e/server/beforeCreateRoom.js b/app/e2e/server/beforeCreateRoom.js index 29ee95653d4..ce3b21ad693 100644 --- a/app/e2e/server/beforeCreateRoom.js +++ b/app/e2e/server/beforeCreateRoom.js @@ -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; } }); diff --git a/app/lib/lib/MessageTypes.js b/app/lib/lib/MessageTypes.js index 8c6364f0ab1..aa265826a41 100644 --- a/app/lib/lib/MessageTypes.js +++ b/app/lib/lib/MessageTypes.js @@ -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', + }, ]; diff --git a/app/ui/client/views/app/createChannel.js b/app/ui/client/views/app/createChannel.js index 9cb96bfc1ab..fa3def7e50f 100644 --- a/app/ui/client/views/app/createChannel.js +++ b/app/ui/client/views/app/createChannel.js @@ -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); diff --git a/client/views/room/Header/ToolBox/ToolBox.tsx b/client/views/room/Header/ToolBox/ToolBox.tsx index 35116129bb8..d6d070aa87e 100644 --- a/client/views/room/Header/ToolBox/ToolBox.tsx +++ b/client/views/room/Header/ToolBox/ToolBox.tsx @@ -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, }]; })); diff --git a/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.js b/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.js index b6749913129..318ec2623c8 100644 --- a/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.js +++ b/client/views/room/contextualBar/Info/EditRoomInfo/EditRoomInfo.js @@ -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 }) { {t('Encrypted')} - + } diff --git a/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js b/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js index 4c9913d825e..bf1581fb5b4 100644 --- a/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js +++ b/client/views/room/contextualBar/Info/RoomInfo/RoomInfo.js @@ -45,7 +45,7 @@ export const RoomInfoIcon = ({ name }) => ; export const Title = (props) => ; export const RoomInfo = function RoomInfo({ - name, + fname: name, description, archived, broadcast, diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 757da4c29ed..0d3caed4806 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -5,6 +5,8 @@ "__count__empty_rooms_will_be_removed_automatically__rooms__": "__count__ empty rooms will be removed automatically:
__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 ", "#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", diff --git a/server/startup/migrations/index.js b/server/startup/migrations/index.js index 307fd414164..8e55d2e71a7 100644 --- a/server/startup/migrations/index.js +++ b/server/startup/migrations/index.js @@ -210,4 +210,5 @@ import './v210'; import './v211'; import './v212'; import './v213'; +import './v214'; import './xrun'; diff --git a/server/startup/migrations/v214.js b/server/startup/migrations/v214.js new file mode 100644 index 00000000000..13385aef4a5 --- /dev/null +++ b/server/startup/migrations/v214.js @@ -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 } }); + }, +});