From 01f6b3dfc6f8b64622691abc77d951cbe5252d12 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Mon, 6 Jan 2020 17:47:22 -0500 Subject: [PATCH] notify devices when we don't send them keys (#1135) and handle incoming notifications --- spec/integ/megolm-integ.spec.js | 6 + spec/unit/crypto/algorithms/megolm.spec.js | 153 ++++++++++ src/crypto/OlmDevice.js | 121 +++++++- src/crypto/algorithms/megolm.js | 264 +++++++++++++++--- src/crypto/index.js | 25 +- .../store/indexeddb-crypto-store-backend.js | 45 ++- src/crypto/store/indexeddb-crypto-store.js | 18 +- src/crypto/store/localStorage-crypto-store.js | 30 +- src/crypto/store/memory-crypto-store.js | 15 +- 9 files changed, 621 insertions(+), 56 deletions(-) diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 858568e94..459725e2c 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -617,6 +617,9 @@ describe("megolm", function() { ).respond(200, { event_id: '$event_id', }); + aliceTestClient.httpBackend.when( + 'PUT', '/sendToDevice/org.matrix.room_key.withheld/', + ).respond(200, {}); return Promise.all([ aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), @@ -714,6 +717,9 @@ describe("megolm", function() { event_id: '$event_id', }; }); + aliceTestClient.httpBackend.when( + 'PUT', '/sendToDevice/org.matrix.room_key.withheld/', + ).respond(200, {}); return Promise.all([ aliceTestClient.client.sendTextMessage(ROOM_ID, 'test2'), diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 88beac84f..57f6a3fe4 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -8,6 +8,9 @@ import testUtils from '../../../test-utils'; import OlmDevice from '../../../../lib/crypto/OlmDevice'; import Crypto from '../../../../lib/crypto'; import logger from '../../../../lib/logger'; +import TestClient from '../../../TestClient'; +import olmlib from '../../../../lib/crypto/olmlib'; +import Room from '../../../../lib/models/room'; const MatrixEvent = sdk.MatrixEvent; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; @@ -342,4 +345,154 @@ describe("MegolmDecryption", function() { expect(ct2.session_id).toEqual(ct1.session_id); }); }); + + it("notifies devices that have been blocked", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient1 = (new TestClient( + "@bob:example.com", "bobdevice1", + )).client; + const bobClient2 = (new TestClient( + "@bob:example.com", "bobdevice2", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient1.initCrypto(), + bobClient2.initCrypto(), + ]); + const aliceDevice = aliceClient._crypto._olmDevice; + const bobDevice1 = bobClient1._crypto._olmDevice; + const bobDevice2 = bobClient2._crypto._olmDevice; + + const encryptionCfg = { + "algorithm": "m.megolm.v1.aes-sha2", + }; + const roomId = "!someroom"; + const room = new Room(roomId, aliceClient, "@alice:example.com", {}); + room.getEncryptionTargetMembers = async function() { + return [{userId: "@bob:example.com"}]; + }; + room.setBlacklistUnverifiedDevices(true); + aliceClient.store.storeRoom(room); + await aliceClient.setRoomEncryption(roomId, encryptionCfg); + + const BOB_DEVICES = { + bobdevice1: { + user_id: "@bob:example.com", + device_id: "bobdevice1", + algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], + keys: { + "ed25519:Dynabook": bobDevice1.deviceEd25519Key, + "curve25519:Dynabook": bobDevice1.deviceCurve25519Key, + }, + verified: 0, + }, + bobdevice2: { + user_id: "@bob:example.com", + device_id: "bobdevice2", + algorithms: [olmlib.OLM_ALGORITHM, olmlib.MEGOLM_ALGORITHM], + keys: { + "ed25519:Dynabook": bobDevice2.deviceEd25519Key, + "curve25519:Dynabook": bobDevice2.deviceCurve25519Key, + }, + verified: -1, + }, + }; + + aliceClient._crypto._deviceList.storeDevicesForUser( + "@bob:example.com", BOB_DEVICES, + ); + aliceClient._crypto._deviceList.downloadKeys = async function(userIds) { + return this._getDevicesFromStore(userIds); + }; + + let run = false; + aliceClient.sendToDevice = async (msgtype, contentMap) => { + run = true; + expect(msgtype).toBe("org.matrix.room_key.withheld"); + delete contentMap["@bob:example.com"].bobdevice1.session_id; + delete contentMap["@bob:example.com"].bobdevice2.session_id; + expect(contentMap).toStrictEqual({ + '@bob:example.com': { + bobdevice1: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + code: 'm.unverified', + reason: + 'The sender has disabled encrypting to unverified devices.', + sender_key: aliceDevice.deviceCurve25519Key, + }, + bobdevice2: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + code: 'm.blacklisted', + reason: 'The sender has blocked you.', + sender_key: aliceDevice.deviceCurve25519Key, + }, + }, + }); + }; + + const event = new MatrixEvent({ + type: "m.room.message", + sender: "@alice:example.com", + room_id: roomId, + event_id: "$event", + content: { + msgtype: "m.text", + body: "secret", + }, + }); + await aliceClient._crypto.encryptEvent(event, room); + + expect(run).toBe(true); + + aliceClient.stopClient(); + bobClient1.stopClient(); + bobClient2.stopClient(); + }); + + it("throws an error describing why it doesn't have a key", async function() { + const aliceClient = (new TestClient( + "@alice:example.com", "alicedevice", + )).client; + const bobClient = (new TestClient( + "@bob:example.com", "bobdevice", + )).client; + await Promise.all([ + aliceClient.initCrypto(), + bobClient.initCrypto(), + ]); + const bobDevice = bobClient._crypto._olmDevice; + + const roomId = "!someroom"; + + aliceClient._crypto._onToDeviceEvent(new MatrixEvent({ + type: "org.matrix.room_key.withheld", + sender: "@bob:example.com", + content: { + algorithm: "m.megolm.v1.aes-sha2", + room_id: roomId, + session_id: "session_id", + sender_key: bobDevice.deviceCurve25519Key, + code: "m.blacklisted", + reason: "You have been blocked", + }, + })); + + await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({ + type: "m.room.encrypted", + sender: "@bob:example.com", + event_id: "$event", + room_id: roomId, + content: { + algorithm: "m.megolm.v1.aes-sha2", + ciphertext: "blablabla", + device_id: "bobdevice", + sender_key: bobDevice.deviceCurve25519Key, + session_id: "session_id", + }, + }))).rejects.toThrow("The sender has blocked you."); + }); }); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index b86b4f858..0c3161c34 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017, 2019 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,6 +19,8 @@ limitations under the License. import logger from '../logger'; import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; +import algorithms from './algorithms'; + // The maximum size of an event is 65K, and we base64 the content, so this is a // reasonable approximation to the biggest plaintext we can encrypt. const MAX_PLAINTEXT_LENGTH = 65536 * 3 / 4; @@ -818,9 +821,9 @@ OlmDevice.prototype._getInboundGroupSession = function( roomId, senderKey, sessionId, txn, func, ) { this._cryptoStore.getEndToEndInboundGroupSession( - senderKey, sessionId, txn, (sessionData) => { + senderKey, sessionId, txn, (sessionData, withheld) => { if (sessionData === null) { - func(null); + func(null, null, withheld); return; } @@ -834,7 +837,7 @@ OlmDevice.prototype._getInboundGroupSession = function( } this._unpickleInboundGroupSession(sessionData, (session) => { - func(session, sessionData); + func(session, sessionData, withheld); }); }, ); @@ -859,7 +862,10 @@ OlmDevice.prototype.addInboundGroupSession = async function( exportFormat, ) { await this._cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + 'readwrite', [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], (txn) => { /* if we already have this session, consider updating it */ this._getInboundGroupSession( roomId, senderKey, sessionId, txn, @@ -914,6 +920,60 @@ OlmDevice.prototype.addInboundGroupSession = async function( ); }; +/** + * Record in the data store why an inbound group session was withheld. + * + * @param {string} roomId room that the session belongs to + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {string} sessionId session identifier + * @param {string} code reason code + * @param {string} reason human-readable version of `code` + */ +OlmDevice.prototype.addInboundGroupSessionWithheld = async function( + roomId, senderKey, sessionId, code, reason, +) { + await this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD], + (txn) => { + this._cryptoStore.storeEndToEndInboundGroupSessionWithheld( + senderKey, sessionId, + { + room_id: roomId, + code: code, + reason: reason, + }, + txn, + ); + }, + ); +}; + +export const WITHHELD_MESSAGES = { + "m.unverified": "The sender has disabled encrypting to unverified devices.", + "m.blacklisted": "The sender has blocked you.", + "m.unauthorised": "You are not authorised to read the message.", + "m.no_olm": "Unable to establish a secure channel.", +}; + +/** + * Calculate the message to use for the exception when a session key is withheld. + * + * @param {object} withheld An object that describes why the key was withheld. + * + * @return {string} the message + * + * @private + */ +function _calculateWithheldMessage(withheld) { + if (withheld.code && withheld.code in WITHHELD_MESSAGES) { + return WITHHELD_MESSAGES[withheld.code]; + } else if (withheld.reason) { + return withheld.reason; + } else { + return "decryption key withheld"; + } +} + /** * Decrypt a received message with an inbound group session * @@ -934,16 +994,48 @@ OlmDevice.prototype.decryptGroupMessage = async function( roomId, senderKey, sessionId, body, eventId, timestamp, ) { let result; + // when the localstorage crypto store is used as an indexeddb backend, + // exceptions thrown from within the inner function are not passed through + // to the top level, so we store exceptions in a variable and raise them at + // the end + let error; await this._cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + 'readwrite', [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], (txn) => { this._getInboundGroupSession( - roomId, senderKey, sessionId, txn, (session, sessionData) => { + roomId, senderKey, sessionId, txn, (session, sessionData, withheld) => { if (session === null) { + if (withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + _calculateWithheldMessage(withheld), + { + session: senderKey + '|' + sessionId, + }, + ); + } result = null; return; } - const res = session.decrypt(body); + let res; + try { + res = session.decrypt(body); + } catch (e) { + if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX' && withheld) { + error = new algorithms.DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + _calculateWithheldMessage(withheld), + { + session: senderKey + '|' + sessionId, + }, + ); + } else { + error = e; + } + } let plaintext = res.plaintext; if (plaintext === undefined) { @@ -965,7 +1057,7 @@ OlmDevice.prototype.decryptGroupMessage = async function( msgInfo.id !== eventId || msgInfo.timestamp !== timestamp ) { - throw new Error( + error = new Error( "Duplicate message index, possible replay attack: " + messageIndexKey, ); @@ -994,6 +1086,9 @@ OlmDevice.prototype.decryptGroupMessage = async function( }, ); + if (error) { + throw error; + } return result; }; @@ -1009,7 +1104,10 @@ OlmDevice.prototype.decryptGroupMessage = async function( OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, sessionId) { let result; await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + 'readonly', [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], (txn) => { this._cryptoStore.getEndToEndInboundGroupSession( senderKey, sessionId, txn, (sessionData) => { if (sessionData === null) { @@ -1060,7 +1158,10 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( ) { let result; await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + 'readonly', [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], (txn) => { this._getInboundGroupSession( roomId, senderKey, sessionId, txn, (session, sessionData) => { if (session === null) { diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 8c60adf5c..909211d6a 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -28,6 +29,8 @@ const utils = require("../../utils"); const olmlib = require("../olmlib"); const base = require("./base"); +import {WITHHELD_MESSAGES} from '../OlmDevice'; + /** * @private * @constructor @@ -47,6 +50,7 @@ function OutboundSessionInfo(sessionId) { this.useCount = 0; this.creationTime = new Date().getTime(); this.sharedWithDevices = {}; + this.blockedDevicesNotified = {}; } @@ -84,6 +88,15 @@ OutboundSessionInfo.prototype.markSharedWithDevice = function( this.sharedWithDevices[userId][deviceId] = chainIndex; }; +OutboundSessionInfo.prototype.markNotifiedBlockedDevice = function( + userId, deviceId, +) { + if (!this.blockedDevicesNotified[userId]) { + this.blockedDevicesNotified[userId] = {}; + } + this.blockedDevicesNotified[userId][deviceId] = true; +}; + /** * Determine if this session has been shared with devices which it shouldn't * have been. @@ -166,11 +179,14 @@ utils.inherits(MegolmEncryption, base.EncryptionAlgorithm); * @private * * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * @param {Object} blocked The devices that are blocked, indexed by user ID * * @return {module:client.Promise} Promise which resolves to the * OutboundSessionInfo when setup is complete. */ -MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { +MegolmEncryption.prototype._ensureOutboundSession = async function( + devicesInRoom, blocked, +) { const self = this; let session; @@ -237,9 +253,36 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { } } - return self._shareKeyWithDevices( + await self._shareKeyWithDevices( session, shareMap, ); + + // are there any new blocked devices that we need to notify? + const blockedMap = {}; + for (const userId in blocked) { + if (!blocked.hasOwnProperty(userId)) { + continue; + } + + const userBlockedDevices = blocked[userId]; + + for (const deviceId in userBlockedDevices) { + if (!userBlockedDevices.hasOwnProperty(deviceId)) { + continue; + } + + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || []; + blockedMap[userId].push(userBlockedDevices[deviceId]); + } + } + } + + // notify blocked devices that they're blocked + await self._notifyBlockedDevices(session, blockedMap); } // helper which returns the session prepared by prepareSession @@ -363,6 +406,42 @@ MegolmEncryption.prototype._splitUserDeviceMap = function( return mapSlices; }; +/** + * @private + * + * @param {object} devicesByUser map from userid to list of devices + * + * @return {array>} the blocked devices, split into chunks + */ +MegolmEncryption.prototype._splitBlockedDevices = function(devicesByUser) { + const maxToDeviceMessagesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice = []; + const mapSlices = [currentSlice]; + + for (const userId of Object.keys(devicesByUser)) { + const userBlockedDevicesToShareWith = devicesByUser[userId]; + + for (const blockedInfo of userBlockedDevicesToShareWith) { + if (currentSlice.length > maxToDeviceMessagesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + + currentSlice.push({ + userId: userId, + blockedInfo: blockedInfo, + }); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; +}; + /** * @private * @@ -427,6 +506,49 @@ MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( }); }; +/** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {array} userDeviceMap list of blocked devices to notify + * + * @param {object} payload fields to include in the notification payload + * + * @return {module:client.Promise} Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ +MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( + session, userDeviceMap, payload, +) { + const contentMap = {}; + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.blockedInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = Object.assign({}, payload); + message.code = blockedInfo.code; + message.reason = blockedInfo.reason; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = message; + } + + await this._baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); + + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } +}; + /** * Re-shares a megolm session key with devices if the key has already been * sent to them. @@ -561,6 +683,42 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device } }; +/** + * Notify blocked devices that they have been blocked. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} devicesByUser + * map from userid to device ID to blocked data + */ +MegolmEncryption.prototype._notifyBlockedDevices = async function( + session, devicesByUser, +) { + const payload = { + room_id: this._roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + }; + + const userDeviceMaps = this._splitBlockedDevices(devicesByUser); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this._sendBlockedNotificationsToDevices( + session, userDeviceMaps[i], payload, + ); + logger.log(`Completed blacklist notification for ${session.sessionId} ` + + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + logger.log(`blacklist notification for ${session.sessionId} in ` + + `${this._roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); + + throw e; + } + } +}; + /** * @inheritdoc * @@ -570,42 +728,41 @@ MegolmEncryption.prototype._shareKeyWithDevices = async function(session, device * * @return {module:client.Promise} Promise which resolves to the new event body */ -MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { +MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { const self = this; logger.log(`Starting to encrypt event for ${this._roomId}`); - return this._getDevicesInRoom(room).then(function(devicesInRoom) { - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - self._checkForUnknownDevices(devicesInRoom); + const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - return self._ensureOutboundSession(devicesInRoom); - }).then(function(session) { - const payloadJson = { - room_id: self._roomId, - type: eventType, - content: content, - }; + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + self._checkForUnknownDevices(devicesInRoom); - const ciphertext = self._olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); + const session = await self._ensureOutboundSession(devicesInRoom, blocked); + const payloadJson = { + room_id: self._roomId, + type: eventType, + content: content, + }; - const encryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: self._olmDevice.deviceCurve25519Key, - ciphertext: ciphertext, - session_id: session.sessionId, - // Include our device ID so that recipients can send us a - // m.new_device message if they don't have our session key. - // XXX: Do we still need this now that m.new_device messages - // no longer exist since #483? - device_id: self._deviceId, - }; + const ciphertext = self._olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); - session.useCount++; - return encryptedContent; - }); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: self._olmDevice.deviceCurve25519Key, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: self._deviceId, + }; + + session.useCount++; + return encryptedContent; }; /** @@ -654,8 +811,11 @@ MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { * * @param {module:models/room} room * - * @return {module:client.Promise} Promise which resolves to a map - * from userId to deviceId to deviceInfo + * @return {module:client.Promise} Promise which resolves to an array whose + * first element is a map from userId to deviceId to deviceInfo indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked */ MegolmEncryption.prototype._getDevicesInRoom = async function(room) { const members = await room.getEncryptionTargetMembers(); @@ -676,6 +836,7 @@ MegolmEncryption.prototype._getDevicesInRoom = async function(room) { // using all the device_lists changes and left fields. // See https://github.com/vector-im/riot-web/issues/2305 for details. const devices = await this._crypto.downloadKeys(roomMembers, false); + const blocked = {}; // remove any blocked devices for (const userId in devices) { if (!devices.hasOwnProperty(userId)) { @@ -690,13 +851,27 @@ MegolmEncryption.prototype._getDevicesInRoom = async function(room) { if (userDevices[deviceId].isBlocked() || (userDevices[deviceId].isUnverified() && isBlacklisting) - ) { + ) { + if (!blocked[userId]) { + blocked[userId] = {}; + } + const blockedInfo = userDevices[deviceId].isBlocked() + ? { + code: "m.blacklisted", + reason: WITHHELD_MESSAGES["m.blacklisted"], + } + : { + code: "m.unverified", + reason: WITHHELD_MESSAGES["m.unverified"], + }; + blockedInfo.deviceInfo = userDevices[deviceId]; + blocked[userId][deviceId] = blockedInfo; delete userDevices[deviceId]; } } } - return devices; + return [devices, blocked]; }; /** @@ -756,6 +931,11 @@ MegolmDecryption.prototype.decryptEvent = async function(event) { event.getId(), event.getTs(), ); } catch (e) { + if (e.name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { @@ -963,6 +1143,20 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { }); }; +/** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ +MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { + const content = event.getContent(); + + await this._olmDevice.addInboundGroupSessionWithheld( + content.room_id, content.sender_key, content.session_id, content.code, + content.reason, + ); +}; + /** * @inheritdoc */ diff --git a/src/crypto/index.js b/src/crypto/index.js index c91d6f9c2..6e3f8e534 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -2,7 +2,7 @@ Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd Copyright 2018-2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019-2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -2408,6 +2408,8 @@ Crypto.prototype._onToDeviceEvent = function(event) { this._secretStorage._onRequestReceived(event); } else if (event.getType() === "m.secret.send") { this._secretStorage._onSecretReceived(event); + } else if (event.getType() === "org.matrix.room_key.withheld") { + this._onRoomKeyWithheldEvent(event); } else if (event.getContent().transaction_id) { this._onKeyVerificationMessage(event); } else if (event.getContent().msgtype === "m.bad.encrypted") { @@ -2447,6 +2449,27 @@ Crypto.prototype._onRoomKeyEvent = function(event) { alg.onRoomKeyEvent(event); }; +/** + * Handle a key withheld event + * + * @private + * @param {module:models/event.MatrixEvent} event key withheld event + */ +Crypto.prototype._onRoomKeyWithheldEvent = function(event) { + const content = event.getContent(); + + if (!content.room_id || !content.session_id || !content.algorithm + || !content.sender_key) { + logger.error("key withheld event is missing fields"); + return; + } + + const alg = this._getRoomDecryptor(content.room_id, content.algorithm); + if (alg.onRoomKeyWithheldEvent) { + alg.onRoomKeyWithheldEvent(event); + } +}; + /** * Handle a general key verification event. * diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index aeead378e..acac5c9f6 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -18,7 +19,7 @@ limitations under the License. import logger from '../../logger'; import utils from '../../utils'; -export const VERSION = 7; +export const VERSION = 8; /** * Implementation of a CryptoStore which is backed by an existing @@ -79,7 +80,7 @@ export class Backend { `enqueueing key request for ${requestBody.room_id} / ` + requestBody.session_id, ); - txn.oncomplete = () => { resolve(request); }; + txn.oncomplete = () => {resolve(request);}; const store = txn.objectStore("outgoingRoomKeyRequests"); store.add(request); }); @@ -428,14 +429,36 @@ export class Backend { // Inbound group sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { + let session = false; + let withheld = false; const objectStore = txn.objectStore("inbound_group_sessions"); const getReq = objectStore.get([senderCurve25519Key, sessionId]); getReq.onsuccess = function() { try { if (getReq.result) { - func(getReq.result.session); + session = getReq.result.session; } else { - func(null); + session = null; + } + if (withheld !== false) { + func(session, withheld); + } + } catch (e) { + abortWithException(txn, e); + } + }; + + const withheldObjectStore = txn.objectStore("inbound_group_sessions_withheld"); + const withheldGetReq = withheldObjectStore.get([senderCurve25519Key, sessionId]); + withheldGetReq.onsuccess = function() { + try { + if (withheldGetReq.result) { + withheld = withheldGetReq.result.session; + } else { + withheld = null; + } + if (session !== false) { + func(session, withheld); } } catch (e) { abortWithException(txn, e); @@ -499,6 +522,15 @@ export class Backend { }); } + storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key, sessionId, sessionData, txn, + ) { + const objectStore = txn.objectStore("inbound_group_sessions_withheld"); + objectStore.put({ + senderCurve25519Key, sessionId, session: sessionData, + }); + } + getEndToEndDeviceData(txn, func) { const objectStore = txn.objectStore("device_data"); const getReq = objectStore.get("-"); @@ -662,6 +694,11 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["senderCurve25519Key", "sessionId"], }); } + if (oldVersion < 8) { + db.createObjectStore("inbound_group_sessions_withheld", { + keyPath: ["senderCurve25519Key", "sessionId"], + }); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 68c10aec1..66c353681 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -104,7 +105,10 @@ export default class IndexedDBCryptoStore { // we can fall back to a different backend. return backend.doTxn( 'readonly', - [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], + [ + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, + IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + ], (txn) => { backend.getEndToEndInboundGroupSession('', '', txn, () => {}); }).then(() => { @@ -471,6 +475,16 @@ export default class IndexedDBCryptoStore { }); } + storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key, sessionId, sessionData, txn, + ) { + this._backendPromise.then(backend => { + backend.storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key, sessionId, sessionData, txn, + ); + }); + } + // End-to-end device tracking /** @@ -607,6 +621,8 @@ export default class IndexedDBCryptoStore { IndexedDBCryptoStore.STORE_ACCOUNT = 'account'; IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; +IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD + = 'inbound_group_sessions_withheld'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup'; diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index 0f304fbb1..1f8220d15 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -1,5 +1,6 @@ /* Copyright 2017, 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -32,6 +33,7 @@ const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; const KEY_CROSS_SIGNING_KEYS = E2E_PREFIX + "cross_signing_keys"; const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; +const KEY_INBOUND_SESSION_WITHHELD_PREFIX = E2E_PREFIX + "inboundgroupsessions.withheld/"; const KEY_ROOMS_PREFIX = E2E_PREFIX + "rooms/"; const KEY_SESSIONS_NEEDING_BACKUP = E2E_PREFIX + "sessionsneedingbackup"; @@ -43,6 +45,10 @@ function keyEndToEndInboundGroupSession(senderKey, sessionId) { return KEY_INBOUND_SESSION_PREFIX + senderKey + "/" + sessionId; } +function keyEndToEndInboundGroupSessionWithheld(senderKey, sessionId) { + return KEY_INBOUND_SESSION_WITHHELD_PREFIX + senderKey + "/" + sessionId; +} + function keyEndToEndRoomsPrefix(roomId) { return KEY_ROOMS_PREFIX + roomId; } @@ -125,10 +131,16 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { - func(getJsonItem( - this.store, - keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), - )); + func( + getJsonItem( + this.store, + keyEndToEndInboundGroupSession(senderCurve25519Key, sessionId), + ), + getJsonItem( + this.store, + keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), + ), + ); } getAllEndToEndInboundGroupSessions(txn, func) { @@ -170,6 +182,16 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { ); } + storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key, sessionId, sessionData, txn, + ) { + setJsonItem( + this.store, + keyEndToEndInboundGroupSessionWithheld(senderCurve25519Key, sessionId), + sessionData, + ); + } + getEndToEndDeviceData(txn, func) { func(getJsonItem( this.store, KEY_DEVICE_DATA, diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index eca79f037..952af6696 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -1,6 +1,7 @@ /* Copyright 2017 Vector Creations Ltd Copyright 2018 New Vector Ltd +Copyright 2020 The Matrix.org Foundation C.I.C. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -37,6 +38,7 @@ export default class MemoryCryptoStore { this._sessions = {}; // Map of {senderCurve25519Key+'/'+sessionId -> session data object} this._inboundGroupSessions = {}; + this._inboundGroupSessionsWithheld = {}; // Opaque device data object this._deviceData = null; // roomId -> Opaque roomInfo object @@ -276,7 +278,11 @@ export default class MemoryCryptoStore { // Inbound Group Sessions getEndToEndInboundGroupSession(senderCurve25519Key, sessionId, txn, func) { - func(this._inboundGroupSessions[senderCurve25519Key+'/'+sessionId] || null); + const k = senderCurve25519Key+'/'+sessionId; + func( + this._inboundGroupSessions[k] || null, + this._inboundGroupSessionsWithheld[k] || null, + ); } getAllEndToEndInboundGroupSessions(txn, func) { @@ -306,6 +312,13 @@ export default class MemoryCryptoStore { this._inboundGroupSessions[senderCurve25519Key+'/'+sessionId] = sessionData; } + storeEndToEndInboundGroupSessionWithheld( + senderCurve25519Key, sessionId, sessionData, txn, + ) { + const k = senderCurve25519Key+'/'+sessionId; + this._inboundGroupSessionsWithheld[k] = sessionData; + } + // Device Data getEndToEndDeviceData(txn, func) {