From 835aafcb179bccacae8057c6f9afa74d37c80ca2 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 25 Jun 2021 10:18:46 +0100 Subject: [PATCH] Type the rest of algorithms because tests are unhappy otherwise --- src/crypto/DeviceList.ts | 32 +- src/crypto/algorithms/base.ts | 46 +- src/crypto/algorithms/{index.js => index.ts} | 3 +- src/crypto/algorithms/megolm.js | 1788 ----------------- src/crypto/algorithms/megolm.ts | 1833 ++++++++++++++++++ src/crypto/algorithms/olm.js | 361 ---- src/crypto/algorithms/olm.ts | 355 ++++ src/crypto/index.ts | 7 +- src/models/event.ts | 2 +- 9 files changed, 2225 insertions(+), 2202 deletions(-) rename src/crypto/algorithms/{index.js => index.ts} (88%) delete mode 100644 src/crypto/algorithms/megolm.js create mode 100644 src/crypto/algorithms/megolm.ts delete mode 100644 src/crypto/algorithms/olm.js create mode 100644 src/crypto/algorithms/olm.ts diff --git a/src/crypto/DeviceList.ts b/src/crypto/DeviceList.ts index f5ec71d1a..71d3d364d 100644 --- a/src/crypto/DeviceList.ts +++ b/src/crypto/DeviceList.ts @@ -59,7 +59,7 @@ enum TrackingStatus { UpToDate, } -type DeviceInfoMap = Record>; +export type DeviceInfoMap = Record>; /** * @alias module:crypto/DeviceList @@ -70,7 +70,7 @@ export class DeviceList extends EventEmitter { // [device info] // } // } - private devices: DeviceInfoMap = {}; + private devices: Record> = {}; // userId -> { // [key info] @@ -315,7 +315,7 @@ export class DeviceList extends EventEmitter { * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. */ private getDevicesFromStore(userIds: string[]): DeviceInfoMap { - const stored = {}; + const stored: DeviceInfoMap = {}; userIds.map((u) => { stored[u] = {}; const devices = this.getStoredDevicesForUser(u) || []; @@ -463,27 +463,11 @@ export class DeviceList extends EventEmitter { /** * Replaces the list of devices for a user with the given device list * - * @param {string} u The user ID - * @param {Object} devs New device info for user + * @param {string} userId The user ID + * @param {Object} devices New device info for user */ - public storeDevicesForUser(u: string, devs: Record): void { - // remove previous devices from userByIdentityKey - if (this.devices[u] !== undefined) { - for (const [deviceId, dev] of Object.entries(this.devices[u])) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - delete this.userByIdentityKey[identityKey]; - } - } - - this.devices[u] = devs; - - // add new ones - for (const [deviceId, dev] of Object.entries(devs)) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - this.userByIdentityKey[identityKey] = u; - } + public storeDevicesForUser(userId: string, devices: Record): void { + this.setRawStoredDevicesForUser(userId, devices); this.dirty = true; } @@ -859,7 +843,7 @@ class DeviceListUpdateSerialiser { ); // put the updates into the object that will be returned as our results - const storage = {}; + const storage: Record = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); diff --git a/src/crypto/algorithms/base.ts b/src/crypto/algorithms/base.ts index 256a499de..7f687774b 100644 --- a/src/crypto/algorithms/base.ts +++ b/src/crypto/algorithms/base.ts @@ -24,7 +24,7 @@ import { MatrixClient } from "../../client"; import { Room } from "../../models/room"; import { OlmDevice } from "../OlmDevice"; import { MatrixEvent, RoomMember } from "../.."; -import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; import { DeviceInfo } from "../deviceinfo"; /** @@ -33,7 +33,9 @@ import { DeviceInfo } from "../deviceinfo"; * * @type {Object.} */ -export const ENCRYPTION_CLASSES: Record = {}; +export const ENCRYPTION_CLASSES: Record EncryptionAlgorithm> = {}; + +type DecryptionClassParams = Omit; /** * map of registered encryption algorithm classes. Map from string to {@link @@ -41,7 +43,7 @@ export const ENCRYPTION_CLASSES: Record = {}; * * @type {Object.} */ -export const DECRYPTION_CLASSES: Record = {}; +export const DECRYPTION_CLASSES: Record DecryptionAlgorithm> = {}; interface IParams { userId: string; @@ -50,6 +52,7 @@ interface IParams { olmDevice: OlmDevice; baseApis: MatrixClient; roomId: string; + config: object; } /** @@ -89,7 +92,7 @@ export abstract class EncryptionAlgorithm { * * @param {module:models/room} room the room the event is in */ - public abstract prepareToEncrypt(room: Room): void; + public prepareToEncrypt(room: Room): void {} /** * Encrypt a message event @@ -115,16 +118,16 @@ export abstract class EncryptionAlgorithm { * @public * @abstract */ - public abstract onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string); + public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} - public abstract reshareKeyWithDevice( + public reshareKeyWithDevice?( senderKey: string, sessionId: string, userId: string, device: DeviceInfo, ): Promise; - public forceDiscardSession?: () => void; + public forceDiscardSession?(): void; } /** @@ -140,13 +143,13 @@ export abstract class EncryptionAlgorithm { * from. Null for to-device events. */ export abstract class DecryptionAlgorithm { - private readonly userId: string; - private readonly crypto: Crypto; - private readonly olmDevice: OlmDevice; - private readonly baseApis: MatrixClient; - private readonly roomId: string; + protected readonly userId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; - constructor(params: Omit) { + constructor(params: DecryptionClassParams) { this.userId = params.userId; this.crypto = params.crypto; this.olmDevice = params.olmDevice; @@ -205,7 +208,7 @@ export abstract class DecryptionAlgorithm { * * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ - public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest) { + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } @@ -215,12 +218,13 @@ export abstract class DecryptionAlgorithm { * * @param {string} senderKey the sender's key */ - public async retryDecryptionFromSender(senderKey: string): Promise { + public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default + return false; } - public onRoomKeyWithheldEvent?: (event: MatrixEvent) => Promise; - public sendSharedHistoryInboundSessions?: (devicesByUser: Record) => Promise; + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; + public sendSharedHistoryInboundSessions?(devicesByUser: Record): Promise; } /** @@ -237,7 +241,7 @@ export abstract class DecryptionAlgorithm { export class DecryptionError extends Error { public readonly detailedString: string; - constructor(public readonly code: string, msg: string, details: Record) { + constructor(public readonly code: string, msg: string, details?: Record) { super(msg); this.code = code; this.name = 'DecryptionError'; @@ -245,7 +249,7 @@ export class DecryptionError extends Error { } } -function detailedStringForDecryptionError(err: DecryptionError, details: Record): string { +function detailedStringForDecryptionError(err: DecryptionError, details?: Record): string { let result = err.name + '[msg: ' + err.message; if (details) { @@ -289,8 +293,8 @@ export class UnknownDeviceError extends Error { */ export function registerAlgorithm( algorithm: string, - encryptor: EncryptionAlgorithm, - decryptor: DecryptionAlgorithm, + encryptor: new (params: IParams) => EncryptionAlgorithm, + decryptor: new (params: Omit) => DecryptionAlgorithm, ): void { ENCRYPTION_CLASSES[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; diff --git a/src/crypto/algorithms/index.js b/src/crypto/algorithms/index.ts similarity index 88% rename from src/crypto/algorithms/index.js rename to src/crypto/algorithms/index.ts index 0fb646cfe..3dd1158a0 100644 --- a/src/crypto/algorithms/index.js +++ b/src/crypto/algorithms/index.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 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. diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js deleted file mode 100644 index f457e6e6d..000000000 --- a/src/crypto/algorithms/megolm.js +++ /dev/null @@ -1,1788 +0,0 @@ -/* -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. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/megolm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, - UnknownDeviceError, -} from "./base"; - -import { WITHHELD_MESSAGES } from '../OlmDevice'; - -// determine whether the key can be shared with invitees -export function isRoomSharedHistory(room) { - const visibilityEvent = room.currentState && - room.currentState.getStateEvents("m.room.history_visibility", ""); - // NOTE: if the room visibility is unset, it would normally default to - // "world_readable". - // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) - // But we will be paranoid here, and treat it as a situation where the room - // is not shared-history - const visibility = visibilityEvent && visibilityEvent.getContent() && - visibilityEvent.getContent().history_visibility; - return ["world_readable", "shared"].includes(visibility); -} - -/** - * @private - * @constructor - * - * @param {string} sessionId - * @param {boolean} sharedHistory whether the session can be freely shared with - * other group members, according to the room history visibility settings - * - * @property {string} sessionId - * @property {Number} useCount number of times this session has been used - * @property {Number} creationTime when the session was created (ms since the epoch) - * - * @property {object} sharedWithDevices - * devices with which we have shared the session key - * userId -> {deviceId -> msgindex} - */ -function OutboundSessionInfo(sessionId, sharedHistory = false) { - this.sessionId = sessionId; - this.useCount = 0; - this.creationTime = new Date().getTime(); - this.sharedWithDevices = {}; - this.blockedDevicesNotified = {}; - this.sharedHistory = sharedHistory; -} - -/** - * Check if it's time to rotate the session - * - * @param {Number} rotationPeriodMsgs - * @param {Number} rotationPeriodMs - * @return {Boolean} - */ -OutboundSessionInfo.prototype.needsRotation = function( - rotationPeriodMsgs, rotationPeriodMs, -) { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || - sessionLifetime >= rotationPeriodMs - ) { - logger.log( - "Rotating megolm session after " + this.useCount + - " messages, " + sessionLifetime + "ms", - ); - return true; - } - - return false; -}; - -OutboundSessionInfo.prototype.markSharedWithDevice = function( - userId, deviceId, chainIndex, -) { - if (!this.sharedWithDevices[userId]) { - this.sharedWithDevices[userId] = {}; - } - 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. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - * - * @return {Boolean} true if we have shared the session with devices which aren't - * in devicesInRoom. - */ -OutboundSessionInfo.prototype.sharedWithTooManyDevices = function( - devicesInRoom, -) { - for (const userId in this.sharedWithDevices) { - if (!this.sharedWithDevices.hasOwnProperty(userId)) { - continue; - } - - if (!devicesInRoom.hasOwnProperty(userId)) { - logger.log("Starting new megolm session because we shared with " + userId); - return true; - } - - for (const deviceId in this.sharedWithDevices[userId]) { - if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { - continue; - } - - if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { - logger.log( - "Starting new megolm session because we shared with " + - userId + ":" + deviceId, - ); - return true; - } - } - } -}; - -/** - * Megolm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function MegolmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - - // the most recent attempt to set up a session. This is used to serialise - // the session setups, so that we have a race-free view of which session we - // are using, and which devices we have shared the keys with. It resolves - // with an OutboundSessionInfo (or undefined, for the first message in the - // room). - this._setupPromise = Promise.resolve(); - - // Map of outbound sessions by sessions ID. Used if we need a particular - // session (the session we're currently using to send is always obtained - // using _setupPromise). - this._outboundSessions = {}; - - // default rotation periods - this._sessionRotationPeriodMsgs = 100; - this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; - - if (params.config.rotation_period_ms !== undefined) { - this._sessionRotationPeriodMs = params.config.rotation_period_ms; - } - - if (params.config.rotation_period_msgs !== undefined) { - this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs; - } -} -utils.inherits(MegolmEncryption, EncryptionAlgorithm); - -/** - * @private - * - * @param {module:models/room} room - * @param {Object} devicesInRoom The devices in this room, indexed by user ID - * @param {Object} blocked The devices that are blocked, indexed by user ID - * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm - * session creation - * - * @return {Promise} Promise which resolves to the - * OutboundSessionInfo when setup is complete. - */ -MegolmEncryption.prototype._ensureOutboundSession = async function( - room, devicesInRoom, blocked, singleOlmCreationPhase, -) { - let session; - - // takes the previous OutboundSessionInfo, and considers whether to create - // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. - // - // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession) => { - session = oldSession; - - const sharedHistory = isRoomSharedHistory(room); - - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } - - // need to make a brand new session? - if (session && session.needsRotation(this._sessionRotationPeriodMsgs, - this._sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this._roomId}`); - this._outboundSessions[session.sessionId] = session; - } - - // now check if we need to share with any devices - const shareMap = {}; - - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this._olmDevice, this._baseApis, shareMap, - ); - - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions( - session, key, payload, olmSessions, - ); - logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); - const errorDevices = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers = []; - await this._shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); - await this._shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); - - await this._notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this._notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Notifying blocked devices in ${this._roomId}`); - // also, notify blocked devices that they're blocked - const blockedMap = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } - - await this._notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); - })(), - ]); - }; - - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; - } - - // first wait for the previous share to complete - const prom = this._setupPromise.then(prepareSession); - - // Ensure any failures are logged for debugging - prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this._roomId}`, e); - }); - - // _setupPromise resolves to `session` whether or not the share succeeds - this._setupPromise = prom.then(returnSession, returnSession); - - // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); -}; - -/** - * @private - * - * @param {boolean} sharedHistory - * - * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session - */ -MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { - const sessionId = this._olmDevice.createOutboundGroupSession(); - const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); - - await this._olmDevice.addInboundGroupSession( - this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, - key.key, { ed25519: this._olmDevice.deviceEd25519Key }, false, - { sharedHistory: sharedHistory }, - ); - - // don't wait for it to complete - this._crypto.backupManager.backupGroupSession( - this._olmDevice.deviceCurve25519Key, sessionId, - ); - - return new OutboundSessionInfo(sessionId, sharedHistory); -}; - -/** - * Determines what devices in devicesByUser don't have an olm session as given - * in devicemap. - * - * @private - * - * @param {object} devicemap the devices that have olm sessions, as returned by - * olmlib.ensureOlmSessionsForDevices. - * @param {object} devicesByUser a map of user IDs to array of deviceInfo - * @param {array} [noOlmDevices] an array to fill with devices that don't have - * olm sessions - * - * @return {array} an array of devices that don't have olm sessions. If - * noOlmDevices is specified, then noOlmDevices will be returned. - */ -MegolmEncryption.prototype._getDevicesWithoutSessions = function( - devicemap, devicesByUser, noOlmDevices, -) { - noOlmDevices = noOlmDevices || []; - - for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { - const sessionResults = devicemap[userId]; - - for (const deviceInfo of devicesToShareWith) { - const deviceId = deviceInfo.deviceId; - - const sessionResult = sessionResults[deviceId]; - if (!sessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - - noOlmDevices.push({ userId, deviceInfo }); - delete sessionResults[deviceId]; - - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - continue; - } - } - } - - return noOlmDevices; -}; - -/** - * Splits the user device map into multiple chunks to reduce the number of - * devices we encrypt to per API call. - * - * @private - * - * @param {object} devicesByUser map from userid to list of devices - * - * @return {array>} the blocked devices, split into chunks - */ -MegolmEncryption.prototype._splitDevices = function(devicesByUser) { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice = []; - const mapSlices = [currentSlice]; - - for (const [userId, userDevices] of Object.entries(devicesByUser)) { - for (const deviceInfo of Object.values(userDevices)) { - currentSlice.push({ - userId: userId, - deviceInfo: deviceInfo.device, - }); - } - - // We do this in the per-user loop as we prefer that all messages to the - // same user end up in the same API call to make it easier for the - // server (e.g. only have to send one EDU if a remote user, etc). This - // does mean that if a user has many devices we may go over the desired - // limit, but its not a hard limit so that is fine. - if (currentSlice.length > maxDevicesPerRequest) { - // the current slice is filled up. Start inserting into the next slice - currentSlice = []; - mapSlices.push(currentSlice); - } - } - if (currentSlice.length === 0) { - mapSlices.pop(); - } - return mapSlices; -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {number} chainIndex current chain index - * - * @param {object} userDeviceMap - * mapping from userId to deviceInfo - * - * @param {object} payload fields to include in the encrypted payload - * - * @return {Promise} Promise which resolves once the key sharing - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( - session, chainIndex, userDeviceMap, payload, -) { - const contentMap = {}; - - const promises = []; - for (let i = 0; i < userDeviceMap.length; i++) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - const val = userDeviceMap[i]; - const userId = val.userId; - const deviceInfo = val.deviceInfo; - const deviceId = deviceInfo.deviceId; - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = encryptedContent; - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - - return Promise.all(promises).then(() => { - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { - // 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.markSharedWithDevice( - userId, deviceId, chainIndex, - ); - } - } - }); - }); -}; - -/** - * @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 {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.deviceInfo; - const deviceInfo = blockedInfo.deviceInfo; - const deviceId = deviceInfo.deviceId; - - const message = Object.assign({}, payload); - message.code = blockedInfo.code; - message.reason = blockedInfo.reason; - if (message.code === "m.no_olm") { - delete message.room_id; - delete message.session_id; - } - - 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. - * - * @param {string} senderKey The key of the originating device for the session - * @param {string} sessionId ID of the outbound session to share - * @param {string} userId ID of the user who owns the target device - * @param {module:crypto/deviceinfo} device The target device - */ -MegolmEncryption.prototype.reshareKeyWithDevice = async function( - senderKey, sessionId, userId, device, -) { - const obSessionInfo = this._outboundSessions[sessionId]; - if (!obSessionInfo) { - logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); - return; - } - - // The chain index of the key we previously sent this device - if (obSessionInfo.sharedWithDevices[userId] === undefined) { - logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); - return; - } - const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; - if (sentChainIndex === undefined) { - logger.debug( - "megolm session ID " + sessionId + " never shared with device " + - userId + ":" + device.deviceId, - ); - return; - } - - // get the key from the inbound session: the outbound one will already - // have been ratcheted to the next chain index. - const key = await this._olmDevice.getInboundGroupSessionKey( - this._roomId, senderKey, sessionId, sentChainIndex, - ); - - if (!key) { - logger.warn( - `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, - ); - return; - } - - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [device], - }, - ); - - const payload = { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - device, - payload, - ); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [userId]: { - [device.deviceId]: encryptedContent, - }, - }); - logger.debug(`Re-shared key for megolm session ${sessionId} ` + - `with ${userId}:${device.deviceId}`); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key the session key as returned by - * OlmDevice.getOutboundGroupSessionKey - * - * @param {object} payload the base to-device message payload for sharing keys - * - * @param {object} devicesByUser - * map from userid to list of devices - * - * @param {array} errorDevices - * array that will be populated with the devices that we can't get an - * olm session for - * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param {Array} [failedServers] An array to fill with remote servers that - * failed to respond to one-time-key requests. - */ -MegolmEncryption.prototype._shareKeyWithDevices = async function( - session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, -) { - logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); - const devicemap = await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, - logger.withPrefix(`[${this._roomId}]`), - ); - logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); - - this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - - logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions(session, key, payload, devicemap); - logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); -}; - -MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( - session, key, payload, devicemap, -) { - const userDeviceMaps = this._splitDevices(devicemap); - - for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = - `megolm keys for ${session.sessionId} ` + - `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`; - try { - logger.debug(`Sharing ${taskDetail}`); - await this._encryptAndSendKeysToDevices( - session, key.chain_index, userDeviceMaps[i], payload, - ); - logger.debug(`Shared ${taskDetail}`); - } catch (e) { - logger.error(`Failed to share ${taskDetail}`); - throw e; - } - } -}; - -/** - * Notify devices that we weren't able to create olm sessions. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key - * - * @param {Array} failedDevices the devices that we were unable to - * create olm sessions for, as returned by _shareKeyWithDevices - */ -MegolmEncryption.prototype._notifyFailedOlmDevices = async function( - session, key, failedDevices, -) { - logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); - - // mark the devices that failed as "handled" because we don't want to try - // to claim a one-time-key for dead devices on every message. - for (const { userId, deviceInfo } of failedDevices) { - const deviceId = deviceInfo.deviceId; - - session.markSharedWithDevice( - userId, deviceId, key.chain_index, - ); - } - - const filteredFailedDevices = - await this._olmDevice.filterOutNotifiedErrorDevices( - failedDevices, - ); - logger.debug( - `Filtered down to ${filteredFailedDevices.length} error devices ` + - `in ${this._roomId}`, - ); - const blockedMap = {}; - for (const { userId, deviceInfo } of filteredFailedDevices) { - blockedMap[userId] = blockedMap[userId] || {}; - // we use a similar format to what - // olmlib.ensureOlmSessionsForDevices returns, so that - // we can use the same function to split - blockedMap[userId][deviceInfo.deviceId] = { - device: { - code: "m.no_olm", - reason: WITHHELD_MESSAGES["m.no_olm"], - deviceInfo, - }, - }; - } - - // send the notifications - await this._notifyBlockedDevices(session, blockedMap); - logger.debug( - `Notified ${filteredFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); -}; - -/** - * 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._splitDevices(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; - } - } -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -MegolmEncryption.prototype.prepareToEncrypt = function(room) { - if (this.encryptionPreparation) { - // We're already preparing something, so don't do anything else. - // FIXME: check if we need to restart - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; - logger.debug( - `Already started preparing to encrypt for ${this._roomId} ` + - `${elapsedTime} ms ago, skipping`, - ); - return; - } - - logger.debug(`Preparing to encrypt events for ${this._roomId}`); - - this.encryptionPreparationMetadata = { - startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this._roomId}`); - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this._removeUnknownDevices(devicesInRoom); - } - - logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(room, devicesInRoom, blocked, true); - - logger.debug(`Ready to encrypt events for ${this._roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this._roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - logger.log(`Starting to encrypt event for ${this._roomId}`); - - if (this.encryptionPreparation) { - // If we started sending keys, wait for it to be done. - // FIXME: check if we need to cancel - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - try { - await this.encryptionPreparation; - } catch (e) { - // ignore any errors -- if the preparation failed, we'll just - // restart everything here - } - } - - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - this._checkForUnknownDevices(devicesInRoom); - } - - const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); - const payloadJson = { - room_id: this._roomId, - type: eventType, - content: content, - }; - - const ciphertext = this._olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); - const encryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._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: this._deviceId, - }; - - session.useCount++; - return encryptedContent; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * This should not normally be necessary. - */ -MegolmEncryption.prototype.forceDiscardSession = function() { - this._setupPromise = this._setupPromise.then(() => null); -}; - -/** - * Checks the devices we're about to send to and see if any are entirely - * unknown to the user. If so, warn the user, and mark them as known to - * give the user a chance to go verify them before re-sending this message. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { - const unknownDevices = {}; - - Object.keys(devicesInRoom).forEach((userId)=>{ - Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ - const device = devicesInRoom[userId][deviceId]; - if (device.isUnverified() && !device.isKnown()) { - if (!unknownDevices[userId]) { - unknownDevices[userId] = {}; - } - unknownDevices[userId][deviceId] = device; - } - }); - }); - - if (Object.keys(unknownDevices).length) { - // it'd be kind to pass unknownDevices up to the user in this error - throw new UnknownDeviceError( - "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", unknownDevices); - } -}; - -/** - * Remove unknown devices from a set of devices. The devicesInRoom parameter - * will be modified. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._removeUnknownDevices = function(devicesInRoom) { - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, device] of Object.entries(userDevices)) { - if (device.isUnverified() && !device.isKnown()) { - delete userDevices[deviceId]; - } - } - - if (Object.keys(userDevices).length === 0) { - delete devicesInRoom[userId]; - } - } -}; - -/** - * Get the list of unblocked devices for all users in the room - * - * @param {module:models/room} room - * - * @return {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(); - const roomMembers = members.map(function(u) { - return u.userId; - }); - - // The global value is treated as a default for when rooms don't specify a value. - let isBlacklisting = this._crypto.getGlobalBlacklistUnverifiedDevices(); - if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { - isBlacklisting = room.getBlacklistUnverifiedDevices(); - } - - // We are happy to use a cached version here: we assume that if we already - // have a list of the user's devices, then we already share an e2e room - // with them, which means that they will have announced any new devices via - // device_lists in their /sync response. This cache should then be maintained - // using all the device_lists changes and left fields. - // See https://github.com/vector-im/element-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)) { - continue; - } - - const userDevices = devices[userId]; - for (const deviceId in userDevices) { - if (!userDevices.hasOwnProperty(deviceId)) { - continue; - } - - const deviceTrust = this._crypto.checkDeviceTrust(userId, deviceId); - - if (userDevices[deviceId].isBlocked() || - (!deviceTrust.isVerified() && 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, blocked]; -}; - -/** - * Megolm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function MegolmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); - - // events which we couldn't decrypt due to unknown sessions / indexes: map from - // senderKey|sessionId to Set of MatrixEvents - this._pendingEvents = {}; - - // this gets stubbed out by the unit tests. - this.olmlib = olmlib; -} -utils.inherits(MegolmDecryption, DecryptionAlgorithm); - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting, or rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -MegolmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || - !content.ciphertext - ) { - throw new DecryptionError( - "MEGOLM_MISSING_FIELDS", - "Missing fields in input", - ); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this._addEventToPendingList(event); - - let res; - try { - res = await this._olmDevice.decryptGroupMessage( - event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, - 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') { - this._requestKeysForEvent(event); - - errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; - } - - throw new DecryptionError( - errorCode, - e ? e.toString() : "Unknown Error: Error is undefined", { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - if (res === null) { - // We've got a message for a session we don't have. - // - // (XXX: We might actually have received this key since we started - // decrypting, in which case we'll have scheduled a retry, and this - // request will be redundant. We could probably check to see if the - // event is still in the pending list; if not, a retry will have been - // scheduled, so we needn't send out the request here.) - this._requestKeysForEvent(event); - - // See if there was a problem with the olm session at the time the - // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this._olmDevice.sessionMayHaveProblems( - content.sender_key, event.getTs() - 120000, - ); - if (problem) { - let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] - || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += - " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - problemDescription, - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - // success. We can remove the event from the pending list, if that hasn't - // already happened. - this._removeEventFromPendingList(event); - - const payload = JSON.parse(res.result); - - // belt-and-braces check that the room id matches that indicated by the HS - // (this is somewhat redundant, since the megolm session is scoped to the - // room, so neither the sender nor a MITM can lie about the room_id). - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "MEGOLM_BAD_ROOM", - "Message intended for room " + payload.room_id, - ); - } - - return { - clearEvent: payload, - senderCurve25519Key: res.senderKey, - claimedEd25519Key: res.keysClaimed.ed25519, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - untrusted: res.untrusted, - }; -}; - -MegolmDecryption.prototype._requestKeysForEvent = function(event) { - const wireContent = event.getWireContent(); - - const recipients = event.getKeyRequestRecipients(this._userId); - - this._crypto.requestRoomKey({ - room_id: event.getRoomId(), - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, recipients); -}; - -/** - * Add an event to the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._addEventToPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - if (!this._pendingEvents[senderKey]) { - this._pendingEvents[senderKey] = new Map(); - } - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents.has(sessionId)) { - senderPendingEvents.set(sessionId, new Set()); - } - senderPendingEvents.get(sessionId).add(event); -}; - -/** - * Remove an event from the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._removeEventFromPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - const senderPendingEvents = this._pendingEvents[senderKey]; - const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); - if (!pendingEvents) { - return; - } - - pendingEvents.delete(event); - if (pendingEvents.size === 0) { - senderPendingEvents.delete(senderKey); - } - if (senderPendingEvents.size === 0) { - delete this._pendingEvents[senderKey]; - } -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyEvent = function(event) { - const content = event.getContent(); - const sessionId = content.session_id; - let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; - let exportFormat = false; - let keysClaimed; - - if (!content.room_id || - !sessionId || - !content.session_key - ) { - logger.error("key event is missing fields"); - return; - } - - if (!senderKey) { - logger.error("key event has no sender key (not encrypted?)"); - return; - } - - if (event.getType() == "m.forwarded_room_key") { - exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } - - // copy content before we modify it - forwardingKeyChain = forwardingKeyChain.slice(); - forwardingKeyChain.push(senderKey); - - senderKey = content.sender_key; - if (!senderKey) { - logger.error("forwarded_room_key event is missing sender_key field"); - return; - } - - const ed25519Key = content.sender_claimed_ed25519_key; - if (!ed25519Key) { - logger.error( - `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, - ); - return; - } - - keysClaimed = { - ed25519: ed25519Key, - }; - } else { - keysClaimed = event.getKeysClaimed(); - } - - const extraSessionData = {}; - if (content["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { - // have another go at decrypting events sent with this session. - this._retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this._crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } - }); - }).then(() => { - // don't wait for the keys to be backed up for the server - this._crypto.backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { - logger.error(`Error handling m.room_key_event: ${e}`); - }); -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { - const content = event.getContent(); - const senderKey = content.sender_key; - - if (content.code === "m.no_olm") { - const sender = event.getSender(); - logger.warn( - `${sender}:${senderKey} was unable to establish an olm session with us`, - ); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one - - // Note: after we record that the olm session has had a problem, we - // trigger retrying decryption for all the messages from the sender's - // key, so that we can update the error message to indicate the olm - // session problem. - - if (await this._olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - logger.debug("New session already created. Not creating a new one."); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - return; - } - let device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this._crypto.downloadKeys([sender], false); - device = this._crypto.deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - logger.info( - "Couldn't find device for identity key " + senderKey + - ": not establishing session", - ); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); - this.retryDecryptionFromSender(senderKey); - return; - } - } - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { [sender]: [device] }, false, - ); - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - } else { - await this._olmDevice.addInboundGroupSessionWithheld( - content.room_id, senderKey, content.session_id, content.code, - content.reason, - ); - } -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { - const body = keyRequest.requestBody; - - return this._olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { - const userId = keyRequest.userId; - const deviceId = keyRequest.deviceId; - const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); - const body = keyRequest.requestBody; - - this.olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [deviceInfo], - }, - ).then((devicemap) => { - const olmSessionResult = devicemap[userId][deviceId]; - if (!olmSessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - logger.log( - "sharing keys for session " + body.sender_key + "|" - + body.session_id + " with device " - + userId + ":" + deviceId, - ); - - return this._buildKeyForwardingMessage( - body.room_id, body.sender_key, body.session_id, - ); - }).then((payload) => { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - return this.olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ).then(() => { - const contentMap = { - [userId]: { - [deviceId]: encryptedContent, - }, - }; - - // TODO: retries - return this._baseApis.sendToDevice("m.room.encrypted", contentMap); - }); - }); -}; - -MegolmDecryption.prototype._buildKeyForwardingMessage = async function( - roomId, senderKey, sessionId, -) { - const key = await this._olmDevice.getInboundGroupSessionKey( - roomId, senderKey, sessionId, - ); - - return { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; -}; - -/** - * @inheritdoc - * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} [opts={}] options for the import - * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted - * @param {string} [opts.source] where the key came from - */ -MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { - const extraSessionData = {}; - if (opts.untrusted) { - extraSessionData.untrusted = true; - } - if (session["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ).then(() => { - if (opts.source !== "backup") { - // don't wait for it to complete - this._crypto.backupManager.backupGroupSession( - session.sender_key, session.session_id, - ).catch((e) => { - // This throws if the upload failed, but this is fine - // since it will have written it to the db and will retry. - logger.log("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this._retryDecryption(session.sender_key, session.session_id); - }); -}; - -/** - * Have another go at decrypting events after we receive a key. Resolves once - * decryption has been re-attempted on all events. - * - * @private - * @param {String} senderKey - * @param {String} sessionId - * - * @return {Boolean} whether all messages were successfully decrypted - */ -MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - const pending = senderPendingEvents.get(sessionId); - if (!pending) { - return true; - } - - logger.debug("Retrying decryption on events", [...pending]); - - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto, { isRetry: true }); - } catch (e) { - // don't die if something goes wrong - } - })); - - // If decrypted successfully, they'll have been removed from _pendingEvents - return !((this._pendingEvents[senderKey] || {})[sessionId]); -}; - -MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - delete this._pendingEvents[senderKey]; - - await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto); - } catch (e) { - // don't die if something goes wrong - } - })); - })); - - return !this._pendingEvents[senderKey]; -}; - -MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); - - logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); - - const sharedHistorySessions = - await this._olmDevice.getSharedHistoryInboundGroupSessions( - this._roomId, - ); - logger.log("shared-history sessions", sharedHistorySessions); - for (const [senderKey, sessionId] of sharedHistorySessions) { - const payload = await this._buildKeyForwardingMessage( - this._roomId, senderKey, sessionId, - ); - - const promises = []; - const contentMap = {}; - for (const [userId, devices] of Object.entries(devicesByUser)) { - contentMap[userId] = {}; - for (const deviceInfo of devices) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - contentMap[userId][deviceInfo.deviceId] = encryptedContent; - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - } - await Promise.all(promises); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - await this._baseApis.sendToDevice("m.room.encrypted", contentMap); - } -}; - -registerAlgorithm( - olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, -); diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts new file mode 100644 index 000000000..af80a9489 --- /dev/null +++ b/src/crypto/algorithms/megolm.ts @@ -0,0 +1,1833 @@ +/* +Copyright 2015 - 2021 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/megolm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, + UnknownDeviceError, +} from "./base"; +import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { Room } from '../../models/room'; +import { DeviceInfo } from "../deviceinfo"; +import { IOlmSessionResult } from "../olmlib"; +import { DeviceInfoMap } from "../DeviceList"; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; + +// determine whether the key can be shared with invitees +export function isRoomSharedHistory(room: Room): boolean { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +interface IBlockedDevice { + code: string; + reason: string; + deviceInfo: DeviceInfo; +} + +interface IBlockedMap { + [userId: string]: { + [deviceId: string]: IBlockedDevice; + }; +} + +interface IOlmDevice { + userId: string; + deviceInfo: T; +} + +/* eslint-disable camelcase */ +interface IOutboundGroupSessionKey { + chain_index: number; + key: string; +} + +interface IMessage { + type: string; + content: { + algorithm: string; + room_id: string; + sender_key?: string; + sender_claimed_ed25519_key?: string; + session_id: string; + session_key: string; + chain_index: number; + forwarding_curve25519_key_chain?: string[]; + "org.matrix.msc3061.shared_history": boolean; + }; +} + +interface IKeyForwardingMessage extends IMessage { + type: "m.forwarded_room_key"; +} + +interface IPayload extends Partial { + code?: string; + reason?: string; + room_id?: string; + session_id?: string; + algorithm?: string; + sender_key?: string; +} +/* eslint-enable camelcase */ + +/** + * @private + * @constructor + * + * @param {string} sessionId + * @param {boolean} sharedHistory whether the session can be freely shared with + * other group members, according to the room history visibility settings + * + * @property {string} sessionId + * @property {Number} useCount number of times this session has been used + * @property {Number} creationTime when the session was created (ms since the epoch) + * + * @property {object} sharedWithDevices + * devices with which we have shared the session key + * userId -> {deviceId -> msgindex} + */ +class OutboundSessionInfo { + public useCount = 0; + public creationTime: number; + public sharedWithDevices: Record> = {}; + public blockedDevicesNotified: Record> = {}; + + constructor(public readonly sessionId: string, public readonly sharedHistory = false) { + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + * + * @param {Number} rotationPeriodMsgs + * @param {Number} rotationPeriodMs + * @return {Boolean} + */ + public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { + const sessionLifetime = new Date().getTime() - this.creationTime; + + if (this.useCount >= rotationPeriodMsgs || + sessionLifetime >= rotationPeriodMs + ) { + logger.log( + "Rotating megolm session after " + this.useCount + + " messages, " + sessionLifetime + "ms", + ); + return true; + } + + return false; + } + + public markSharedWithDevice(userId: string, deviceId: string, chainIndex: number): void { + if (!this.sharedWithDevices[userId]) { + this.sharedWithDevices[userId] = {}; + } + this.sharedWithDevices[userId][deviceId] = chainIndex; + } + + public markNotifiedBlockedDevice(userId: string, deviceId: string): void { + 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. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + * + * @return {Boolean} true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + public sharedWithTooManyDevices(devicesInRoom: Record>): boolean { + for (const userId in this.sharedWithDevices) { + if (!this.sharedWithDevices.hasOwnProperty(userId)) { + continue; + } + + if (!devicesInRoom.hasOwnProperty(userId)) { + logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + + for (const deviceId in this.sharedWithDevices[userId]) { + if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { + continue; + } + + if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { + logger.log( + "Starting new megolm session because we shared with " + + userId + ":" + deviceId, + ); + return true; + } + } + } + } +} + +/** + * Megolm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class MegolmEncryption extends EncryptionAlgorithm { + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + private setupPromise = Promise.resolve(undefined); + + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using setupPromise). + private outboundSessions: Record = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation: Promise; + private encryptionPreparationMetadata: { + startTime: number; + }; + + constructor(params) { + super(params); + + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @private + * + * @param {module:models/room} room + * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * @param {Object} blocked The devices that are blocked, indexed by user ID + * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm + * session creation + * + * @return {Promise} Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + private async ensureOutboundSession( + room: Room, + devicesInRoom: DeviceInfoMap, + blocked: IBlockedMap, + singleOlmCreationPhase = false, + ): Promise { + let session; + + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // Updates `session` to hold the final OutboundSessionInfo. + // + // returns a promise which resolves once the keyshare is successful. + const prepareSession = async (oldSession: OutboundSessionInfo) => { + session = oldSession; + + const sharedHistory = isRoomSharedHistory(room); + + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session && session.needsRotation(this.sessionRotationPeriodMsgs, + this.sessionRotationPeriodMs) + ) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session && session.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } + + // now check if we need to share with any devices + const shareMap = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); + + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`); + const errorDevices = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Notifying blocked devices in ${this.roomId}`); + // also, notify blocked devices that they're blocked + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} blocked devices in ${this.roomId}`); + })(), + ]); + }; + + // helper which returns the session prepared by prepareSession + function returnSession() { + return session; + } + + // first wait for the previous share to complete + const prom = this.setupPromise.then(prepareSession); + + // Ensure any failures are logged for debugging + prom.catch(e => { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); + }); + + // setupPromise resolves to `session` whether or not the share succeeds + this.setupPromise = prom.then(returnSession, returnSession); + + // but we return a promise which only resolves if the share was successful. + return prom.then(returnSession); + } + + /** + * @private + * + * @param {boolean} sharedHistory + * + * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + */ + private async prepareNewSession(sharedHistory: boolean): Promise { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + + await this.olmDevice.addInboundGroupSession( + this.roomId, this.olmDevice.deviceCurve25519Key, [], sessionId, + key.key, { ed25519: this.olmDevice.deviceEd25519Key }, false, + { sharedHistory }, + ); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key, sessionId); + + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @private + * + * @param {object} devicemap the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param {object} devicesByUser a map of user IDs to array of deviceInfo + * @param {array} [noOlmDevices] an array to fill with devices that don't have + * olm sessions + * + * @return {array} an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + private getDevicesWithoutSessions( + devicemap: Record>, + devicesByUser: Record, + noOlmDevices: IOlmDevice[] = [], + ): IOlmDevice[] { + for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { + const sessionResults = devicemap[userId]; + + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults[deviceId]; + if (!sessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ userId, deviceInfo }); + delete sessionResults[deviceId]; + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @private + * + * @param {object} devicesByUser map from userid to list of devices + * + * @return {array>} the blocked devices, split into chunks + */ + private splitDevices( + devicesByUser: Record>, + ): IOlmDevice[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice[] = []; + const mapSlices = [currentSlice]; + + for (const [userId, userDevices] of Object.entries(devicesByUser)) { + for (const deviceInfo of Object.values(userDevices)) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device, + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {number} chainIndex current chain index + * + * @param {object} userDeviceMap + * mapping from userId to deviceInfo + * + * @param {object} payload fields to include in the encrypted payload + * + * @return {Promise} Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + private encryptAndSendKeysToDevices( + session: OutboundSessionInfo, + chainIndex: number, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + const promises = []; + for (let i = 0; i < userDeviceMap.length; i++) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + const val = userDeviceMap[i]; + const userId = val.userId; + const deviceInfo = val.deviceInfo; + const deviceId = deviceInfo.deviceId; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = encryptedContent; + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + + return Promise.all(promises).then(() => { + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { + // 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.markSharedWithDevice( + userId, deviceId, chainIndex, + ); + } + } + }); + }); + } + + /** + * @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 {Promise} Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + private async sendBlockedNotificationsToDevices( + session: OutboundSessionInfo, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = Object.assign({}, payload); + message.code = blockedInfo.code; + message.reason = blockedInfo.reason; + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + + 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. + * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ + public async reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "megolm session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey( + this.roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [device], + }, + ); + + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + device, + payload, + ); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug(`Re-shared key for megolm session ${sessionId} with ${userId}:${device.deviceId}`); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param {object} payload the base to-device message payload for sharing keys + * + * @param {object} devicesByUser + * map from userid to list of devices + * + * @param {array} errorDevices + * array that will be populated with the devices that we can't get an + * olm session for + * + * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param {Array} [failedServers] An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + private async shareKeyWithDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicesByUser: Record, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ) { + logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, + logger.withPrefix(`[${this.roomId}]`), + ); + logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); + + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + + logger.debug(`Sharing keys with Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + logger.debug(`Shared keys with Olm sessions in ${this.roomId}`); + } + + private async shareKeyWithOlmSessions( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicemap: Record>, + ): Promise { + const userDeviceMaps = this.splitDevices(devicemap); + + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = + `megolm keys for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + logger.debug(`Sharing ${taskDetail}`); + await this.encryptAndSendKeysToDevices( + session, key.chain_index, userDeviceMaps[i], payload, + ); + logger.debug(`Shared ${taskDetail}`); + } catch (e) { + logger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key + * + * @param {Array} failedDevices the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + private async notifyFailedOlmDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + failedDevices: IOlmDevice[], + ): Promise { + logger.debug( + `Notifying ${failedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { userId, deviceInfo } of failedDevices) { + const deviceId = deviceInfo.deviceId; + + session.markSharedWithDevice( + userId, deviceId, key.chain_index, + ); + } + + const filteredFailedDevices = + await this.olmDevice.filterOutNotifiedErrorDevices( + failedDevices, + ); + logger.debug( + `Filtered down to ${filteredFailedDevices.length} error devices ` + + `in ${this.roomId}`, + ); + const blockedMap: Record> = {}; + for (const { userId, deviceInfo } of filteredFailedDevices) { + blockedMap[userId] = blockedMap[userId] || {}; + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap[userId][deviceInfo.deviceId] = { + device: { + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }, + }; + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + logger.debug( + `Notified ${filteredFailedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + } + + /** + * 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 + */ + private async notifyBlockedDevices( + session: OutboundSessionInfo, + devicesByUser: Record>, + ): Promise { + const payload: IPayload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + }; + + const userDeviceMaps = this.splitDevices(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; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + if (this.encryptionPreparation) { + // We're already preparing something, so don't do anything else. + // FIXME: check if we need to restart + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + logger.debug( + `Already started preparing to encrypt for ${this.roomId} ` + + `${elapsedTime} ms ago, skipping`, + ); + return; + } + + logger.debug(`Preparing to encrypt events for ${this.roomId}`); + + this.encryptionPreparationMetadata = { + startTime: Date.now(), + }; + this.encryptionPreparation = (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparationMetadata; + delete this.encryptionPreparation; + } + })(); + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + logger.log(`Starting to encrypt event for ${this.roomId}`); + + if (this.encryptionPreparation) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + this.checkForUnknownDevices(devicesInRoom); + } + + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content, + }; + + const ciphertext = this.olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.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: this.deviceId, + }; + + session.useCount++; + return encryptedContent; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * This should not normally be necessary. + */ + public forceDiscardSession(): void { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { + const unknownDevices = {}; + + Object.keys(devicesInRoom).forEach((userId)=>{ + Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ + const device = devicesInRoom[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + if (!unknownDevices[userId]) { + unknownDevices[userId] = {}; + } + unknownDevices[userId][deviceId] = device; + } + }); + }); + + if (Object.keys(unknownDevices).length) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", unknownDevices); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, device] of Object.entries(userDevices)) { + if (device.isUnverified() && !device.isKnown()) { + delete userDevices[deviceId]; + } + } + + if (Object.keys(userDevices).length === 0) { + delete devicesInRoom[userId]; + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param {module:models/room} room + * + * @return {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 + */ + private async getDevicesInRoom(room: Room): Promise<[DeviceInfoMap, IBlockedMap]> { + const members = await room.getEncryptionTargetMembers(); + const roomMembers = members.map(function(u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices(); + if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { + isBlacklisting = room.getBlacklistUnverifiedDevices(); + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + const blocked: IBlockedMap = {}; + // remove any blocked devices + for (const userId in devices) { + if (!devices.hasOwnProperty(userId)) { + continue; + } + + const userDevices = devices[userId]; + for (const deviceId in userDevices) { + if (!userDevices.hasOwnProperty(deviceId)) { + continue; + } + + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + + if (userDevices[deviceId].isBlocked() || + (!deviceTrust.isVerified() && isBlacklisting) + ) { + if (!blocked[userId]) { + blocked[userId] = {}; + } + const isBlocked = userDevices[deviceId].isBlocked(); + blocked[userId][deviceId] = { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevices[deviceId], + }; + delete userDevices[deviceId]; + } + } + } + + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class MegolmDecryption extends DecryptionAlgorithm { + // events which we couldn't decrypt due to unknown sessions / indexes: map from + // senderKey|sessionId to Set of MatrixEvents + private pendingEvents: Record>> = {}; + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + + if (!content.sender_key || !content.session_id || + !content.ciphertext + ) { + throw new DecryptionError( + "MEGOLM_MISSING_FIELDS", + "Missing fields in input", + ); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + + let res; + try { + res = await this.olmDevice.decryptGroupMessage( + event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, + 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') { + this.requestKeysForEvent(event); + + errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; + } + + throw new DecryptionError( + errorCode, + e ? e.toString() : "Unknown Error: Error is undefined", { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + if (res === null) { + // We've got a message for a session we don't have. + // + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems( + content.sender_key, event.getTs() - 120000, + ); + if (problem) { + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] + || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += + " Trying to create a new secure channel and re-requesting the keys."; + } + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + problemDescription, + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + "The sender's device has not sent us the keys for this message.", + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + // success. We can remove the event from the pending list, if that hasn't + // already happened. + this.removeEventFromPendingList(event); + + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "MEGOLM_BAD_ROOM", + "Message intended for room " + payload.room_id, + ); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted, + }; + } + + private requestKeysForEvent(event: MatrixEvent): void { + const wireContent = event.getWireContent(); + + const recipients = event.getKeyRequestRecipients(this.userId); + + this.crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, recipients); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private addEventToPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents[senderKey]) { + this.pendingEvents[senderKey] = new Map(); + } + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId).add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private removeEventFromPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents[senderKey]; + const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); + if (!pendingEvents) { + return; + } + + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(senderKey); + } + if (senderPendingEvents.size === 0) { + delete this.pendingEvents[senderKey]; + } + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + const sessionId = content.session_id; + let senderKey = event.getSenderKey(); + let forwardingKeyChain = []; + let exportFormat = false; + let keysClaimed; + + if (!content.room_id || + !sessionId || + !content.session_key + ) { + logger.error("key event is missing fields"); + return; + } + + if (!senderKey) { + logger.error("key event has no sender key (not encrypted?)"); + return; + } + + if (event.getType() == "m.forwarded_room_key") { + exportFormat = true; + forwardingKeyChain = content.forwarding_curve25519_key_chain; + if (!Array.isArray(forwardingKeyChain)) { + forwardingKeyChain = []; + } + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + senderKey = content.sender_key; + if (!senderKey) { + logger.error("forwarded_room_key event is missing sender_key field"); + return; + } + + const ed25519Key = content.sender_claimed_ed25519_key; + if (!ed25519Key) { + logger.error( + `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, + ); + return; + } + + keysClaimed = { + ed25519: ed25519Key, + }; + } else { + keysClaimed = event.getKeysClaimed(); + } + + const extraSessionData: any = {}; + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, extraSessionData, + ).then(() => { + // have another go at decrypting events sent with this session. + this.retryDecryption(senderKey, sessionId) + .then((success) => { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + if (success) { + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, + }); + } + }); + }).then(() => { + // don't wait for the keys to be backed up for the server + this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + }).catch((e) => { + logger.error(`Error handling m.room_key_event: ${e}`); + }); + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { + const content = event.getContent(); + const senderKey = content.sender_key; + + if (content.code === "m.no_olm") { + const sender = event.getSender(); + logger.warn( + `${sender}:${senderKey} was unable to establish an olm session with us`, + ); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + // Note: after we record that the olm session has had a problem, we + // trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + logger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + logger.info( + "Couldn't find device for identity key " + senderKey + + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + this.retryDecryptionFromSender(senderKey); + return; + } + } + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { [sender]: [device] }, false, + ); + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, senderKey, content.session_id, content.code, + content.reason, + ); + } + } + + /** + * @inheritdoc + */ + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { + const body = keyRequest.requestBody; + + return this.olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); + } + + /** + * @inheritdoc + */ + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + this.olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [deviceInfo], + }, + ).then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + + logger.log( + "sharing keys for session " + body.sender_key + "|" + + body.session_id + " with device " + + userId + ":" + deviceId, + ); + + return this.buildKeyForwardingMessage( + body.room_id, body.sender_key, body.session_id, + ); + }).then((payload) => { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + return this.olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ).then(() => { + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + }); + }); + } + + private async buildKeyForwardingMessage( + roomId: string, + senderKey: string, + sessionId: string, + ): Promise { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + } + + /** + * @inheritdoc + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} [opts={}] options for the import + * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted + * @param {string} [opts.source] where the key came from + */ + public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + const extraSessionData: any = {}; + if (opts.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ).then(() => { + if (opts.source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession( + session.sender_key, session.session_id, + ).catch((e) => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + logger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @private + * @param {String} senderKey + * @param {String} sessionId + * + * @return {Boolean} whether all messages were successfully decrypted + */ + private async retryDecryption(senderKey: string, sessionId: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + + logger.debug("Retrying decryption on events", [...pending]); + + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true }); + } catch (e) { + // don't die if something goes wrong + } + })); + + // If decrypted successfully, they'll have been removed from pendingEvents + return !((this.pendingEvents[senderKey] || {})[sessionId]); + } + + public async retryDecryptionFromSender(senderKey: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + delete this.pendingEvents[senderKey]; + + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + + return !this.pendingEvents[senderKey]; + } + + public async sendSharedHistoryInboundSessions(devicesByUser: Record): Promise { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + + logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); + + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + logger.log("shared-history sessions", sharedHistorySessions); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + const promises = []; + const contentMap = {}; + for (const [userId, devices] of Object.entries(devicesByUser)) { + contentMap[userId] = {}; + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + contentMap[userId][deviceInfo.deviceId] = encryptedContent; + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} + +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + +registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/src/crypto/algorithms/olm.js b/src/crypto/algorithms/olm.js deleted file mode 100644 index 74444b75a..000000000 --- a/src/crypto/algorithms/olm.js +++ /dev/null @@ -1,361 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/olm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { DeviceInfo } from "../deviceinfo"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, -} from "./base"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -/** - * Olm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function OlmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - this._sessionPrepared = false; - this._prepPromise = null; -} -utils.inherits(OlmEncryption, EncryptionAlgorithm); - -/** - * @private - - * @param {string[]} roomMembers list of currently-joined users in the room - * @return {Promise} Promise which resolves when setup is complete - */ -OlmEncryption.prototype._ensureSession = function(roomMembers) { - if (this._prepPromise) { - // prep already in progress - return this._prepPromise; - } - - if (this._sessionPrepared) { - // prep already done - return Promise.resolve(); - } - - const self = this; - this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) { - return self._crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(function() { - self._sessionPrepared = true; - }).finally(function() { - self._prepPromise = null; - }); - return this._prepPromise; -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - // pick the list of recipients based on the membership list. - // - // TODO: there is a race condition here! What if a new user turns up - // just as you are sending a secret message? - - const members = await room.getEncryptionTargetMembers(); - - const users = members.map(function(u) { - return u.userId; - }); - - const self = this; - await this._ensureSession(users); - - const payloadFields = { - room_id: room.roomId, - type: eventType, - content: content, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: self._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - const promises = []; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - const devices = self._crypto.getStoredDevicesForUser(userId); - - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - const key = deviceInfo.getIdentityKey(); - if (key == self._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - self._userId, self._deviceId, self._olmDevice, - userId, deviceInfo, payloadFields, - ), - ); - } - } - - return await Promise.all(promises).then(() => encryptedContent); -}; - -/** - * Olm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function OlmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); -} -utils.inherits(OlmDecryption, DecryptionAlgorithm); - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting. Rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -OlmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError( - "OLM_MISSING_CIPHERTEXT", - "Missing ciphertext", - ); - } - - if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { - throw new DecryptionError( - "OLM_NOT_INCLUDED_IN_RECIPIENTS", - "Not included in recipients", - ); - } - const message = ciphertext[this._olmDevice.deviceCurve25519Key]; - let payloadString; - - try { - payloadString = await this._decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError( - "OLM_BAD_ENCRYPTED_MESSAGE", - "Bad Encrypted Message", { - sender: deviceKey, - err: e, - }, - ); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this._userId) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT", - "Message was intented for " + payload.recipient, - ); - } - - if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT_KEY", - "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this._olmDevice.deviceEd25519Key, - }, - ); - } - - // check that the original sender matches what the homeserver told us, to - // avoid people masquerading as others. - // (this check is also provided via the sender's embedded ed25519 key, - // which is checked elsewhere). - if (payload.sender != event.getSender()) { - throw new DecryptionError( - "OLM_FORWARDED_MESSAGE", - "Message forwarded from " + payload.sender, { - reported_sender: event.getSender(), - }, - ); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "OLM_BAD_ROOM", - "Message intended for room " + payload.room_id, { - reported_room: event.room_id, - }, - ); - } - - const claimedKeys = payload.keys || {}; - - return { - clearEvent: payload, - senderCurve25519Key: deviceKey, - claimedEd25519Key: claimedKeys.ed25519 || null, - }; -}; - -/** - * Attempt to decrypt an Olm message - * - * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender - * @param {object} message message object, with 'type' and 'body' fields - * - * @return {string} payload, if decrypted successfully. - */ -OlmDecryption.prototype._decryptMessage = async function( - theirDeviceIdentityKey, message, -) { - // This is a wrapper that serialises decryptions of prekey messages, because - // otherwise we race between deciding we have no active sessions for the message - // and creating a new one, which we can only do once because it removes the OTK. - if (message.type !== 0) { - // not a prekey message: we can safely just try & decrypt it - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - } else { - const myPromise = this._olmDevice._olmPrekeyPromise.then(() => { - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - }); - // we want the error, but don't propagate it to the next decryption - this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); - return await myPromise; - } -}; - -OlmDecryption.prototype._reallyDecryptMessage = async function( - theirDeviceIdentityKey, message, -) { - const sessionIds = await this._olmDevice.getSessionIdsForDevice( - theirDeviceIdentityKey, - ); - - // try each session in turn. - const decryptionErrors = {}; - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i]; - try { - const payload = await this._olmDevice.decryptMessage( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - logger.log( - "Decrypted Olm message from " + theirDeviceIdentityKey + - " with session " + sessionId, - ); - return payload; - } catch (e) { - const foundSession = await this._olmDevice.matchesSession( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + ": " + e.message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = e.message; - } - } - - if (message.type !== 0) { - // not a prekey message, so it should have matched an existing session, but it - // didn't work. - - if (sessionIds.length === 0) { - throw new Error("No existing sessions"); - } - - throw new Error( - "Error decrypting non-prekey message with existing sessions: " + - JSON.stringify(decryptionErrors), - ); - } - - // prekey message which doesn't match any existing sessions: make a new - // session. - - let res; - try { - res = await this._olmDevice.createInboundSession( - theirDeviceIdentityKey, message.type, message.body, - ); - } catch (e) { - decryptionErrors["(new)"] = e.message; - throw new Error( - "Error decrypting prekey message: " + - JSON.stringify(decryptionErrors), - ); - } - - logger.log( - "created new inbound Olm session ID " + - res.session_id + " with " + theirDeviceIdentityKey, - ); - return res.payload; -}; - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts new file mode 100644 index 000000000..d45365ba9 --- /dev/null +++ b/src/crypto/algorithms/olm.ts @@ -0,0 +1,355 @@ +/* +Copyright 2016 - 2021 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. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/olm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { DeviceInfo } from "../deviceinfo"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, +} from "./base"; +import { Room } from '../../models/room'; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult } from "../index"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +interface IMessage { + type: number | string; + body: string; +} + +/** + * Olm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class OlmEncryption extends EncryptionAlgorithm { + private sessionPrepared = false; + private prepPromise: Promise = null; + + /** + * @private + + * @param {string[]} roomMembers list of currently-joined users in the room + * @return {Promise} Promise which resolves when setup is complete + */ + private ensureSession(roomMembers: string[]): Promise { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + + this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }).then(() => { + this.sessionPrepared = true; + }).finally(() => { + this.prepPromise = null; + }); + + return this.prepPromise; + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + + const users = members.map(function(u) { + return u.userId; + }); + + await this.ensureSession(users); + + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + const promises = []; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + const devices = this.crypto.getStoredDevicesForUser(userId); + + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, this.deviceId, this.olmDevice, + userId, deviceInfo, payloadFields, + ), + ); + } + } + + return await Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class OlmDecryption extends DecryptionAlgorithm { + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + + if (!ciphertext) { + throw new DecryptionError( + "OLM_MISSING_CIPHERTEXT", + "Missing ciphertext", + ); + } + + if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) { + throw new DecryptionError( + "OLM_NOT_INCLUDED_IN_RECIPIENTS", + "Not included in recipients", + ); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key]; + let payloadString; + + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new DecryptionError( + "OLM_BAD_ENCRYPTED_MESSAGE", + "Bad Encrypted Message", { + sender: deviceKey, + err: e, + }, + ); + } + + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT", + "Message was intented for " + payload.recipient, + ); + } + + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT_KEY", + "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key, + }, + ); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new DecryptionError( + "OLM_FORWARDED_MESSAGE", + "Message forwarded from " + payload.sender, { + reported_sender: event.getSender(), + }, + ); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "OLM_BAD_ROOM", + "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId(), + }, + ); + } + + const claimedKeys = payload.keys || {}; + + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null, + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender + * @param {object} message message object, with 'type' and 'body' fields + * + * @return {string} payload, if decrypted successfully. + */ + private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice._olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); + return await myPromise; + } + } + + private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors = {}; + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i]; + try { + const payload = await this.olmDevice.decryptMessage( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + logger.log( + "Decrypted Olm message from " + theirDeviceIdentityKey + + " with session " + sessionId, + ); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error( + "Error decrypting prekey message with existing session id " + + sessionId + ": " + e.message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = e.message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res; + try { + res = await this.olmDevice.createInboundSession( + theirDeviceIdentityKey, message.type, message.body, + ); + } catch (e) { + decryptionErrors["(new)"] = e.message; + throw new Error( + "Error decrypting prekey message: " + + JSON.stringify(decryptionErrors), + ); + } + + logger.log( + "created new inbound Olm session ID " + + res.session_id + " with " + theirDeviceIdentityKey, + ); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 132821219..b2b0c620e 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -28,7 +28,7 @@ import { ReEmitter } from '../ReEmitter'; import { logger } from '../logger'; import { OlmDevice } from "./OlmDevice"; import * as olmlib from "./olmlib"; -import { DeviceList } from "./DeviceList"; +import {DeviceInfoMap, DeviceList } from "./DeviceList"; import { DeviceInfo, IDevice } from "./deviceinfo"; import * as algorithms from "./algorithms"; import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; @@ -1923,10 +1923,7 @@ export class Crypto extends EventEmitter { * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ - public downloadKeys( - userIds: string[], - forceDownload?: boolean, - ): Promise>> { + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { return this.deviceList.downloadKeys(userIds, forceDownload); } diff --git a/src/models/event.ts b/src/models/event.ts index edcee4ad5..490988787 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -131,7 +131,7 @@ interface IDecryptionResult { } /* eslint-enable camelcase */ -interface IClearEvent { +export interface IClearEvent { type: string; content: Omit; unsigned?: IUnsigned;