UNPKG

matrix-js-sdk

Version:
1,132 lines (1,073 loc) 65.1 kB
"use strict"; var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault"); Object.defineProperty(exports, "__esModule", { value: true }); exports.MegolmEncryption = exports.MegolmDecryption = void 0; exports.isRoomSharedHistory = isRoomSharedHistory; var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty")); var _uuid = require("uuid"); var _logger = require("../../logger"); var olmlib = _interopRequireWildcard(require("../olmlib")); var _base = require("./base"); var _OlmDevice = require("../OlmDevice"); var _event = require("../../@types/event"); var _OutgoingRoomKeyRequestManager = require("../OutgoingRoomKeyRequestManager"); function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); } function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; } function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; } function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2.default)(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; } // determine whether the key can be shared with invitees function isRoomSharedHistory(room) { var _room$currentState, _visibilityEvent$getC; const visibilityEvent = room === null || room === void 0 ? void 0 : (_room$currentState = room.currentState) === null || _room$currentState === void 0 ? void 0 : _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 === null || visibilityEvent === void 0 ? void 0 : (_visibilityEvent$getC = visibilityEvent.getContent()) === null || _visibilityEvent$getC === void 0 ? void 0 : _visibilityEvent$getC.history_visibility; return ["world_readable", "shared"].includes(visibility); } /** * @internal */ class OutboundSessionInfo { /** number of times this session has been used */ /** when the session was created (ms since the epoch) */ /** devices with which we have shared the session key `userId -> {deviceId -> SharedWithData}` */ /** * @param sharedHistory - whether the session can be freely shared with * other group members, according to the room history visibility settings */ constructor(sessionId, sharedHistory = false) { this.sessionId = sessionId; this.sharedHistory = sharedHistory; (0, _defineProperty2.default)(this, "useCount", 0); (0, _defineProperty2.default)(this, "creationTime", void 0); (0, _defineProperty2.default)(this, "sharedWithDevices", {}); (0, _defineProperty2.default)(this, "blockedDevicesNotified", {}); this.creationTime = new Date().getTime(); } /** * Check if it's time to rotate the session */ needsRotation(rotationPeriodMsgs, rotationPeriodMs) { const sessionLifetime = new Date().getTime() - this.creationTime; if (this.useCount >= rotationPeriodMsgs || sessionLifetime >= rotationPeriodMs) { _logger.logger.log("Rotating megolm session after " + this.useCount + " messages, " + sessionLifetime + "ms"); return true; } return false; } markSharedWithDevice(userId, deviceId, deviceKey, chainIndex) { if (!this.sharedWithDevices[userId]) { this.sharedWithDevices[userId] = {}; } this.sharedWithDevices[userId][deviceId] = { deviceKey, messageIndex: chainIndex }; } markNotifiedBlockedDevice(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 devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. * * @returns true if we have shared the session with devices which aren't * in devicesInRoom. */ sharedWithTooManyDevices(devicesInRoom) { for (const userId in this.sharedWithDevices) { if (!this.sharedWithDevices.hasOwnProperty(userId)) { continue; } if (!devicesInRoom.hasOwnProperty(userId)) { _logger.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.logger.log("Starting new megolm session because we shared with " + userId + ":" + deviceId); return true; } } } return false; } } /** * Megolm encryption implementation * * @param params - parameters, as per {@link EncryptionAlgorithm} */ class MegolmEncryption extends _base.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). // 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). constructor(params) { var _params$config$rotati, _params$config, _params$config$rotati2, _params$config2; super(params); (0, _defineProperty2.default)(this, "setupPromise", Promise.resolve(null)); (0, _defineProperty2.default)(this, "outboundSessions", {}); (0, _defineProperty2.default)(this, "sessionRotationPeriodMsgs", void 0); (0, _defineProperty2.default)(this, "sessionRotationPeriodMs", void 0); (0, _defineProperty2.default)(this, "encryptionPreparation", void 0); (0, _defineProperty2.default)(this, "roomId", void 0); this.roomId = params.roomId; this.sessionRotationPeriodMsgs = (_params$config$rotati = (_params$config = params.config) === null || _params$config === void 0 ? void 0 : _params$config.rotation_period_msgs) !== null && _params$config$rotati !== void 0 ? _params$config$rotati : 100; this.sessionRotationPeriodMs = (_params$config$rotati2 = (_params$config2 = params.config) === null || _params$config2 === void 0 ? void 0 : _params$config2.rotation_period_ms) !== null && _params$config$rotati2 !== void 0 ? _params$config$rotati2 : 7 * 24 * 3600 * 1000; } /** * @internal * * @param devicesInRoom - The devices in this room, indexed by user ID * @param blocked - The devices that are blocked, indexed by user ID * @param singleOlmCreationPhase - Only perform one round of olm * session creation * * This method updates the setupPromise field of the class by chaining a new * call on top of the existing promise, and then catching and discarding any * errors that might happen while setting up the outbound group session. This * is done to ensure that `setupPromise` always resolves to `null` or the * `OutboundSessionInfo`. * * Using `>>=` to represent the promise chaining operation, it does the * following: * * ``` * setupPromise = previousSetupPromise >>= setup >>= discardErrors * ``` * * The initial value for the `setupPromise` is a promise that resolves to * `null`. The forceDiscardSession() resets setupPromise to this initial * promise. * * @returns Promise which resolves to the * OutboundSessionInfo when setup is complete. */ async ensureOutboundSession(room, devicesInRoom, blocked, singleOlmCreationPhase = false) { // takes the previous OutboundSessionInfo, and considers whether to create // a new one. Also shares the key with any (new) devices in the room. // // returns a promise which resolves once the keyshare is successful. const setup = async oldSession => { const sharedHistory = isRoomSharedHistory(room); const session = await this.prepareSession(devicesInRoom, sharedHistory, oldSession); await this.shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, session); return session; }; // first wait for the previous share to complete const fallible = this.setupPromise.then(setup); // Ensure any failures are logged for debugging and make sure that the // promise chain remains unbroken // // setupPromise resolves to `null` or the `OutboundSessionInfo` whether // or not the share succeeds this.setupPromise = fallible.catch(e => { _logger.logger.error(`Failed to setup outbound session in ${this.roomId}`, e); return null; }); // but we return a promise which only resolves if the share was successful. return fallible; } async prepareSession(devicesInRoom, sharedHistory, session) { var _session, _session2; // history visibility changed if (session && sharedHistory !== session.sharedHistory) { session = null; } // need to make a brand new session? if ((_session = session) !== null && _session !== void 0 && _session.needsRotation(this.sessionRotationPeriodMsgs, this.sessionRotationPeriodMs)) { _logger.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 ((_session2 = session) !== null && _session2 !== void 0 && _session2.sharedWithTooManyDevices(devicesInRoom)) { session = null; } if (!session) { _logger.logger.log(`Starting new megolm session for room ${this.roomId}`); session = await this.prepareNewSession(sharedHistory); _logger.logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this.roomId}`); this.outboundSessions[session.sessionId] = session; } return session; } async shareSession(devicesInRoom, sharedHistory, singleOlmCreationPhase, blocked, 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.logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`, olmSessions); await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); _logger.logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); })(), (async () => { _logger.logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`, devicesWithoutSession); 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.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.logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); await this.shareKeyWithDevices(session, key, payload, retryDevices, failedDevices, 30000); _logger.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.logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); })(), (async () => { _logger.logger.debug(`There are ${Object.entries(blocked).length} blocked devices in ${this.roomId}`, Object.entries(blocked)); // also, notify newly blocked devices that they're blocked _logger.logger.debug(`Notifying newly blocked devices in ${this.roomId}`); 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.logger.debug(`Notified ${blockedCount} newly blocked devices in ${this.roomId}`, blockedMap); })()]); } /** * @internal * * * @returns session */ async prepareNewSession(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 }); // 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. * * @internal * * @param devicemap - the devices that have olm sessions, as returned by * olmlib.ensureOlmSessionsForDevices. * @param devicesByUser - a map of user IDs to array of deviceInfo * @param noOlmDevices - an array to fill with devices that don't have * olm sessions * * @returns an array of devices that don't have olm sessions. If * noOlmDevices is specified, then noOlmDevices will be returned. */ getDevicesWithoutSessions(devicemap, devicesByUser, 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. * * @internal * * @param devicesByUser - map from userid to list of devices * * @returns the blocked devices, split into chunks */ splitDevices(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; } /** * @internal * * * @param chainIndex - current chain index * * @param userDeviceMap - mapping from userId to deviceInfo * * @param payload - fields to include in the encrypted payload * * @returns Promise which resolves once the key sharing * for the given userDeviceMap is generated and has been sent. */ encryptAndSendKeysToDevices(session, chainIndex, devices, payload) { return this.crypto.encryptAndSendToDevices(devices, payload).then(() => { // store that we successfully uploaded the keys of the current slice for (const device of devices) { session.markSharedWithDevice(device.userId, device.deviceInfo.deviceId, device.deviceInfo.getIdentityKey(), chainIndex); } }).catch(error => { _logger.logger.error("failed to encryptAndSendToDevices", error); throw error; }); } /** * @internal * * * @param userDeviceMap - list of blocked devices to notify * * @param payload - fields to include in the notification payload * * @returns Promise which resolves once the notifications * for the given userDeviceMap is generated and has been sent. */ async sendBlockedNotificationsToDevices(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 = _objectSpread(_objectSpread({}, payload), {}, { code: blockedInfo.code, reason: blockedInfo.reason, [_event.ToDeviceMessageId]: (0, _uuid.v4)() }); 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("m.room_key.withheld", contentMap); // record the fact that we notified these blocked devices 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 senderKey - The key of the originating device for the session * @param sessionId - ID of the outbound session to share * @param userId - ID of the user who owns the target device * @param device - The target device */ async reshareKeyWithDevice(senderKey, sessionId, userId, device) { const obSessionInfo = this.outboundSessions[sessionId]; if (!obSessionInfo) { _logger.logger.debug(`megolm session ${senderKey}|${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.logger.debug(`megolm session ${senderKey}|${sessionId} never shared with user ${userId}`); return; } const sessionSharedData = obSessionInfo.sharedWithDevices[userId][device.deviceId]; if (sessionSharedData === undefined) { _logger.logger.debug(`megolm session ${senderKey}|${sessionId} never shared with device ${userId}:${device.deviceId}`); return; } if (sessionSharedData.deviceKey !== device.getIdentityKey()) { _logger.logger.warn(`Megolm session ${senderKey}|${sessionId} has been shared with device ${device.deviceId} but ` + `with identity key ${sessionSharedData.deviceKey}. Key is now ${device.getIdentityKey()}!`); 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, sessionSharedData.messageIndex); if (!key) { _logger.logger.warn(`No inbound session key found for megolm session ${senderKey}|${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: {}, [_event.ToDeviceMessageId]: (0, _uuid.v4)() }; 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.logger.debug(`Re-shared key for megolm session ${senderKey}|${sessionId} with ${userId}:${device.deviceId}`); } /** * @internal * * * @param key - the session key as returned by * OlmDevice.getOutboundGroupSessionKey * * @param payload - the base to-device message payload for sharing keys * * @param devicesByUser - map from userid to list of devices * * @param errorDevices - array that will be populated with the devices that we can't get an * olm session for * * @param otkTimeout - The timeout in milliseconds when requesting * one-time keys for establishing new olm sessions. * * @param failedServers - An array to fill with remote servers that * failed to respond to one-time-key requests. */ async shareKeyWithDevices(session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers) { var _logger$withPrefix; _logger.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 = _logger.logger.withPrefix) === null || _logger$withPrefix === void 0 ? void 0 : _logger$withPrefix.call(_logger.logger, `[${this.roomId}]`)); _logger.logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); _logger.logger.debug(`Sharing keys with newly created Olm sessions in ${this.roomId}`); await this.shareKeyWithOlmSessions(session, key, payload, devicemap); _logger.logger.debug(`Shared keys with newly created Olm sessions in ${this.roomId}`); } async shareKeyWithOlmSessions(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.logger.debug(`Sharing ${taskDetail}`, userDeviceMaps[i].map(d => `${d.userId}/${d.deviceInfo.deviceId}`)); await this.encryptAndSendKeysToDevices(session, key.chain_index, userDeviceMaps[i], payload); _logger.logger.debug(`Shared ${taskDetail}`); } catch (e) { _logger.logger.error(`Failed to share ${taskDetail}`); throw e; } } } /** * Notify devices that we weren't able to create olm sessions. * * * * @param failedDevices - the devices that we were unable to * create olm sessions for, as returned by shareKeyWithDevices */ async notifyFailedOlmDevices(session, key, failedDevices) { _logger.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, deviceInfo.getIdentityKey(), key.chain_index); } const unnotifiedFailedDevices = await this.olmDevice.filterOutNotifiedErrorDevices(failedDevices); _logger.logger.debug(`Need to notify ${unnotifiedFailedDevices.length} failed devices ` + `which haven't been notified before in ${this.roomId}`); const blockedMap = {}; for (const { userId, deviceInfo } of unnotifiedFailedDevices) { 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: _OlmDevice.WITHHELD_MESSAGES["m.no_olm"], deviceInfo } }; } // send the notifications await this.notifyBlockedDevices(session, blockedMap); _logger.logger.debug(`Notified ${unnotifiedFailedDevices.length} devices we failed to ` + `create Olm sessions in ${this.roomId}`); } /** * Notify blocked devices that they have been blocked. * * * @param devicesByUser - map from userid to device ID to blocked data */ async notifyBlockedDevices(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.logger.log(`Completed blacklist notification for ${session.sessionId} ` + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`); } catch (e) { _logger.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 room - the room the event is in */ prepareToEncrypt(room) { if (this.encryptionPreparation != null) { // 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.encryptionPreparation.startTime; _logger.logger.debug(`Already started preparing to encrypt for ${this.roomId} ` + `${elapsedTime} ms ago, skipping`); return; } _logger.logger.debug(`Preparing to encrypt events for ${this.roomId}`); this.encryptionPreparation = { startTime: Date.now(), promise: (async () => { try { _logger.logger.debug(`Getting devices in ${this.roomId}`); const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); if (this.crypto.globalErrorOnUnknownDevices) { // 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.logger.debug(`Ensuring outbound session in ${this.roomId}`); await this.ensureOutboundSession(room, devicesInRoom, blocked, true); _logger.logger.debug(`Ready to encrypt events for ${this.roomId}`); } catch (e) { _logger.logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); } finally { delete this.encryptionPreparation; } })() }; } /** * @param content - plaintext event content * * @returns Promise which resolves to the new event body */ async encryptMessage(room, eventType, content) { _logger.logger.log(`Starting to encrypt event for ${this.roomId}`); if (this.encryptionPreparation != null) { // 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.promise; } catch (e) { // ignore any errors -- if the preparation failed, we'll just // restart everything here } } /** * When using in-room messages and the room has encryption enabled, * clients should ensure that encryption does not hinder the verification. */ const forceDistributeToUnverified = this.isVerificationEvent(eventType, content); const [devicesInRoom, blocked] = await this.getDevicesInRoom(room, forceDistributeToUnverified); // 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.globalErrorOnUnknownDevices) { 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; } isVerificationEvent(eventType, content) { switch (eventType) { case _event.EventType.KeyVerificationCancel: case _event.EventType.KeyVerificationDone: case _event.EventType.KeyVerificationMac: case _event.EventType.KeyVerificationStart: case _event.EventType.KeyVerificationKey: case _event.EventType.KeyVerificationReady: case _event.EventType.KeyVerificationAccept: { return true; } case _event.EventType.RoomMessage: { return content["msgtype"] === _event.MsgType.KeyVerificationRequest; } default: { return false; } } } /** * 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. */ forceDiscardSession() { 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 devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. */ checkForUnknownDevices(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 _base.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 devicesInRoom - `userId -> {deviceId -> object}` * devices we should shared the session with. */ removeUnknownDevices(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 forceDistributeToUnverified - if set to true will include the unverified devices * even if setting is set to block them (useful for verification) * * @returns 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 */ async getDevicesInRoom(room, forceDistributeToUnverified = false) { 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.globalBlacklistUnverifiedDevices; const isRoomBlacklisting = room.getBlacklistUnverifiedDevices(); if (typeof isRoomBlacklisting === "boolean") { isBlacklisting = isRoomBlacklisting; } // 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 && !forceDistributeToUnverified) { if (!blocked[userId]) { blocked[userId] = {}; } const isBlocked = userDevices[deviceId].isBlocked(); blocked[userId][deviceId] = { code: isBlocked ? "m.blacklisted" : "m.unverified", reason: _OlmDevice.WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], deviceInfo: userDevices[deviceId] }; delete userDevices[deviceId]; } } } return [devices, blocked]; } } /** * Megolm decryption implementation * * @param params - parameters, as per {@link DecryptionAlgorithm} */ exports.MegolmEncryption = MegolmEncryption; class MegolmDecryption extends _base.DecryptionAlgorithm { // events which we couldn't decrypt due to unknown sessions / // indexes, or which we could only decrypt with untrusted keys: // map from senderKey|sessionId to Set of MatrixEvents // this gets stubbed out by the unit tests. constructor(params) { super(params); (0, _defineProperty2.default)(this, "pendingEvents", new Map()); (0, _defineProperty2.default)(this, "olmlib", olmlib); (0, _defineProperty2.default)(this, "roomId", void 0); this.roomId = params.roomId; } /** * returns a promise which resolves to a * {@link EventDecryptionResult} once we have finished * decrypting, or rejects with an `algorithms.DecryptionError` if there is a * problem decrypting the event. */ async decryptEvent(event) { const content = event.getWireContent(); if (!content.sender_key || !content.session_id || !content.ciphertext) { throw new _base.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 === null || e === void 0 ? void 0 : e.message) === "OLM.UNKNOWN_MESSAGE_INDEX") { this.requestKeysForEvent(event); errorCode = "OLM_UNKNOWN_MESSAGE_INDEX"; } throw new _base.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. // try and get the missing key from the backup first this.crypto.backupManager.queryKeyBackupRateLimited(event.getRoomId(), content.session_id).catch(() => {}); // (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) { _logger.logger.info(`When handling UISI from ${event.getSender()} (sender key ${content.sender_key}): ` + `recent session problem with that sender: ${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 _base.DecryptionError("MEGOLM_UNKNOWN_INBOUND_SESSION_ID", problemDescription, { session: content.sender_key + "|" + content.session_id }); } throw new _base.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. However, if the event was // decrypted with an untrusted key, leave it on the pending // list so it will be retried if we find a trusted key later. if (!res.untrusted) { 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 _base.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 }; } requestKeysForEvent(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. * * @internal * */ addEventToPendingList(event) { var _senderPendingEvents$; const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; if (!this.pendingEvents.has(senderKey)) { this.pendingEvents.set(senderKey, new Map()); } const senderPendingEvents = this.pendingEvents.get(senderKey); if (!senderPendingEvents.has(sessionId)) { senderPendingEvents.set(sessionId, new Set()); } (_senderPendingEvents$ = senderPendingEvents.get(sessionId)) === null || _senderPendingEvents$ === void 0 ? void 0 : _senderPendingEvents$.add(event); } /** * Remove an event from the list of those awaiting their session keys. * * @internal * */ removeEventFromPendingList(event) { const content = event.getWireContent(); const senderKey = content.sender_key; const sessionId = content.session_id; const senderPendingEvents = this.pendingEvents.get(senderKey); const pendingEvents = senderPendingEvents === null || senderPendingEvents === void 0 ? void 0 : senderPendingEvents.get(sessionId); if (!pendingEvents) { return; } pendingEvents.delete(event); if (pendingEvents.size === 0) { senderPendingEvents.delete(sessionId); } if (senderPendingEvents.size === 0) { this.pendingEvents.delete(senderKey); } } async onRoomKeyEvent(event) { const content = event.getContent(); let senderKey = event.getSenderKey(); let forwardingKeyChain = []; let exportFormat = false; let keysClaimed; const extraSessionData = {}; if (!content.room_id || !content.session_key || !content.session_id || !content.algorithm) { _logger.logger.error("key event is missing fields"); return; } if (!olmlib.isOlmEncrypted(event)) { _logger.logger.error("key event not properly encrypted"); return; } if (content["org.matrix.msc3061.shared_history"]) { extraSessionData.sharedHistory = true; } if (event.getType() == "m.forwarded_room_key") { var _room$getMember, _memberEvent$getUnsig, _memberEvent$getPrevC, _this$crypto$deviceLi; const deviceInfo = this.crypto.deviceList.getDeviceByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); const senderKeyUser = this.baseApis.crypto.deviceList.getUserByIdentityKey(olmlib.OLM_ALGORITHM, senderKey); if (senderKeyUser !== event.getSender()) { _logger.logger.error("sending device does not belong to the user it claims to be from"); return; } const outgoingRequests = deviceInfo ? await this.crypto.cryptoStore.getOutgoingRoomKeyRequestsByTarget(event.getSender(), deviceInfo.deviceId, [_OutgoingRoomKeyRequestManager.RoomKeyRequestState.Sent]) : []; const weRequested = outgoingRequests.some(req => req.requestBody.room_id === content.room_id && req.requestBody.session_id === content.session_id); const room = this.baseApis.getRoom(content.room_id); const memberEvent = room === null || room === void 0 ? void 0 : (_room$getMember = room.getMember(this.userId)) === null || _room$getMember === void 0 ? void 0 : _room$getMember.events.member; const fromInviter = (memberEvent === null || memberEvent === void 0 ? void 0 : memberEvent.getSender()) === event.getSender() || (memberEvent === null || memberEvent === void 0 ? void 0 : (_memberEvent$getUnsig = memberEvent.getUnsigned()) === null || _memberEvent$getUnsig === void 0 ? void 0 : _memberEvent$getUnsig.prev_sender) === event.getSender() && (memberEvent === null || memberEvent === void 0 ? void 0 : (_memberEvent$getPrevC = memberEvent.getPrevContent()) === null || _memberEvent$getPrevC === void 0 ? void 0 : _memberEvent$getPrevC.membership) === "invite"; const fromUs = event.getSender() === this.baseApis.getUserId(); if (!weRequested && !fromUs) { // If someone sends us an unsolicited key and they're // not one of our other devices and it's not shared // history, ignore it if (!extraSessionData.sharedHistory) { _logger.logger.log("forwarded key not shared history - ignoring"); return; } // If someone sends us an unsolicited key for a room