diff --git a/src/base-apis.js b/src/base-apis.js index d928c406e..95b250a94 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -1018,20 +1018,35 @@ MatrixBaseApis.prototype.uploadKeysRequest = function(content, opts, callback) { * * @param {string[]} userIds list of users to get keys for * - * @param {module:client.callback=} callback + * @param {Object=} opts + * + * @param {string=} opts.token sync token to pass in the query request, to help + * the HS give the most recent results * * @return {module:client.Promise} Resolves: result object. Rejects: with * an error response ({@link module:http-api.MatrixError}). */ -MatrixBaseApis.prototype.downloadKeysForUsers = function(userIds, callback) { - const downloadQuery = {}; - - for (let i = 0; i < userIds.length; ++i) { - downloadQuery[userIds[i]] = {}; +MatrixBaseApis.prototype.downloadKeysForUsers = function(userIds, opts) { + if (utils.isFunction(opts)) { + // opts used to be 'callback'. + throw new Error( + 'downloadKeysForUsers no longer accepts a callback parameter', + ); } - const content = {device_keys: downloadQuery}; + opts = opts || {}; + + const content = { + device_keys: {}, + }; + if ('token' in opts) { + content.token = opts.token; + } + userIds.forEach((u) => { + content.device_keys[u] = {}; + }); + return this._http.authedRequestWithPrefix( - callback, "POST", "/keys/query", undefined, content, + undefined, "POST", "/keys/query", undefined, content, httpApi.PREFIX_UNSTABLE, ); }; @@ -1067,6 +1082,28 @@ MatrixBaseApis.prototype.claimOneTimeKeys = function(devices, key_algorithm) { ); }; +/** + * Ask the server for a list of users who have changed their device lists + * between a pair of sync tokens + * + * @param {string} oldToken + * @param {string} newToken + * + * @return {module:client.Promise} Resolves: result object. Rejects: with + * an error response ({@link module:http-api.MatrixError}). + */ +MatrixBaseApis.prototype.getKeyChanges = function(oldToken, newToken) { + const qps = { + from: oldToken, + to: newToken, + }; + + return this._http.authedRequestWithPrefix( + undefined, "GET", "/keys/changes", qps, undefined, + httpApi.PREFIX_UNSTABLE, + ); +}; + // Identity Server Operations // ========================== diff --git a/src/client.js b/src/client.js index b04620f39..1c2c79095 100644 --- a/src/client.js +++ b/src/client.js @@ -158,6 +158,7 @@ function MatrixClient(opts) { this, this, opts.sessionStore, userId, this.deviceId, + this.store, ); this.olmVersion = Crypto.getOlmVersion(); @@ -2665,8 +2666,6 @@ MatrixClient.prototype.startClient = function(opts) { }; } - this._clientOpts = opts; - if (this._crypto) { this._crypto.uploadKeys(5).done(); const tenMinutes = 1000 * 60 * 10; @@ -2684,6 +2683,13 @@ MatrixClient.prototype.startClient = function(opts) { console.error("Still have sync object whilst not running: stopping old one"); this._syncApi.stop(); } + + // shallow-copy the opts dict before modifying and storing it + opts = Object.assign({}, opts); + + opts.crypto = this._crypto; + this._clientOpts = opts; + this._syncApi = new SyncApi(this, opts); this._syncApi.sync(); }; @@ -3067,12 +3073,26 @@ module.exports.CRYPTO_ENABLED = CRYPTO_ENABLED; * * * @event module:client~MatrixClient#"sync" + * * @param {string} state An enum representing the syncing state. One of "PREPARED", * "SYNCING", "ERROR", "STOPPED". + * * @param {?string} prevState An enum representing the previous syncing state. * One of "PREPARED", "SYNCING", "ERROR", "STOPPED" or null. + * * @param {?Object} data Data about this transition. + * * @param {MatrixError} data.err The matrix error if state=ERROR. + * + * @param {String} data.oldSyncToken The 'since' token passed to /sync. + * null for the first successful sync since this client was + * started. Only present if state=PREPARED or + * state=SYNCING. + * + * @param {String} data.nextSyncToken The 'next_batch' result from /sync, which + * will become the 'since' token for the next call to /sync. Only present if + * state=PREPARED or state=SYNCING. + * * @example * matrixClient.on("sync", function(state, prevState, data) { * switch (state) { diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.js index 770938b17..6aa89c05c 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.js @@ -41,6 +41,8 @@ export default class DeviceList { // userId -> promise this._keyDownloadsInProgressByUser = {}; + + this.lastKnownSyncToken = null; } /** @@ -288,8 +290,13 @@ export default class DeviceList { _doKeyDownloadForUsers(downloadUsers) { console.log('Starting key download for ' + downloadUsers); + const token = this.lastKnownSyncToken; + const opts = {}; + if (token) { + opts.token = token; + } return this._baseApis.downloadKeysForUsers( - downloadUsers, + downloadUsers, opts, ).then((res) => { const dk = res.device_keys || {}; @@ -319,6 +326,10 @@ export default class DeviceList { this._sessionStore.storeEndToEndDevicesForUser( userId, storage, ); + + if (token) { + this._sessionStore.storeEndToEndDeviceSyncToken(token); + } } }); } diff --git a/src/crypto/index.js b/src/crypto/index.js index 4957bdb39..57edebedf 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -48,14 +48,16 @@ const DeviceList = require('./DeviceList').default; * @param {string} userId The user ID for the local user * * @param {string} deviceId The identifier for this device. + * + * @param {Object} clientStore the MatrixClient data store. */ -function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { +function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId, + clientStore) { this._baseApis = baseApis; this._sessionStore = sessionStore; this._userId = userId; this._deviceId = deviceId; - - this._initialSyncCompleted = false; + this._clientStore = clientStore; this._olmDevice = new OlmDevice(sessionStore); this._deviceList = new DeviceList(baseApis, sessionStore, this._olmDevice); @@ -111,11 +113,8 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId) { function _registerEventHandlers(crypto, eventEmitter) { eventEmitter.on("sync", function(syncState, oldState, data) { try { - if (syncState == "PREPARED") { - // XXX ugh. we're assuming the eventEmitter is a MatrixClient. - // how can we avoid doing so? - const rooms = eventEmitter.getRooms(); - crypto._onInitialSyncCompleted(rooms); + if (syncState === "SYNCING") { + crypto._onSyncCompleted(data); } } catch (e) { console.error("Error handling sync", e); @@ -710,6 +709,18 @@ Crypto.prototype.decryptEvent = function(event) { alg.decryptEvent(event); }; +/** + * Handle the notification from /sync that a user has updated their device list. + * + * @param {String} userId + */ +Crypto.prototype.userDeviceListChanged = function(userId) { + this._deviceList.invalidateUserDeviceList(userId); + + // don't flush the outdated device list yet - we do it once we finish + // processing the sync. +}; + /** * handle an m.room.encryption event * @@ -729,19 +740,50 @@ Crypto.prototype._onCryptoEvent = function(event) { }; /** - * handle the completion of the initial sync. + * handle the completion of a /sync * - * Announces the new device. + * This is called after the processing of each successful /sync response. + * It is an opportunity to do a batch process on the information received. + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ +Crypto.prototype._onSyncCompleted = function(syncData) { + this._deviceList.lastKnownSyncToken = syncData.nextSyncToken; + + if (!syncData.oldSyncToken) { + // an initialsync. + this._sendNewDeviceEvents(); + + // if we have a deviceSyncToken, we can tell the deviceList to + // invalidate devices which have changed since then. + const oldSyncToken = this._sessionStore.getEndToEndDeviceSyncToken(); + if (oldSyncToken) { + this._invalidateDeviceListsSince(oldSyncToken).catch((e) => { + // if that failed, we fall back to invalidating everyone. + console.warn("Error fetching changed device list", e); + this._invalidateDeviceListForAllActiveUsers(); + return this._deviceList.flushNewDeviceRequests(); + }).done(); + } else { + // otherwise, we have to invalidate all devices for all users we + // share a room with. + this._invalidateDeviceListForAllActiveUsers(); + } + } + + // catch up on any new devices we got told about during the sync. + this._deviceList.refreshOutdatedDeviceLists().done(); +}; + +/** + * Send m.new_device messages to any devices we share a room with. + * + * (TODO: we can get rid of this once a suitable number of homeservers and + * clients support the more reliable device list update stream mechanism) * * @private - * @param {module:models/room[]} rooms list of rooms the client knows about */ -Crypto.prototype._onInitialSyncCompleted = function(rooms) { - this._initialSyncCompleted = true; - - // catch up on any m.new_device events which arrived during the initial sync. - this._deviceList.refreshOutdatedDeviceLists().done(); - +Crypto.prototype._sendNewDeviceEvents = function() { if (this._sessionStore.getDeviceAnnounced()) { return; } @@ -750,23 +792,7 @@ Crypto.prototype._onInitialSyncCompleted = function(rooms) { // we have arrived. // build a list of rooms for each user. const roomsByUser = {}; - for (let i = 0; i < rooms.length; i++) { - const room = rooms[i]; - - // check for rooms with encryption enabled - const alg = this._roomEncryptors[room.roomId]; - if (!alg) { - continue; - } - - // ignore any rooms which we have left - const me = room.getMember(this._userId); - if (!me || ( - me.membership !== "join" && me.membership !== "invite" - )) { - continue; - } - + for (const room of this._getE2eRooms()) { const members = room.getJoinedMembers(); for (let j = 0; j < members.length; j++) { const m = members[j]; @@ -800,6 +826,88 @@ Crypto.prototype._onInitialSyncCompleted = function(rooms) { }); }; +/** + * Ask the server which users have new devices since a given token, + * invalidate them, and start an update query. + * + * @param {String} oldSyncToken + * + * @returns {Promise} resolves once the query is complete. Rejects if the + * keyChange query fails. + */ +Crypto.prototype._invalidateDeviceListsSince = function(oldSyncToken) { + return this._baseApis.getKeyChanges( + oldSyncToken, this.lastKnownSyncToken, + ).then((r) => { + if (!r.changed || !Array.isArray(r.changed)) { + return; + } + + // only invalidate users we share an e2e room with - we don't + // care about users in non-e2e rooms. + const filteredUserIds = this._getE2eRoomMembers(); + r.changed.forEach((u) => { + if (u in filteredUserIds) { + this._deviceList.invalidateUserDeviceList(u); + } + }); + return this._deviceList.flushNewDeviceRequests(); + }); +}; + +/** + * Invalidate any stored device list for any users we share an e2e room with + * + * @private + */ +Crypto.prototype._invalidateDeviceListForAllActiveUsers = function() { + Object.keys(this._getE2eRoomMembers()).forEach((m) => { + this._deviceList.invalidateUserDeviceList(m); + }); +}; + +/** + * get the users we share an e2e-enabled room with + * + * @returns {Object} userid->userid map (should be a Set but argh ES6) + */ +Crypto.prototype._getE2eRoomMembers = function() { + const userIds = Object.create(null); + + const rooms = this._getE2eRooms(); + for (const r of rooms) { + const members = r.getJoinedMembers(); + members.forEach((m) => { userIds[m.userId] = m.userId; }); + } + + return userIds; +}; + +/** + * Get a list of the e2e-enabled rooms we are members of + * + * @returns {module:models.Room[]} + */ +Crypto.prototype._getE2eRooms = function() { + return this._clientStore.getRooms().filter((room) => { + // check for rooms with encryption enabled + const alg = this._roomEncryptors[room.roomId]; + if (!alg) { + return false; + } + + // ignore any rooms which we have left + const me = room.getMember(this._userId); + if (!me || ( + me.membership !== "join" && me.membership !== "invite" + )) { + return false; + } + + return true; + }); +}; + /** * Handle a key event * @@ -873,12 +981,6 @@ Crypto.prototype._onNewDeviceEvent = function(event) { } this._deviceList.invalidateUserDeviceList(userId); - - // we delay handling these until the intialsync has completed, so that we - // can do all of them together. - if (this._initialSyncCompleted) { - this._deviceList.refreshOutdatedDeviceLists().done(); - } }; diff --git a/src/store/session/webstorage.js b/src/store/session/webstorage.js index d8aa828c3..5e9bffe55 100644 --- a/src/store/session/webstorage.js +++ b/src/store/session/webstorage.js @@ -99,6 +99,27 @@ WebStorageSessionStore.prototype = { return getJsonItem(this.store, keyEndToEndDevicesForUser(userId)); }, + /** + * Store the sync token corresponding to the device list. + * + * This is used when starting the client, to get a list of the users who + * have changed their device list since the list time we were running. + * + * @param {String?} token + */ + storeEndToEndDeviceSyncToken: function(token) { + setJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN, token); + }, + + /** + * Get the sync token corresponding to the device list. + * + * @return {String?} token + */ + getEndToEndDeviceSyncToken: function() { + return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); + }, + /** * Store a session between the logged-in user and another device * @param {string} deviceKey The public key of the other device. @@ -180,6 +201,7 @@ WebStorageSessionStore.prototype = { const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; const KEY_END_TO_END_ANNOUNCED = E2E_PREFIX + "announced"; +const KEY_END_TO_END_DEVICE_SYNC_TOKEN = E2E_PREFIX + "device_sync_token"; function keyEndToEndDevicesForUser(userId) { return E2E_PREFIX + "devices/" + userId; @@ -199,6 +221,8 @@ function keyEndToEndRoom(roomId) { function getJsonItem(store, key) { try { + // if the key is absent, store.getItem() returns null, and + // JSON.parse(null) === null, so this returns null. return JSON.parse(store.getItem(key)); } catch (e) { debuglog("Failed to get key %s: %s", key, e); diff --git a/src/sync.js b/src/sync.js index 90ba408d3..facf5d6ed 100644 --- a/src/sync.js +++ b/src/sync.js @@ -58,6 +58,7 @@ function debuglog() { * @constructor * @param {MatrixClient} client The matrix client instance to use. * @param {Object} opts Config options + * @param {module:crypto=} opts.crypto Crypto manager */ function SyncApi(client, opts) { this.client = client; @@ -529,13 +530,18 @@ SyncApi.prototype._sync = function(syncOptions) { } // emit synced events + const syncEventData = { + oldSyncToken: syncToken, + nextSyncToken: data.next_batch, + }; + if (!syncOptions.hasSyncedBefore) { - self._updateSyncState("PREPARED"); + self._updateSyncState("PREPARED", syncEventData); syncOptions.hasSyncedBefore = true; } // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates - self._updateSyncState("SYNCING"); + self._updateSyncState("SYNCING", syncEventData); self._sync(syncOptions); }, function(err) { @@ -584,6 +590,7 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { // next_batch: $token, // presence: { events: [] }, // account_data: { events: [] }, + // device_lists: { changed: ["@user:server", ... ]}, // to_device: { events: [] }, // rooms: { // invite: { @@ -859,6 +866,13 @@ SyncApi.prototype._processSyncResponse = function(syncToken, data) { client.getNotifTimelineSet().addLiveEvent(event); }); } + + // Handle device list updates + if (this.opts.crypto && data.device_lists && data.device_lists.changed) { + data.device_lists.changed.forEach((u) => { + this.opts.crypto.userDeviceListChanged(u); + }); + } }; /**