diff --git a/CHANGELOG.md b/CHANGELOG.md index bdfc2363a..a9f0b6b4c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,26 @@ +Changes in [9.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.10.0) (2021-03-29) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.10.0-rc.1...v9.10.0) + + * No changes since rc.1 + +Changes in [9.10.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.10.0-rc.1) (2021-03-25) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.9.0...v9.10.0-rc.1) + + * Don't send m.call.hangup if m.call.invite wasn't sent either + [\#1647](https://github.com/matrix-org/matrix-js-sdk/pull/1647) + * docs: registerGuest() + [\#1641](https://github.com/matrix-org/matrix-js-sdk/pull/1641) + * Download device keys in chunks of 250 + [\#1639](https://github.com/matrix-org/matrix-js-sdk/pull/1639) + * More VoIP connectivity fixes + [\#1646](https://github.com/matrix-org/matrix-js-sdk/pull/1646) + * Make selectDesktopCapturerSource param optional + [\#1644](https://github.com/matrix-org/matrix-js-sdk/pull/1644) + * Expose APIs needed for reworked cross-signing login flow + [\#1632](https://github.com/matrix-org/matrix-js-sdk/pull/1632) + Changes in [9.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v9.9.0) (2021-03-15) ================================================================================================ [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v9.9.0-rc.1...v9.9.0) diff --git a/package.json b/package.json index c4b9e4047..e3e018956 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "9.9.0", + "version": "9.10.0", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", diff --git a/spec/unit/crypto/DeviceList.spec.js b/spec/unit/crypto/DeviceList.spec.js index 3ba25ae1f..a9ff83054 100644 --- a/spec/unit/crypto/DeviceList.spec.js +++ b/spec/unit/crypto/DeviceList.spec.js @@ -51,6 +51,36 @@ const signedDeviceList = { }, }; +const signedDeviceList2 = { + "failures": {}, + "device_keys": { + "@test2:sw1v.org": { + "QJVRHWAKGH": { + "signatures": { + "@test2:sw1v.org": { + "ed25519:QJVRHWAKGH": + "w1xxdLe1iIqzEFHLRVYQeuiM6t2N2ZRiI8s5nDKxf054BP8" + + "1CPEX/AQXh5BhkKAVMlKnwg4T9zU1/wBALeajk3", + }, + }, + "user_id": "@test2:sw1v.org", + "keys": { + "ed25519:QJVRHWAKGH": + "Ig0/C6T+bBII1l2By2Wnnvtjp1nm/iXBlLU5/QESFXL", + "curve25519:QJVRHWAKGH": + "YR3eQnUvTQzGlWih4rsmJkKxpDxzgkgIgsBd1DEZIbm", + }, + "algorithms": [ + "m.olm.v1.curve25519-aes-sha2", + "m.megolm.v1.aes-sha2", + ], + "device_id": "QJVRHWAKGH", + "unsigned": {}, + }, + }, + }, +}; + describe('DeviceList', function() { let downloadSpy; let cryptoStore; @@ -69,7 +99,7 @@ describe('DeviceList', function() { } }); - function createTestDeviceList() { + function createTestDeviceList(keyDownloadChunkSize = 250) { const baseApis = { downloadKeysForUsers: downloadSpy, getUserId: () => '@test1:sw1v.org', @@ -78,7 +108,7 @@ describe('DeviceList', function() { const mockOlm = { verifySignature: function(key, message, signature) {}, }; - const dl = new DeviceList(baseApis, cryptoStore, mockOlm); + const dl = new DeviceList(baseApis, cryptoStore, mockOlm, keyDownloadChunkSize); deviceLists.push(dl); return dl; } @@ -150,4 +180,30 @@ describe('DeviceList', function() { expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); }); }); + + it("should download device keys in batches", function() { + const dl = createTestDeviceList(1); + + dl.startTrackingDeviceList('@test1:sw1v.org'); + dl.startTrackingDeviceList('@test2:sw1v.org'); + + const queryDefer1 = utils.defer(); + downloadSpy.mockReturnValueOnce(queryDefer1.promise); + const queryDefer2 = utils.defer(); + downloadSpy.mockReturnValueOnce(queryDefer2.promise); + + const prom1 = dl.refreshOutdatedDeviceLists(); + expect(downloadSpy).toBeCalledTimes(2); + expect(downloadSpy).toHaveBeenNthCalledWith(1, ['@test1:sw1v.org'], {}); + expect(downloadSpy).toHaveBeenNthCalledWith(2, ['@test2:sw1v.org'], {}); + queryDefer1.resolve(utils.deepCopy(signedDeviceList)); + queryDefer2.resolve(utils.deepCopy(signedDeviceList2)); + + return prom1.then(() => { + const storedKeys1 = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); + expect(Object.keys(storedKeys1)).toEqual(['HGKAWHRVJQ']); + const storedKeys2 = dl.getRawStoredDevicesForUser('@test2:sw1v.org'); + expect(Object.keys(storedKeys2)).toEqual(['QJVRHWAKGH']); + }); + }); }); diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 056344e5d..1195db2db 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -193,7 +193,9 @@ describe("Cross Signing", function() { const keyChangePromise = new Promise((resolve, reject) => { alice.once("crossSigning.keysChanged", async (e) => { resolve(e); - await alice.checkOwnCrossSigningTrust(); + await alice.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true, + }); }); }); diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js index 0b9f8ab93..686912913 100644 --- a/spec/unit/utils.spec.js +++ b/spec/unit/utils.spec.js @@ -282,4 +282,30 @@ describe("utils", function() { expect(target.nonenumerableProp).toBe(undefined); }); }); + + describe("chunkPromises", function() { + it("should execute promises in chunks", async function() { + let promiseCount = 0; + + function fn1() { + return new Promise(async function(resolve, reject) { + await utils.sleep(1); + expect(promiseCount).toEqual(0); + ++promiseCount; + resolve(); + }); + } + + function fn2() { + return new Promise(function(resolve, reject) { + expect(promiseCount).toEqual(1); + ++promiseCount; + resolve(); + }); + } + + await utils.chunkPromises([fn1, fn2], 1); + expect(promiseCount).toEqual(2); + }); + }); }); diff --git a/src/base-apis.js b/src/base-apis.js index a86d24465..1bdfb1f7e 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -246,10 +246,25 @@ MatrixBaseApis.prototype.register = function( /** * Register a guest account. + * This method returns the auth info needed to create a new authenticated client, + * Remember to call `setGuest(true)` on the (guest-)authenticated client, e.g: + * ```javascript + * const tmpClient = await sdk.createClient(MATRIX_INSTANCE); + * const { user_id, device_id, access_token } = tmpClient.registerGuest(); + * const client = createClient({ + * baseUrl: MATRIX_INSTANCE, + * accessToken: access_token, + * userId: user_id, + * deviceId: device_id, + * }) + * client.setGuest(true); + * ``` + * * @param {Object=} opts Registration options * @param {Object} opts.body JSON HTTP body to provide. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: JSON object that contains: + * { user_id, device_id, access_token, home_server } * @return {module:http-api.MatrixError} Rejects: with an error response. */ MatrixBaseApis.prototype.registerGuest = function(opts, callback) { diff --git a/src/client.js b/src/client.js index 53fca78ac..f845a5a08 100644 --- a/src/client.js +++ b/src/client.js @@ -2303,6 +2303,39 @@ MatrixClient.prototype.deleteKeysFromBackup = function(roomId, sessionId, versio ); }; +/** + * Share shared-history decryption keys with the given users. + * + * @param {string} roomId the room for which keys should be shared. + * @param {array} userIds a list of users to share with. The keys will be sent to + * all of the user's current devices. + */ +MatrixClient.prototype.sendSharedHistoryKeys = async function(roomId, userIds) { + if (this._crypto === null) { + throw new Error("End-to-end encryption disabled"); + } + + const roomEncryption = this._roomList.getRoomEncryption(roomId); + if (!roomEncryption) { + // unknown room, or unencrypted room + logger.error("Unknown room. Not sharing decryption keys"); + return; + } + + const deviceInfos = await this._crypto.downloadKeys(userIds); + const devicesByUser = {}; + for (const [userId, devices] of Object.entries(deviceInfos)) { + devicesByUser[userId] = Object.values(devices); + } + + const alg = this._crypto._getRoomDecryptor(roomId, roomEncryption.algorithm); + if (alg.sendSharedHistoryInboundSessions) { + await alg.sendSharedHistoryInboundSessions(devicesByUser); + } else { + logger.warning("Algorithm does not support sharing previous keys", roomEncryption.algorithm); + } +}; + // Group ops // ========= // Operations on groups that come down the sync stream (ie. ones the diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.js index 797f4b860..97836b5a4 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.js @@ -28,7 +28,7 @@ import {DeviceInfo} from './deviceinfo'; import {CrossSigningInfo} from './CrossSigning'; import * as olmlib from './olmlib'; import {IndexedDBCryptoStore} from './store/indexeddb-crypto-store'; -import {defer, sleep} from '../utils'; +import {chunkPromises, defer, sleep} from '../utils'; /* State transition diagram for DeviceList._deviceTrackingStatus @@ -62,7 +62,7 @@ const TRACKING_STATUS_UP_TO_DATE = 3; * @alias module:crypto/DeviceList */ export class DeviceList extends EventEmitter { - constructor(baseApis, cryptoStore, olmDevice) { + constructor(baseApis, cryptoStore, olmDevice, keyDownloadChunkSize = 250) { super(); this._cryptoStore = cryptoStore; @@ -98,6 +98,9 @@ export class DeviceList extends EventEmitter { // userId -> promise this._keyDownloadsInProgressByUser = {}; + // Maximum number of user IDs per request to prevent server overload (#1619) + this._keyDownloadChunkSize = keyDownloadChunkSize; + // Set whenever changes are made other than setting the sync token this._dirty = false; @@ -780,13 +783,17 @@ class DeviceListUpdateSerialiser { opts.token = this._syncToken; } - this._baseApis.downloadKeysForUsers( - downloadUsers, opts, - ).then(async (res) => { - const dk = res.device_keys || {}; - const masterKeys = res.master_keys || {}; - const ssks = res.self_signing_keys || {}; - const usks = res.user_signing_keys || {}; + const factories = []; + for (let i = 0; i < downloadUsers.length; i += this._deviceList._keyDownloadChunkSize) { + const userSlice = downloadUsers.slice(i, i + this._deviceList._keyDownloadChunkSize); + factories.push(() => this._baseApis.downloadKeysForUsers(userSlice, opts)); + } + + chunkPromises(factories, 3).then(async (responses) => { + const dk = Object.assign({}, ...(responses.map(res => res.device_keys || {}))); + const masterKeys = Object.assign({}, ...(responses.map(res => res.master_keys || {}))); + const ssks = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {}))); + const usks = Object.assign({}, ...(responses.map(res => res.user_signing_keys || {}))); // yield to other things that want to execute in between users, to // avoid wedging the CPU diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 0989d19f3..df0733e65 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -1048,6 +1048,7 @@ OlmDevice.prototype.addInboundGroupSession = async function( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD, + IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, ], (txn) => { /* if we already have this session, consider updating it */ this._getInboundGroupSession( @@ -1104,6 +1105,12 @@ OlmDevice.prototype.addInboundGroupSession = async function( this._cryptoStore.storeEndToEndInboundGroupSession( senderKey, sessionId, sessionData, txn, ); + + if (!existingSession && extraSessionData.sharedHistory) { + this._cryptoStore.addSharedHistoryInboundGroupSession( + roomId, senderKey, sessionId, txn, + ); + } } finally { session.free(); } @@ -1383,6 +1390,7 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], "sender_claimed_ed25519_key": senderEd25519Key, + "shared_history": sessionData.sharedHistory || false, }; }, ); @@ -1415,10 +1423,24 @@ OlmDevice.prototype.exportInboundGroupSession = function( "session_key": session.export_session(messageIndex), "forwarding_curve25519_key_chain": session.forwardingCurve25519KeyChain || [], "first_known_index": session.first_known_index(), + "org.matrix.msc3061.shared_history": sessionData.sharedHistory || false, }; }); }; +OlmDevice.prototype.getSharedHistoryInboundGroupSessions = async function(roomId) { + let result; + await this._cryptoStore.doTxn( + 'readonly', [ + IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS, + ], (txn) => { + result = this._cryptoStore.getSharedHistoryInboundGroupSessions(roomId, txn); + }, + logger.withPrefix("[getSharedHistoryInboundGroupSessionsForRoom]"), + ); + return result; +}; + // Utilities // ========= diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index 7ea048d2b..0bf77cd29 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -36,11 +36,27 @@ import { import {WITHHELD_MESSAGES} from '../OlmDevice'; +// determine whether the key can be shared with invitees +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 @@ -50,12 +66,13 @@ import {WITHHELD_MESSAGES} from '../OlmDevice'; * devices with which we have shared the session key * userId -> {deviceId -> msgindex} */ -function OutboundSessionInfo(sessionId) { +function OutboundSessionInfo(sessionId, sharedHistory = false) { this.sessionId = sessionId; this.useCount = 0; this.creationTime = new Date().getTime(); this.sharedWithDevices = {}; this.blockedDevicesNotified = {}; + this.sharedHistory = sharedHistory; } @@ -183,6 +200,7 @@ 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 @@ -192,7 +210,7 @@ utils.inherits(MegolmEncryption, EncryptionAlgorithm); * OutboundSessionInfo when setup is complete. */ MegolmEncryption.prototype._ensureOutboundSession = async function( - devicesInRoom, blocked, singleOlmCreationPhase, + room, devicesInRoom, blocked, singleOlmCreationPhase, ) { let session; @@ -204,6 +222,13 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( 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) @@ -219,7 +244,7 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( if (!session) { logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(); + session = await this._prepareNewSession(sharedHistory); logger.log(`Started new megolm session ${session.sessionId} ` + `for room ${this._roomId}`); this._outboundSessions[session.sessionId] = session; @@ -250,11 +275,12 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( 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, + "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( @@ -374,15 +400,18 @@ MegolmEncryption.prototype._ensureOutboundSession = async function( /** * @private * + * @param {boolean} sharedHistory + * * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session */ -MegolmEncryption.prototype._prepareNewSession = async function() { +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}, + key.key, {ed25519: this._olmDevice.deviceEd25519Key}, false, + {sharedHistory: sharedHistory}, ); // don't wait for it to complete @@ -391,7 +420,7 @@ MegolmEncryption.prototype._prepareNewSession = async function() { sessionId, key.key, ); - return new OutboundSessionInfo(sessionId); + return new OutboundSessionInfo(sessionId, sharedHistory); }; /** @@ -672,14 +701,15 @@ MegolmEncryption.prototype.reshareKeyWithDevice = async function( 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, + "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, }, }; @@ -901,7 +931,7 @@ MegolmEncryption.prototype.prepareToEncrypt = function(room) { } logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(devicesInRoom, blocked, true); + await this._ensureOutboundSession(room, devicesInRoom, blocked, true); logger.debug(`Ready to encrypt events for ${this._roomId}`); } catch (e) { @@ -945,7 +975,7 @@ MegolmEncryption.prototype.encryptMessage = async function(room, eventType, cont this._checkForUnknownDevices(devicesInRoom); } - const session = await this._ensureOutboundSession(devicesInRoom, blocked); + const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); const payloadJson = { room_id: this._roomId, type: eventType, @@ -1370,10 +1400,14 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { 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, + exportFormat, extraSessionData, ).then(() => { // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId) @@ -1573,14 +1607,15 @@ MegolmDecryption.prototype._buildKeyForwardingMessage = async function( 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, + "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, }, }; }; @@ -1594,6 +1629,13 @@ MegolmDecryption.prototype._buildKeyForwardingMessage = async function( * @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, @@ -1602,7 +1644,7 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { session.session_key, session.sender_claimed_keys, true, - opts.untrusted ? { untrusted: opts.untrusted } : {}, + extraSessionData, ).then(() => { if (opts.source !== "backup") { // don't wait for it to complete @@ -1681,6 +1723,80 @@ MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) 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/index.js b/src/crypto/index.js index 5c12076c5..d2b2bc4bf 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -568,7 +568,9 @@ Crypto.prototype.bootstrapCrossSigning = async function({ "Cross-signing private keys not found locally, but they are available " + "in secret storage, reading storage and caching locally", ); - await this.checkOwnCrossSigningTrust(); + await this.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true, + }); } // Assuming no app-supplied callback, default to storing new private keys in @@ -1300,13 +1302,19 @@ Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { * Check the copy of our cross-signing key that we have in the device list and * see if we can get the private key. If so, mark it as trusted. */ -Crypto.prototype.checkOwnCrossSigningTrust = async function() { +Crypto.prototype.checkOwnCrossSigningTrust = async function({ + allowPrivateKeyRequests = false, +} = {}) { const userId = this._userId; // Before proceeding, ensure our cross-signing public keys have been // downloaded via the device list. await this.downloadKeys([this._userId]); + // Also check which private keys are locally cached. + const crossSigningPrivateKeys = + await this._crossSigningInfo.getCrossSigningKeysFromCache(); + // If we see an update to our own master key, check it against the master // key we have and, if it matches, mark it as verified @@ -1324,16 +1332,22 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { const masterChanged = this._crossSigningInfo.getId() !== seenPubkey; if (masterChanged) { logger.info("Got new master public key", seenPubkey); + } + if ( + allowPrivateKeyRequests && + (masterChanged || !crossSigningPrivateKeys.has("master")) + ) { logger.info("Attempting to retrieve cross-signing master private key"); let signing = null; + // It's important for control flow that we leave any errors alone for + // higher levels to handle so that e.g. cancelling access properly + // aborts any larger operation as well. try { const ret = await this._crossSigningInfo.getCrossSigningKey( 'master', seenPubkey, ); signing = ret[1]; logger.info("Got cross-signing master private key"); - } catch (e) { - logger.error("Cross-signing master private key not available", e); } finally { if (signing) signing.free(); } @@ -1352,6 +1366,11 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { if (selfSigningChanged) { logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); + } + if ( + allowPrivateKeyRequests && + (selfSigningChanged || !crossSigningPrivateKeys.has("self_signing")) + ) { logger.info("Attempting to retrieve cross-signing self-signing private key"); let signing = null; try { @@ -1360,8 +1379,6 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { ); signing = ret[1]; logger.info("Got cross-signing self-signing private key"); - } catch (e) { - logger.error("Cross-signing self-signing private key not available", e); } finally { if (signing) signing.free(); } @@ -1374,6 +1391,11 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { } if (userSigningChanged) { logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); + } + if ( + allowPrivateKeyRequests && + (userSigningChanged || !crossSigningPrivateKeys.has("user_signing")) + ) { logger.info("Attempting to retrieve cross-signing user-signing private key"); let signing = null; try { @@ -1382,8 +1404,6 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function() { ); signing = ret[1]; logger.info("Got cross-signing user-signing private key"); - } catch (e) { - logger.error("Cross-signing user-signing private key not available", e); } finally { if (signing) signing.free(); } diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index c3203240f..58bfee576 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -19,7 +19,7 @@ limitations under the License. import {logger} from '../../logger'; import * as utils from "../../utils"; -export const VERSION = 9; +export const VERSION = 10; const PROFILE_TRANSACTIONS = false; /** @@ -759,6 +759,38 @@ export class Backend { })); } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + if (!txn) { + txn = this._db.transaction( + "shared_history_inbound_group_sessions", "readwrite", + ); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + req.onsuccess = () => { + const {sessions} = req.result || {sessions: []}; + sessions.push([senderKey, sessionId]); + objectStore.put({roomId, sessions}); + }; + } + + getSharedHistoryInboundGroupSessions(roomId, txn) { + if (!txn) { + txn = this._db.transaction( + "shared_history_inbound_group_sessions", "readonly", + ); + } + const objectStore = txn.objectStore("shared_history_inbound_group_sessions"); + const req = objectStore.get([roomId]); + return new Promise((resolve, reject) => { + req.onsuccess = () => { + const {sessions} = req.result || {sessions: []}; + resolve(sessions); + }; + req.onerror = reject; + }); + } + doTxn(mode, stores, func, log = logger) { let startTime; let description; @@ -834,6 +866,11 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["userId", "deviceId"], }); } + if (oldVersion < 10) { + db.createObjectStore("shared_history_inbound_group_sessions", { + keyPath: ["roomId"], + }); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 50f3c2678..4cfe36128 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -582,6 +582,29 @@ export class IndexedDBCryptoStore { return this._backend.markSessionsNeedingBackup(sessions, txn); } + /** + * Add a shared-history group session for a room. + * @param {string} roomId The room that the key belongs to + * @param {string} senderKey The sender's curve 25519 key + * @param {string} sessionId The ID of the session + * @param {*} txn An active transaction. See doTxn(). (optional) + */ + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId, txn) { + this._backend.addSharedHistoryInboundGroupSession( + roomId, senderKey, sessionId, txn, + ); + } + + /** + * Get the shared-history group session for a room. + * @param {string} roomId The room that the key belongs to + * @param {*} txn An active transaction. See doTxn(). (optional) + * @returns {Promise} Resolves to an array of [senderKey, sessionId] + */ + getSharedHistoryInboundGroupSessions(roomId, txn) { + return this._backend.getSharedHistoryInboundGroupSessions(roomId, txn); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may @@ -614,6 +637,8 @@ IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS_WITHHELD = 'inbound_group_sessions_withheld'; +IndexedDBCryptoStore.STORE_SHARED_HISTORY_INBOUND_GROUP_SESSIONS + = 'shared_history_inbound_group_sessions'; IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; IndexedDBCryptoStore.STORE_ROOMS = 'rooms'; IndexedDBCryptoStore.STORE_BACKUP = 'sessions_needing_backup'; diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 5170fb2c3..ac4e7be0b 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -51,6 +51,8 @@ export class MemoryCryptoStore { this._rooms = {}; // Set of {senderCurve25519Key+'/'+sessionId} this._sessionsNeedingBackup = {}; + // roomId -> array of [senderKey, sessionId] + this._sharedHistoryInboundGroupSessions = {}; } /** @@ -467,6 +469,16 @@ export class MemoryCryptoStore { return Promise.resolve(); } + addSharedHistoryInboundGroupSession(roomId, senderKey, sessionId) { + const sessions = this._sharedHistoryInboundGroupSessions[roomId] || []; + sessions.push([senderKey, sessionId]); + this._sharedHistoryInboundGroupSessions[roomId] = sessions; + } + + getSharedHistoryInboundGroupSessions(roomId) { + return Promise.resolve(this._sharedHistoryInboundGroupSessions[roomId] || []); + } + // Session key backups doTxn(mode, stores, func) { diff --git a/src/utils.ts b/src/utils.ts index 95b3c11c5..17b3924cd 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -745,6 +745,15 @@ export function promiseTry(fn: () => T): Promise { return new Promise((resolve) => resolve(fn())); } +// Creates and awaits all promises, running no more than `chunkSize` at the same time +export async function chunkPromises(fns: (() => Promise)[], chunkSize: number): Promise { + const results: T[] = []; + for (let i = 0; i < fns.length; i += chunkSize) { + results.push(...(await Promise.all(fns.slice(i, i + chunkSize).map(fn => fn())))); + } + return results; +} + // We need to be able to access the Node.js crypto library from within the // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index 6d12021f0..3529a17ee 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -340,8 +340,8 @@ export class MatrixCall extends EventEmitter { logger.debug("placeVoiceCall"); this.checkForErrorListener(); const constraints = getUserMediaContraints(ConstraintsType.Audio); - await this.placeCallWithConstraints(constraints); this.type = CallType.Voice; + await this.placeCallWithConstraints(constraints); } /** @@ -352,8 +352,8 @@ export class MatrixCall extends EventEmitter { logger.debug("placeVideoCall"); this.checkForErrorListener(); const constraints = getUserMediaContraints(ConstraintsType.Video); - await this.placeCallWithConstraints(constraints); this.type = CallType.Video; + await this.placeCallWithConstraints(constraints); } /** @@ -595,6 +595,8 @@ export class MatrixCall extends EventEmitter { logger.debug("Ending call " + this.callId); this.terminate(CallParty.Local, reason, !suppressEvent); + // We don't want to send hangup here if we didn't even get to sending an invite + if (this.state === CallState.WaitLocalMedia) return; const content = {}; // Continue to send no reason for user hangups temporarily, until // clients understand the user_hangup reason (voip v1) @@ -1373,7 +1375,10 @@ export class MatrixCall extends EventEmitter { } } - async transfer(targetUserId: string, targetRoomId?: string) { + /* + * Transfers this call to another user + */ + async transfer(targetUserId: string) { // Fetch the target user's global profile info: their room avatar / displayname // could be different in whatever room we shae with them. const profileInfo = await this.client.getProfileInfo(targetUserId); @@ -1390,9 +1395,49 @@ export class MatrixCall extends EventEmitter { create_call: replacementId, } as MCallReplacesEvent; - if (targetRoomId) body.target_room = targetRoomId; + await this.sendVoipEvent(EventType.CallReplaces, body); - return this.sendVoipEvent(EventType.CallReplaces, body); + await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); + } + + /* + * Transfers this call to the target call, effectively 'joining' the + * two calls (so the remote parties on each call are connected together). + */ + async transferToCall(transferTargetCall?: MatrixCall) { + const targetProfileInfo = await this.client.getProfileInfo(transferTargetCall.getOpponentMember().userId); + const transfereeProfileInfo = await this.client.getProfileInfo(this.getOpponentMember().userId); + + const newCallId = genCallID(); + + const bodyToTransferTarget = { + // the replacements on each side have their own ID, and it's distinct from the + // ID of the new call (but we can use the same function to generate it) + replacement_id: genCallID(), + target_user: { + id: this.getOpponentMember().userId, + display_name: transfereeProfileInfo.display_name, + avatar_url: transfereeProfileInfo.avatar_url, + }, + await_call: newCallId, + } as MCallReplacesEvent; + + await transferTargetCall.sendVoipEvent(EventType.CallReplaces, bodyToTransferTarget); + + const bodyToTransferee = { + replacement_id: genCallID(), + target_user: { + id: transferTargetCall.getOpponentMember().userId, + display_name: targetProfileInfo.display_name, + avatar_url: targetProfileInfo.avatar_url, + }, + create_call: newCallId, + } as MCallReplacesEvent; + + await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee); + + await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); + await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Replaced, true); } private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean) { diff --git a/src/webrtc/callEventTypes.ts b/src/webrtc/callEventTypes.ts index 11d1cdd76..c68903bb8 100644 --- a/src/webrtc/callEventTypes.ts +++ b/src/webrtc/callEventTypes.ts @@ -32,6 +32,7 @@ export interface MCallReplacesEvent { replacement_id: string; target_user: MCallReplacesTarget; create_call: string; + await_call: string; target_room: string; } /* eslint-enable camelcase */