matrix-js-sdk
Version:
Matrix Client-Server SDK for Javascript
1,132 lines (1,073 loc) • 65.1 kB
JavaScript
"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