diff --git a/spec/TestClient.js b/spec/TestClient.js index 6e6038a73ce..1a7fa08167d 100644 --- a/spec/TestClient.js +++ b/spec/TestClient.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +26,7 @@ import testUtils from './test-utils'; import MockHttpBackend from 'matrix-mock-request'; import expect from 'expect'; import Promise from 'bluebird'; +import LocalStorageCryptoStore from '../lib/crypto/store/localStorage-crypto-store'; /** * Wrapper for a MockStorageApi, MockHttpBackend and MatrixClient @@ -46,14 +48,19 @@ export default function TestClient( if (sessionStoreBackend === undefined) { sessionStoreBackend = new testUtils.MockStorageApi(); } - this.storage = new sdk.WebStorageSessionStore(sessionStoreBackend); + const sessionStore = new sdk.WebStorageSessionStore(sessionStoreBackend); + + // expose this so the tests can get to it + this.cryptoStore = new LocalStorageCryptoStore(sessionStoreBackend); + this.httpBackend = new MockHttpBackend(); this.client = sdk.createClient({ baseUrl: "http://" + userId + ".test.server", userId: userId, accessToken: accessToken, deviceId: deviceId, - sessionStore: this.storage, + sessionStore: sessionStore, + cryptoStore: this.cryptoStore, request: this.httpBackend.requestFn, }); diff --git a/spec/integ/devicelist-integ-spec.js b/spec/integ/devicelist-integ-spec.js index b0fde61b044..f3f889deb74 100644 --- a/spec/integ/devicelist-integ-spec.js +++ b/spec/integ/devicelist-integ-spec.js @@ -1,3 +1,20 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import expect from 'expect'; import Promise from 'bluebird'; @@ -151,9 +168,12 @@ describe("DeviceList management:", function() { aliceTestClient.httpBackend.flush('/keys/query', 1).then( () => aliceTestClient.httpBackend.flush('/send/', 1), ), + aliceTestClient.client._crypto._deviceList.saveIfDirty(), ]); }).then(() => { - expect(aliceTestClient.storage.getEndToEndDeviceSyncToken()).toEqual(1); + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + expect(data.syncToken).toEqual(1); + }); // invalidate bob's and chris's device lists in separate syncs aliceTestClient.httpBackend.when('GET', '/sync').respond(200, { @@ -185,19 +205,21 @@ describe("DeviceList management:", function() { return aliceTestClient.httpBackend.flush('/keys/query', 1); }).then((flushed) => { expect(flushed).toEqual(0); - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; - if (bobStat != 1 && bobStat != 2) { - throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + - bobStat); - } - - const chrisStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error('Unexpected status for chris: wanted 1 or 2, got ' + - chrisStat); - } + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + if (bobStat != 1 && bobStat != 2) { + throw new Error('Unexpected status for bob: wanted 1 or 2, got ' + + bobStat); + } + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + chrisStat, + ); + } + }); // now add an expectation for a query for bob's devices, and let // it complete. @@ -216,15 +238,18 @@ describe("DeviceList management:", function() { // wait for the client to stop processing the response return aliceTestClient.client.downloadKeys(['@bob:xyz']); }).then(() => { - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; - expect(bobStat).toEqual(3); - const chrisStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@chris:abc']; - if (chrisStat != 1 && chrisStat != 2) { - throw new Error('Unexpected status for chris: wanted 1 or 2, got ' + - bobStat); - } + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + expect(bobStat).toEqual(3); + const chrisStat = data.trackingStatus['@chris:abc']; + if (chrisStat != 1 && chrisStat != 2) { + throw new Error( + 'Unexpected status for chris: wanted 1 or 2, got ' + bobStat, + ); + } + }); // now let the query for chris's devices complete. return aliceTestClient.httpBackend.flush('/keys/query', 1); @@ -234,16 +259,18 @@ describe("DeviceList management:", function() { // wait for the client to stop processing the response return aliceTestClient.client.downloadKeys(['@chris:abc']); }).then(() => { - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; - const chrisStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@chris:abc']; - - expect(bobStat).toEqual(3); - expect(chrisStat).toEqual(3); - expect(aliceTestClient.storage.getEndToEndDeviceSyncToken()).toEqual(3); + return aliceTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + const chrisStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual(3); + expect(chrisStat).toEqual(3); + expect(data.syncToken).toEqual(3); + }); }); - }); + }).timeout(3000); // https://github.com/vector-im/riot-web/issues/4983 describe("Alice should know she has stale device lists", () => { @@ -262,13 +289,15 @@ describe("DeviceList management:", function() { }, ); await aliceTestClient.httpBackend.flush('/keys/query', 1); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; - expect(bobStat).toBeGreaterThan( - 0, "Alice should be tracking bob's device list", - ); + expect(bobStat).toBeGreaterThan( + 0, "Alice should be tracking bob's device list", + ); + }); }); it("when Bob leaves", async function() { @@ -297,12 +326,15 @@ describe("DeviceList management:", function() { await aliceTestClient.flushSync(); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; - expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", - ); + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); }); it("when Alice leaves", async function() { @@ -330,12 +362,15 @@ describe("DeviceList management:", function() { ); await aliceTestClient.flushSync(); + await aliceTestClient.client._crypto._deviceList.saveIfDirty(); - const bobStat = aliceTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; - expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", - ); + aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; + + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); }); it("when Bob leaves whilst Alice is offline", async function() { @@ -344,23 +379,19 @@ describe("DeviceList management:", function() { const anotherTestClient = await createTestClient(); try { - anotherTestClient.httpBackend.when('GET', '/keys/changes').respond( - 200, { - changed: [], - left: ['@bob:xyz'], - }, - ); await anotherTestClient.start(); anotherTestClient.httpBackend.when('GET', '/sync').respond( 200, getSyncResponse([])); await anotherTestClient.flushSync(); + await anotherTestClient.client._crypto._deviceList.saveIfDirty(); - const bobStat = anotherTestClient.storage - .getEndToEndDeviceTrackingStatus()['@bob:xyz']; + anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const bobStat = data.trackingStatus['@bob:xyz']; - expect(bobStat).toEqual( - 0, "Alice should have marked bob's device list as untracked", - ); + expect(bobStat).toEqual( + 0, "Alice should have marked bob's device list as untracked", + ); + }); } finally { anotherTestClient.stop(); } diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 17955b46617..31a40aaf64f 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -154,11 +155,15 @@ function aliDownloadsKeys() { // check that the localStorage is updated as we expect (not sure this is // an integration test, but meh) - return Promise.all([p1, p2]).then(function() { - const devices = aliTestClient.storage.getEndToEndDevicesForUser(bobUserId); - expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); - expect(devices[bobDeviceId].verified). - toBe(0); // DeviceVerification.UNVERIFIED + return Promise.all([p1, p2]).then(() => { + return aliTestClient.client._crypto._deviceList.saveIfDirty(); + }).then(() => { + aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { + const devices = data.devices[bobUserId]; + expect(devices[bobDeviceId].keys).toEqual(bobTestClient.deviceKeys.keys); + expect(devices[bobDeviceId].verified). + toBe(0); // DeviceVerification.UNVERIFIED + }); }); } diff --git a/spec/unit/crypto/DeviceList.spec.js b/spec/unit/crypto/DeviceList.spec.js index 7bab0e5c31b..870e40bbcb8 100644 --- a/spec/unit/crypto/DeviceList.spec.js +++ b/spec/unit/crypto/DeviceList.spec.js @@ -1,6 +1,24 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import DeviceList from '../../../lib/crypto/DeviceList'; import MockStorageApi from '../../MockStorageApi'; import WebStorageSessionStore from '../../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../../lib/crypto/store/memory-crypto-store.js'; import testUtils from '../../test-utils'; import utils from '../../../lib/utils'; @@ -40,6 +58,7 @@ const signedDeviceList = { describe('DeviceList', function() { let downloadSpy; let sessionStore; + let cryptoStore; beforeEach(function() { testUtils.beforeEach(this); // eslint-disable-line no-invalid-this @@ -47,6 +66,7 @@ describe('DeviceList', function() { downloadSpy = expect.createSpy(); const mockStorage = new MockStorageApi(); sessionStore = new WebStorageSessionStore(mockStorage); + cryptoStore = new MemoryCryptoStore(); }); function createTestDeviceList() { @@ -56,7 +76,7 @@ describe('DeviceList', function() { const mockOlm = { verifySignature: function(key, message, signature) {}, }; - return new DeviceList(baseApis, sessionStore, mockOlm); + return new DeviceList(baseApis, cryptoStore, sessionStore, mockOlm); } it("should successfully download and store device keys", function() { @@ -72,7 +92,7 @@ describe('DeviceList', function() { queryDefer1.resolve(utils.deepCopy(signedDeviceList)); return prom1.then(() => { - const storedKeys = sessionStore.getEndToEndDevicesForUser('@test1:sw1v.org'); + const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); }); }); @@ -97,14 +117,15 @@ describe('DeviceList', function() { dl.invalidateUserDeviceList('@test1:sw1v.org'); dl.refreshOutdatedDeviceLists(); - // the first request completes - queryDefer1.resolve({ - device_keys: { - '@test1:sw1v.org': {}, - }, - }); - - return prom1.then(() => { + dl.saveIfDirty().then(() => { + // the first request completes + queryDefer1.resolve({ + device_keys: { + '@test1:sw1v.org': {}, + }, + }); + return prom1; + }).then(() => { // uh-oh; user restarts before second request completes. The new instance // should know we never got a complete device list. console.log("Creating new devicelist to simulate app reload"); @@ -121,7 +142,7 @@ describe('DeviceList', function() { // allow promise chain to complete return prom3; }).then(() => { - const storedKeys = sessionStore.getEndToEndDevicesForUser('@test1:sw1v.org'); + const storedKeys = dl.getRawStoredDevicesForUser('@test1:sw1v.org'); expect(Object.keys(storedKeys)).toEqual(['HGKAWHRVJQ']); }); }); diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.js index 57edf4fbf99..d913b426a81 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -25,6 +26,7 @@ import Promise from 'bluebird'; import DeviceInfo from './deviceinfo'; import olmlib from './olmlib'; +import IndexedDBCryptoStore from './store/indexeddb-crypto-store'; /* State transition diagram for DeviceList._deviceTrackingStatus @@ -58,31 +60,153 @@ const TRACKING_STATUS_UP_TO_DATE = 3; * @alias module:crypto/DeviceList */ export default class DeviceList { - constructor(baseApis, sessionStore, olmDevice) { + constructor(baseApis, cryptoStore, sessionStore, olmDevice) { + this._cryptoStore = cryptoStore; this._sessionStore = sessionStore; - this._serialiser = new DeviceListUpdateSerialiser( - baseApis, sessionStore, olmDevice, - ); + + // userId -> { + // deviceId -> { + // [device info] + // } + // } + this._devices = {}; // which users we are tracking device status for. // userId -> TRACKING_STATUS_* - this._deviceTrackingStatus = sessionStore.getEndToEndDeviceTrackingStatus() || {}; + this._deviceTrackingStatus = {}; // loaded from storage in load() + + // The 'next_batch' sync token at the point the data was writen, + // ie. a token representing the point immediately after the + // moment represented by the snapshot in the db. + this._syncToken = null; + + this._serialiser = new DeviceListUpdateSerialiser( + baseApis, olmDevice, this, + ); + + // userId -> promise + this._keyDownloadsInProgressByUser = {}; + + // Set whenever changes are made other than setting the sync token + this._dirty = false; + + // Promise resolved when device data is saved + this._savePromise = null; + } + + /** + * Load the device tracking state from storage + */ + async load() { + let shouldDeleteSessionStore = false; + await this._cryptoStore.doTxn( + // migrate from session store if there's data there and not here + 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + if (deviceData === null) { + console.log("Migrating e2e device data..."); + this._devices = this._sessionStore.getAllEndToEndDevices() || {}; + this._deviceTrackingStatus = ( + this._sessionStore.getEndToEndDeviceTrackingStatus() || {} + ); + this._syncToken = this._sessionStore.getEndToEndDeviceSyncToken(); + this._cryptoStore.storeEndToEndDeviceData({ + devices: this._devices, + trackingStatus: this._deviceTrackingStatus, + syncToken: this._syncToken, + }, txn); + shouldDeleteSessionStore = true; + } else { + this._devices = deviceData ? deviceData.devices : {}, + this._deviceTrackingStatus = deviceData ? + deviceData.trackingStatus : {}; + this._syncToken = deviceData ? deviceData.syncToken : null; + } + }); + }, + ); + + if (shouldDeleteSessionStore) { + // migrated data is now safely persisted: remove from old store + this._sessionStore.removeEndToEndDeviceData(); + } + for (const u of Object.keys(this._deviceTrackingStatus)) { // if a download was in progress when we got shut down, it isn't any more. if (this._deviceTrackingStatus[u] == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; } } + } - // userId -> promise - this._keyDownloadsInProgressByUser = {}; + /** + * Save the device tracking state to storage, if any changes are + * pending other than updating the sync token + * + * The actual save will be delayed by a short amount of time to + * aggregate multiple writes to the database. + * + * @return {Promise} true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + async saveIfDirty() { + if (!this._dirty) return Promise.resolve(false); + + if (this._savePromise === null) { + // Delay saves for a bit so we can aggregate multiple saves that happen + // in quick succession (eg. when a whole room's devices are marked as known) + this._savePromise = Promise.delay(500).then(() => { + console.log('Saving device tracking data at token ' + this._syncToken); + // null out savePromise now (after the delay but before the write), + // otherwise we could return the existing promise when the save has + // actually already happened. Likewise for the dirty flag. + this._savePromise = null; + this._dirty = false; + return this._cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { + this._cryptoStore.storeEndToEndDeviceData({ + devices: this._devices, + trackingStatus: this._deviceTrackingStatus, + syncToken: this._syncToken, + }, txn); + }, + ); + }).then(() => { + return true; + }); + } + return this._savePromise; + } + + /** + * Gets the sync token last set with setSyncToken + * + * @return {string} The sync token + */ + getSyncToken() { + return this._syncToken; + } - this.lastKnownSyncToken = null; + /** + * Sets the sync token that the app will pass as the 'since' to the /sync + * endpoint next time it syncs. + * The sync token must always be set after any changes made as a result of + * data in that sync since setting the sync token to a newer one will mean + * those changed will not be synced from the server if a new client starts + * up with that data. + * + * @param {string} st The sync token + */ + setSyncToken(st) { + this._syncToken = st; } /** - * Download the keys for a list of users and stores the keys in the session - * store. + * Ensures up to date keys for a list of users are stored in the session store, + * downloading and storing them if they're not (or if forceDownload is + * true). * @param {Array} userIds The users to fetch. * @param {bool} forceDownload Always download the keys even if cached. * @@ -152,7 +276,7 @@ export default class DeviceList { * managed to get a list of devices for this user yet. */ getStoredDevicesForUser(userId) { - const devs = this._sessionStore.getEndToEndDevicesForUser(userId); + const devs = this._devices[userId]; if (!devs) { return null; } @@ -165,6 +289,18 @@ export default class DeviceList { return res; } + /** + * Get the stored device data for a user, in raw object form + * + * @param {string} userId the user to get data for + * + * @return {Object} deviceId->{object} devices, or undefined if + * there is no data for this user. + */ + getRawStoredDevicesForUser(userId) { + return this._devices[userId]; + } + /** * Get the stored keys for a single device * @@ -175,7 +311,7 @@ export default class DeviceList { * if we don't know about this device */ getStoredDevice(userId, deviceId) { - const devs = this._sessionStore.getEndToEndDevicesForUser(userId); + const devs = this._devices[userId]; if (!devs || !devs[deviceId]) { return undefined; } @@ -200,7 +336,7 @@ export default class DeviceList { return null; } - const devices = this._sessionStore.getEndToEndDevicesForUser(userId); + const devices = this._devices[userId]; if (!devices) { return null; } @@ -229,6 +365,17 @@ export default class DeviceList { return null; } + /** + * Replaces the list of devices for a user with the given device list + * + * @param {string} u The user ID + * @param {Object} devs New device info for user + */ + storeDevicesForUser(u, devs) { + this._devices[u] = devs; + this._dirty = true; + } + /** * flag the given user for device-list tracking, if they are not already. * @@ -254,8 +401,8 @@ export default class DeviceList { this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; } // we don't yet persist the tracking status, since there may be a lot - // of calls; instead we wait for the forthcoming - // refreshOutdatedDeviceLists. + // of calls; we save all data together once the sync is done + this._dirty = true; } /** @@ -273,10 +420,23 @@ export default class DeviceList { this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; } // we don't yet persist the tracking status, since there may be a lot - // of calls; instead we wait for the forthcoming - // refreshOutdatedDeviceLists. + // of calls; we save all data together once the sync is done + + this._dirty = true; } + /** + * Set all users we're currently tracking to untracked + * + * This will flag each user whose devices we are tracking as in need of an + * update. + */ + stopTrackingAllDeviceLists() { + for (const userId of Object.keys(this._deviceTrackingStatus)) { + this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + } + this._dirty = true; + } /** * Mark the cached device list for the given user outdated. @@ -295,20 +455,9 @@ export default class DeviceList { this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; } // we don't yet persist the tracking status, since there may be a lot - // of calls; instead we wait for the forthcoming - // refreshOutdatedDeviceLists. - } + // of calls; we save all data together once the sync is done - /** - * Mark all tracked device lists as outdated. - * - * This will flag each user whose devices we are tracking as in need of an - * update. - */ - invalidateAllDeviceLists() { - for (const userId of Object.keys(this._deviceTrackingStatus)) { - this.invalidateUserDeviceList(userId); - } + this._dirty = true; } /** @@ -318,6 +467,8 @@ export default class DeviceList { * is no need to wait for this (it's mostly for the unit tests). */ refreshOutdatedDeviceLists() { + this.saveIfDirty(); + const usersToDownload = []; for (const userId of Object.keys(this._deviceTrackingStatus)) { const stat = this._deviceTrackingStatus[userId]; @@ -326,13 +477,20 @@ export default class DeviceList { } } - // we didn't persist the tracking status during - // invalidateUserDeviceList, so do it now. - this._persistDeviceTrackingStatus(); - return this._doKeyDownload(usersToDownload); } + /** + * Set the stored device data for a user, in raw object form + * Used only by internal class DeviceListUpdateSerialiser + * + * @param {string} userId the user to get data for + * + * @param {Object} devices deviceId->{object} the new devices + */ + _setRawStoredDevicesForUser(userId, devices) { + this._devices[userId] = devices; + } /** * Fire off download update requests for the given users, and update the @@ -352,7 +510,7 @@ export default class DeviceList { } const prom = this._serialiser.updateDevicesForUsers( - users, this.lastKnownSyncToken, + users, this._syncToken, ).then(() => { finished(true); }, (e) => { @@ -373,6 +531,8 @@ export default class DeviceList { const finished = (success) => { users.forEach((u) => { + this._dirty = true; + // we may have queued up another download request for this user // since we started this request. If that happens, we should // ignore the completion of the first one. @@ -394,15 +554,11 @@ export default class DeviceList { } } }); - this._persistDeviceTrackingStatus(); + this.saveIfDirty(); }; return prom; } - - _persistDeviceTrackingStatus() { - this._sessionStore.storeEndToEndDeviceTrackingStatus(this._deviceTrackingStatus); - } } /** @@ -415,10 +571,15 @@ export default class DeviceList { * time (and queuing other requests up). */ class DeviceListUpdateSerialiser { - constructor(baseApis, sessionStore, olmDevice) { + /* + * @param {object} baseApis Base API object + * @param {object} olmDevice The Olm Device + * @param {object} deviceList The device list object + */ + constructor(baseApis, olmDevice, deviceList) { this._baseApis = baseApis; - this._sessionStore = sessionStore; this._olmDevice = olmDevice; + this._deviceList = deviceList; // the device list to be updated this._downloadInProgress = false; @@ -431,9 +592,7 @@ class DeviceListUpdateSerialiser { // non-null indicates that we have users queued for download. this._queuedQueryDeferred = null; - // sync token to be used for the next query: essentially the - // most recent one we know about - this._nextSyncToken = null; + this._syncToken = null; // The sync token we send with the requests } /** @@ -452,12 +611,16 @@ class DeviceListUpdateSerialiser { users.forEach((u) => { this._keyDownloadsQueuedByUser[u] = true; }); - this._nextSyncToken = syncToken; if (!this._queuedQueryDeferred) { this._queuedQueryDeferred = Promise.defer(); } + // We always take the new sync token and just use the latest one we've + // been given, since it just needs to be at least as recent as the + // sync response the device invalidation message arrived in + this._syncToken = syncToken; + if (this._downloadInProgress) { // just queue up these users console.log('Queued key download for', users); @@ -484,8 +647,8 @@ class DeviceListUpdateSerialiser { this._downloadInProgress = true; const opts = {}; - if (this._nextSyncToken) { - opts.token = this._nextSyncToken; + if (this._syncToken) { + opts.token = this._syncToken; } this._baseApis.downloadKeysForUsers( @@ -530,7 +693,7 @@ class DeviceListUpdateSerialiser { // map from deviceid -> deviceinfo for this user const userStore = {}; - const devs = this._sessionStore.getEndToEndDevicesForUser(userId); + const devs = this._deviceList.getRawStoredDevicesForUser(userId); if (devs) { Object.keys(devs).forEach((deviceId) => { const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); @@ -542,15 +705,13 @@ class DeviceListUpdateSerialiser { this._olmDevice, userId, userStore, response || {}, ); - // update the session store + // put the updates into thr object that will be returned as our results const storage = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); - this._sessionStore.storeEndToEndDevicesForUser( - userId, storage, - ); + this._deviceList._setRawStoredDevicesForUser(userId, storage); } } diff --git a/src/crypto/index.js b/src/crypto/index.js index 6b1d8f477f6..fab3afa8c7e 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -1,6 +1,7 @@ /* Copyright 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -69,7 +70,9 @@ function Crypto(baseApis, sessionStore, userId, deviceId, this._cryptoStore = cryptoStore; this._olmDevice = new OlmDevice(sessionStore, cryptoStore); - this._deviceList = new DeviceList(baseApis, sessionStore, this._olmDevice); + this._deviceList = new DeviceList( + baseApis, cryptoStore, sessionStore, this._olmDevice, + ); // the last time we did a check for the number of one-time-keys on the // server. @@ -128,6 +131,7 @@ Crypto.prototype.init = async function() { } await this._olmDevice.init(); + await this._deviceList.load(); // build our device keys: these will later be uploaded this._deviceKeys["ed25519:" + this._deviceId] = @@ -135,7 +139,7 @@ Crypto.prototype.init = async function() { this._deviceKeys["curve25519:" + this._deviceId] = this._olmDevice.deviceCurve25519Key; - let myDevices = this._sessionStore.getEndToEndDevicesForUser( + let myDevices = this._deviceList.getRawStoredDevicesForUser( this._userId, ); @@ -153,9 +157,10 @@ Crypto.prototype.init = async function() { }; myDevices[this._deviceId] = deviceInfo; - this._sessionStore.storeEndToEndDevicesForUser( + this._deviceList.storeDevicesForUser( this._userId, myDevices, ); + this._deviceList.saveIfDirty(); } }; @@ -456,7 +461,7 @@ Crypto.prototype.getStoredDevice = function(userId, deviceId) { Crypto.prototype.setDeviceVerification = async function( userId, deviceId, verified, blocked, known, ) { - const devices = this._sessionStore.getEndToEndDevicesForUser(userId); + const devices = this._deviceList.getRawStoredDevicesForUser(userId); if (!devices || !devices[deviceId]) { throw new Error("Unknown device " + userId + ":" + deviceId); } @@ -484,7 +489,8 @@ Crypto.prototype.setDeviceVerification = async function( if (dev.verified !== verificationStatus || dev.known !== knownStatus) { dev.verified = verificationStatus; dev.known = knownStatus; - this._sessionStore.storeEndToEndDevicesForUser(userId, devices); + this._deviceList.storeDevicesForUser(userId, devices); + this._deviceList.saveIfDirty(); } return DeviceInfo.fromStorage(dev, deviceId); }; @@ -809,24 +815,33 @@ Crypto.prototype.decryptEvent = function(event) { * Handle the notification from /sync or /keys/changes that device lists have * been changed. * - * @param {Object} deviceLists device_lists field from /sync, or response from + * @param {Object} syncData Object containing sync tokens associated with this sync + * @param {Object} syncDeviceLists device_lists field from /sync, or response from * /keys/changes */ -Crypto.prototype.handleDeviceListChanges = async function(deviceLists) { - if (deviceLists.changed && Array.isArray(deviceLists.changed)) { - deviceLists.changed.forEach((u) => { - this._deviceList.invalidateUserDeviceList(u); - }); - } - - if (deviceLists.left && Array.isArray(deviceLists.left)) { - deviceLists.left.forEach((u) => { - this._deviceList.stopTrackingDeviceList(u); - }); +Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLists) { + // Initial syncs don't have device change lists. We'll either get the complete list + // of changes for the interval or invalidate everything in onSyncComplete + if (!syncData.oldSyncToken) return; + + if (syncData.oldSyncToken === this._deviceList.getSyncToken()) { + // the point the db is at matches where the sync started from, so + // we can safely write the changes + this._evalDeviceListChanges(syncDeviceLists); + } else { + // the db is at a different point to where this sync started from, so + // additionally fetch the changes between where the db is and where the + // sync started + console.log( + "Device list sync gap detected - fetching key changes between " + + this._deviceList.getSyncToken() + " and " + syncData.oldSyncToken, + ); + const gapDeviceLists = await this._baseApis.getKeyChanges( + this._deviceList.getSyncToken(), syncData.oldSyncToken, + ); + this._evalDeviceListChanges(gapDeviceLists); + this._evalDeviceListChanges(syncDeviceLists); } - - // don't flush the outdated device list yet - we do it once we finish - // processing the sync. }; /** @@ -878,6 +893,22 @@ Crypto.prototype.onCryptoEvent = async function(event) { } }; +/** + * Called before the result of a sync is procesed + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ +Crypto.prototype.onSyncWillProcess = async function(syncData) { + if (!syncData.oldSyncToken) { + // If there is no old sync token, we start all our tracking from + // scratch, so mark everything as untracked. onCryptoEvent will + // be called for all e2e rooms during the processing of the sync, + // at which point we'll start tracking all the users of that room. + console.log("Initial sync performed - resetting device tracking state"); + this._deviceList.stopTrackingAllDeviceLists(); + } +}; + /** * handle the completion of a /sync * @@ -889,37 +920,8 @@ Crypto.prototype.onCryptoEvent = async function(event) { Crypto.prototype.onSyncCompleted = async function(syncData) { const nextSyncToken = syncData.nextSyncToken; - if (!syncData.oldSyncToken) { - console.log("Completed initial sync"); - - // 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 !== null) { - try { - await this._invalidateDeviceListsSince( - oldSyncToken, nextSyncToken, - ); - } catch (e) { - // if that failed, we fall back to invalidating everyone. - console.warn("Error fetching changed device list", e); - this._deviceList.invalidateAllDeviceLists(); - } - } else { - // otherwise, we have to invalidate all devices for all users we - // are tracking. - console.log("Completed first initialsync; invalidating all " + - "device list caches"); - this._deviceList.invalidateAllDeviceLists(); - } - } - - // we can now store our sync token so that we can get an update on - // restart rather than having to invalidate everyone. - // - // (we don't really need to do this on every sync - we could just - // do it periodically) - this._sessionStore.storeEndToEndDeviceSyncToken(nextSyncToken); + this._deviceList.setSyncToken(syncData.nextSyncToken); + this._deviceList.saveIfDirty(); // catch up on any new devices we got told about during the sync. this._deviceList.lastKnownSyncToken = nextSyncToken; @@ -936,25 +938,47 @@ Crypto.prototype.onSyncCompleted = async function(syncData) { }; /** - * Ask the server which users have new devices since a given token, - * and invalidate them + * Trigger the appropriate invalidations and removes for a given + * device list * - * @param {String} oldSyncToken - * @param {String} lastKnownSyncToken - * - * Returns a Promise which resolves once the query is complete. Rejects if the - * keyChange query fails. + * @param {Object} deviceLists device_lists field from /sync, or response from + * /keys/changes */ -Crypto.prototype._invalidateDeviceListsSince = async function( - oldSyncToken, lastKnownSyncToken, -) { - const r = await this._baseApis.getKeyChanges( - oldSyncToken, lastKnownSyncToken, - ); +Crypto.prototype._evalDeviceListChanges = async function(deviceLists) { + if (deviceLists.changed && Array.isArray(deviceLists.changed)) { + deviceLists.changed.forEach((u) => { + this._deviceList.invalidateUserDeviceList(u); + }); + } - console.log("got key changes since", oldSyncToken, ":", r); + if (deviceLists.left && Array.isArray(deviceLists.left)) { + // Check we really don't share any rooms with these users + // any more: the server isn't required to give us the + // exact correct set. + const e2eUserIds = new Set(this._getE2eUsers()); - await this.handleDeviceListChanges(r); + deviceLists.left.forEach((u) => { + if (!e2eUserIds.has(u)) { + this._deviceList.stopTrackingDeviceList(u); + } + }); + } +}; + +/** + * Get a list of all the IDs of users we share an e2e room with + * + * @returns {string[]} List of user IDs + */ +Crypto.prototype._getE2eUsers = function() { + const e2eUserIds = []; + for (const room of this._getE2eRooms()) { + const members = room.getJoinedMembers(); + for (const member of members) { + e2eUserIds.push(member.userId); + } + } + return e2eUserIds; }; /** diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index 1a5442c13fe..41130a65366 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -1,7 +1,24 @@ +/* +Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + import Promise from 'bluebird'; import utils from '../../utils'; -export const VERSION = 4; +export const VERSION = 5; /** * Implementation of a CryptoStore which is backed by an existing @@ -391,6 +408,23 @@ export class Backend { }); } + getEndToEndDeviceData(txn, func) { + const objectStore = txn.objectStore("device_data"); + const getReq = objectStore.get("-"); + getReq.onsuccess = function() { + try { + func(getReq.result || null); + } catch (e) { + abortWithException(txn, e); + } + }; + } + + storeEndToEndDeviceData(deviceData, txn) { + const objectStore = txn.objectStore("device_data"); + objectStore.put(deviceData, "-"); + } + doTxn(mode, stores, func) { const txn = this._db.transaction(stores, mode); const promise = promiseifyTxn(txn); @@ -423,6 +457,9 @@ export function upgradeDatabase(db, oldVersion) { keyPath: ["senderCurve25519Key", "sessionId"], }); } + if (oldVersion < 5) { + db.createObjectStore("device_data"); + } // Expand as needed. } diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 6758e5da9ff..f2907be2632 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -96,7 +97,7 @@ export default class IndexedDBCryptoStore { `unable to connect to indexeddb ${this._dbName}` + `: falling back to localStorage store: ${e}`, ); - return new LocalStorageCryptoStore(); + return new LocalStorageCryptoStore(global.localStorage); }).catch((e) => { console.warn( `unable to open localStorage: falling back to in-memory store: ${e}`, @@ -358,6 +359,33 @@ export default class IndexedDBCryptoStore { ); } + // End-to-end device tracking + + /** + * Store the state of all tracked devices + * This contains devices for each user, a tracking state for each user + * and a sync token matching the point in time the snapshot represents. + * These all need to be written out in full each time such that the snapshot + * is always consistent, so they are stored in one object. + * + * @param {Object} deviceData + * @param {*} txn An active transaction. See doTxn(). + */ + storeEndToEndDeviceData(deviceData, txn) { + this._backendPromise.value().storeEndToEndDeviceData(deviceData, txn); + } + + /** + * Get the state of all tracked devices + * + * @param {*} txn An active transaction. See doTxn(). + * @param {function(Object)} func Function called with the + * device data + */ + getEndToEndDeviceData(txn, func) { + this._backendPromise.value().getEndToEndDeviceData(txn, func); + } + /** * Perform a transaction on the crypto store. Any store methods * that require a transaction (txn) object to be passed in may @@ -389,3 +417,4 @@ export default class IndexedDBCryptoStore { IndexedDBCryptoStore.STORE_ACCOUNT = 'account'; IndexedDBCryptoStore.STORE_SESSIONS = 'sessions'; IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS = 'inbound_group_sessions'; +IndexedDBCryptoStore.STORE_DEVICE_DATA = 'device_data'; diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index df1bb087a0f..760c1b85d26 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -1,5 +1,5 @@ /* -Copyright 2017 New Vector Ltd +Copyright 2017, 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -29,6 +29,7 @@ import MemoryCryptoStore from './memory-crypto-store.js'; const E2E_PREFIX = "crypto."; const KEY_END_TO_END_ACCOUNT = E2E_PREFIX + "account"; +const KEY_DEVICE_DATA = E2E_PREFIX + "device_data"; const KEY_INBOUND_SESSION_PREFIX = E2E_PREFIX + "inboundgroupsessions/"; function keyEndToEndSessions(deviceKey) { @@ -43,9 +44,9 @@ function keyEndToEndInboundGroupSession(senderKey, sessionId) { * @implements {module:crypto/store/base~CryptoStore} */ export default class LocalStorageCryptoStore extends MemoryCryptoStore { - constructor() { + constructor(webStore) { super(); - this.store = global.localStorage; + this.store = webStore; } // Olm Sessions @@ -127,6 +128,18 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { ); } + getEndToEndDeviceData(txn, func) { + func(getJsonItem( + this.store, KEY_DEVICE_DATA, + )); + } + + storeEndToEndDeviceData(deviceData, txn) { + setJsonItem( + this.store, KEY_DEVICE_DATA, deviceData, + ); + } + /** * Delete all data from this store. * @@ -140,12 +153,14 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { // Olm account getAccount(txn, func) { - const account = this.store.getItem(KEY_END_TO_END_ACCOUNT); + const account = getJsonItem(this.store, KEY_END_TO_END_ACCOUNT); func(account); } storeAccount(txn, newData) { - this.store.setItem(KEY_END_TO_END_ACCOUNT, newData); + setJsonItem( + this.store, KEY_END_TO_END_ACCOUNT, newData, + ); } doTxn(mode, stores, func) { diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index cefb80ee882..541d9fc235e 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -1,5 +1,6 @@ /* Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -36,6 +37,8 @@ export default class MemoryCryptoStore { this._sessions = {}; // Map of {senderCurve25519Key+'/'+sessionId -> session data object} this._inboundGroupSessions = {}; + // Opaque device data object + this._deviceData = null; } /** @@ -270,6 +273,16 @@ export default class MemoryCryptoStore { this._inboundGroupSessions[senderCurve25519Key+'/'+sessionId] = sessionData; } + // Device Data + + getEndToEndDeviceData(txn, func) { + func(this._deviceData); + } + + storeEndToEndDeviceData(deviceData, txn) { + this._deviceData = deviceData; + } + doTxn(mode, stores, func) { return Promise.resolve(func(null)); diff --git a/src/store/session/webstorage.js b/src/store/session/webstorage.js index 0fa813a2784..981bfbd7709 100644 --- a/src/store/session/webstorage.js +++ b/src/store/session/webstorage.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 New Vector Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -68,43 +69,24 @@ WebStorageSessionStore.prototype = { }, /** - * Stores the known devices for a user. - * @param {string} userId The user's ID. - * @param {object} devices A map from device ID to keys for the device. + * Retrieves the known devices for all users. + * @return {object} A map from user ID to map of device ID to keys for the device. */ - storeEndToEndDevicesForUser: function(userId, devices) { - setJsonItem(this.store, keyEndToEndDevicesForUser(userId), devices); - }, - - /** - * Retrieves the known devices for a user. - * @param {string} userId The user's ID. - * @return {object} A map from device ID to keys for the device. - */ - getEndToEndDevicesForUser: function(userId) { - return getJsonItem(this.store, keyEndToEndDevicesForUser(userId)); - }, - - storeEndToEndDeviceTrackingStatus: function(statusMap) { - setJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS, statusMap); + getAllEndToEndDevices: function() { + const prefix = keyEndToEndDevicesForUser(''); + const devices = {}; + for (let i = 0; i < this.store.length; ++i) { + const key = this.store.key(i); + const userId = key.substr(prefix.length); + if (key.startsWith(prefix)) devices[userId] = getJsonItem(this.store, key); + } + return devices; }, getEndToEndDeviceTrackingStatus: function() { return getJsonItem(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); }, - /** - * 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. * @@ -114,6 +96,15 @@ WebStorageSessionStore.prototype = { return getJsonItem(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); }, + /** + * Removes all end to end device data from the store + */ + removeEndToEndDeviceData: function() { + removeByPrefix(this.store, keyEndToEndDevicesForUser('')); + removeByPrefix(this.store, KEY_END_TO_END_DEVICE_LIST_TRACKING_STATUS); + removeByPrefix(this.store, KEY_END_TO_END_DEVICE_SYNC_TOKEN); + }, + /** * Retrieve the end-to-end sessions between the logged-in user and another * device. diff --git a/src/sync.js b/src/sync.js index 71fb866d523..02b16fdfefc 100644 --- a/src/sync.js +++ b/src/sync.js @@ -1,6 +1,7 @@ /* Copyright 2015, 2016 OpenMarket Ltd Copyright 2017 Vector Creations Ltd +Copyright 2018 New Vector Ltd Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -621,21 +622,30 @@ SyncApi.prototype._sync = async function(syncOptions) { await client.store.setSyncData(data); } + const syncEventData = { + oldSyncToken: syncToken, + nextSyncToken: data.next_batch, + catchingUp: this._catchingUp, + }; + + if (this.opts.crypto && !isCachedResponse) { + // tell the crypto module we're about to process a sync + // response + await this.opts.crypto.onSyncWillProcess(syncEventData); + } + try { - await this._processSyncResponse(syncToken, data, isCachedResponse); + await this._processSyncResponse(syncEventData, data, isCachedResponse); } catch(e) { // log the exception with stack if we have it, else fall back // to the plain description console.error("Caught /sync error", e.stack || e); } - // emit synced events - const syncEventData = { - oldSyncToken: syncToken, - nextSyncToken: data.next_batch, - catchingUp: this._catchingUp, - }; + // update this as it may have changed + syncEventData.catchingUp = this._catchingUp; + // emit synced events if (!syncOptions.hasSyncedBefore) { this._updateSyncState("PREPARED", syncEventData); syncOptions.hasSyncedBefore = true; @@ -702,13 +712,12 @@ SyncApi.prototype._onSyncError = function(err, syncOptions) { * Process data returned from a sync response and propagate it * into the model objects * - * @param {string} syncToken the old next_batch token sent to this - * sync request. + * @param {Object} syncEventData Object containing sync tokens associated with this sync * @param {Object} data The response from /sync * @param {bool} isCachedResponse True if this response is from our local cache */ SyncApi.prototype._processSyncResponse = async function( - syncToken, data, isCachedResponse, + syncEventData, data, isCachedResponse, ) { const client = this.client; const self = this; @@ -950,7 +959,8 @@ SyncApi.prototype._processSyncResponse = async function( self._deregisterStateListeners(room); room.resetLiveTimeline( joinObj.timeline.prev_batch, - self.opts.canResetEntireTimeline(room.roomId) ? null : syncToken, + self.opts.canResetEntireTimeline(room.roomId) ? + null : syncEventData.oldSyncToken, ); // We have to assume any gap in any timeline is @@ -1034,7 +1044,7 @@ SyncApi.prototype._processSyncResponse = async function( // in the timeline relative to ones paginated in by /notifications. // XXX: we could fix this by making EventTimeline support chronological // ordering... but it doesn't, right now. - if (syncToken && this._notifEvents.length) { + if (syncEventData.oldSyncToken && this._notifEvents.length) { this._notifEvents.sort(function(a, b) { return a.getTs() - b.getTs(); }); @@ -1046,7 +1056,9 @@ SyncApi.prototype._processSyncResponse = async function( // Handle device list updates if (data.device_lists) { if (this.opts.crypto) { - await this.opts.crypto.handleDeviceListChanges(data.device_lists); + await this.opts.crypto.handleDeviceListChanges( + syncEventData, data.device_lists, + ); } else { // FIXME if we *don't* have a crypto module, we still need to // invalidate the device lists. But that would require a