diff --git a/.travis.yml b/.travis.yml index 67382e760..823517fc5 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: node_js node_js: - - node # Latest stable version of nodejs. + - "10.11.0" script: - ./travis.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 82902fc22..46c90e029 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +Changes in [0.13.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.13.1) (2018-11-14) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.13.0...v0.13.1) + + * Add function to get currently joined rooms. + [\#779](https://github.com/matrix-org/matrix-js-sdk/pull/779) + +Changes in [0.13.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.13.0) (2018-11-15) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.1...v0.13.0) + +BREAKING CHANGE +---------------- + * `MatrixClient::login` now sets client `access_token` and `user_id` following successful login with username and password. + +Changes in [0.12.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.1) (2018-10-29) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.1-rc.1...v0.12.1) + + * No changes since rc.1 + +Changes in [0.12.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.1-rc.1) (2018-10-24) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.0...v0.12.1-rc.1) + + * Add repository type to package.json to make it valid + [\#762](https://github.com/matrix-org/matrix-js-sdk/pull/762) + * Add getMediaConfig() + [\#761](https://github.com/matrix-org/matrix-js-sdk/pull/761) + * add new examples, to be expanded into a post + [\#739](https://github.com/matrix-org/matrix-js-sdk/pull/739) + Changes in [0.12.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.12.0) (2018-10-16) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.12.0-rc.1...v0.12.0) diff --git a/browser-index.js b/browser-index.js index 66a6036f2..cc7e50fe8 100644 --- a/browser-index.js +++ b/browser-index.js @@ -1,5 +1,17 @@ var matrixcs = require("./lib/matrix"); -matrixcs.request(require("browser-request")); +const request = require('browser-request'); +const queryString = require('qs'); + +matrixcs.request(function(opts, fn) { + // We manually fix the query string for browser-request because + // it doesn't correctly handle cases like ?via=one&via=two. Instead + // we mimic `request`'s query string interface to make it all work + // as expected. + // browser-request will happily take the constructed string as the + // query string without trying to modify it further. + opts.qs = queryString.stringify(opts.qs || {}, opts.qsStringifyOptions); + return request(opts, fn); +}); // just *accessing* indexedDB throws an exception in firefox with // indexeddb disabled. diff --git a/package.json b/package.json index b44f1f246..4a968f2f2 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "0.12.0", + "version": "0.13.1", "description": "Matrix Client-Server SDK for Javascript", "main": "index.js", "scripts": { @@ -59,15 +59,16 @@ "bs58": "^4.0.1", "content-type": "^1.0.2", "loglevel": "1.6.1", - "request": "^2.53.0" + "qs": "^6.5.2", + "request": "^2.88.0" }, "devDependencies": { "babel-cli": "^6.18.0", - "babel-eslint": "^7.1.1", + "babel-eslint": "^8.1.1", "babel-plugin-transform-async-to-bluebird": "^1.1.1", "babel-plugin-transform-runtime": "^6.23.0", "babel-preset-es2015": "^6.18.0", - "browserify": "^14.0.0", + "browserify": "^16.2.3", "browserify-shim": "^3.8.13", "eslint": "^3.13.1", "eslint-config-google": "^0.7.1", @@ -83,7 +84,7 @@ "source-map-support": "^0.4.11", "sourceify": "^0.1.0", "uglify-js": "^2.8.26", - "watchify": "^3.2.1" + "watchify": "^3.11.0" }, "browserify": { "transform": [ diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 9a37e0b38..b302856cc 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -817,8 +817,14 @@ describe("megolm", function() { }; }); + // Grab the event that we'll need to resend + const room = aliceTestClient.client.getRoom(ROOM_ID); + const pendingEvents = room.getPendingEvents(); + expect(pendingEvents.length).toEqual(1); + const unsentEvent = pendingEvents[0]; + return Promise.all([ - aliceTestClient.client.sendTextMessage(ROOM_ID, 'test'), + aliceTestClient.client.resendEvent(unsentEvent, room), // the crypto stuff can take a while, so give the requests a whole second. aliceTestClient.httpBackend.flushAllExpected({ diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index ee06ef369..47d7d2d67 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -1,9 +1,13 @@ - -"use strict"; import 'source-map-support/register'; import Crypto from '../../lib/crypto'; import expect from 'expect'; +import WebStorageSessionStore from '../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../MockStorageApi'; + +const EventEmitter = require("events").EventEmitter; + const sdk = require("../.."); const Olm = global.Olm; @@ -20,4 +24,96 @@ describe("Crypto", function() { it("Crypto exposes the correct olm library version", function() { expect(Crypto.getOlmVersion()[0]).toEqual(3); }); + + + describe('Session management', function() { + const otkResponse = { + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:FLIBBLE': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally a valid signature', + }, + }, + }, + }, + }, + }, + }; + let crypto; + let mockBaseApis; + let mockRoomList; + + let fakeEmitter; + + beforeEach(async function() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + cryptoStore.storeEndToEndDeviceData({ + devices: { + '@bob:home.server': { + 'BOBDEVICE': { + keys: { + 'curve25519:BOBDEVICE': 'this is a key', + }, + }, + }, + }, + trackingStatus: {}, + }); + + mockBaseApis = { + sendToDevice: expect.createSpy(), + }; + mockRoomList = {}; + + fakeEmitter = new EventEmitter(); + + crypto = new Crypto( + mockBaseApis, + sessionStore, + "@alice:home.server", + "FLIBBLE", + sessionStore, + cryptoStore, + mockRoomList, + ); + crypto.registerEventHandlers(fakeEmitter); + await crypto.init(); + }); + + afterEach(async function() { + await crypto.stop(); + }); + + it("restarts wedged Olm sessions", async function() { + const prom = new Promise((resolve) => { + mockBaseApis.claimOneTimeKeys = function() { + resolve(); + return otkResponse; + }; + }); + + fakeEmitter.emit('toDeviceEvent', { + getType: expect.createSpy().andReturn('m.room.message'), + getContent: expect.createSpy().andReturn({ + msgtype: 'm.bad.encrypted', + }), + getWireContent: expect.createSpy().andReturn({ + algorithm: 'm.olm.v1.curve25519-aes-sha2', + sender_key: 'this is a key', + }), + getSender: expect.createSpy().andReturn('@bob:home.server'), + }); + + console.log("waiting"); + await prom; + console.log("done"); + }); + }); }); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 6c777859e..641adb19c 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -18,6 +18,7 @@ import Crypto from '../../../../lib/crypto'; const MatrixEvent = sdk.MatrixEvent; const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; +const MegolmEncryption = algorithms.ENCRYPTION_CLASSES['m.megolm.v1.aes-sha2']; const ROOM_ID = '!ROOM:ID'; @@ -34,9 +35,11 @@ describe("MegolmDecryption", function() { let mockCrypto; let mockBaseApis; - beforeEach(function() { + beforeEach(async function() { testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + await Olm.init(); + mockCrypto = testUtils.mock(Crypto, 'Crypto'); mockBaseApis = {}; @@ -66,7 +69,6 @@ describe("MegolmDecryption", function() { describe('receives some keys:', function() { let groupSession; beforeEach(async function() { - await Olm.init(); groupSession = new global.Olm.OutboundGroupSession(); groupSession.create(); @@ -263,5 +265,92 @@ describe("MegolmDecryption", function() { // test is successful if no exception is thrown }); }); + + it("re-uses sessions for sequential messages", async function() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + + const olmDevice = new OlmDevice(sessionStore, cryptoStore); + olmDevice.verifySignature = expect.createSpy(); + await olmDevice.init(); + + mockBaseApis.claimOneTimeKeys = expect.createSpy().andReturn(Promise.resolve({ + one_time_keys: { + '@alice:home.server': { + aliceDevice: { + 'signed_curve25519:flooble': { + key: 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI', + signatures: { + '@alice:home.server': { + 'ed25519:aliceDevice': 'totally valid', + }, + }, + }, + }, + }, + }, + })); + mockBaseApis.sendToDevice = expect.createSpy().andReturn(Promise.resolve()); + + mockCrypto.downloadKeys.andReturn(Promise.resolve({ + '@alice:home.server': { + aliceDevice: { + deviceId: 'aliceDevice', + isBlocked: expect.createSpy().andReturn(false), + isUnverified: expect.createSpy().andReturn(false), + getIdentityKey: expect.createSpy().andReturn( + 'YWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWE', + ), + getFingerprint: expect.createSpy().andReturn(''), + }, + }, + })); + + const megolmEncryption = new MegolmEncryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + config: { + rotation_period_ms: 9999999999999, + }, + }); + const mockRoom = { + getEncryptionTargetMembers: expect.createSpy().andReturn( + [{userId: "@alice:home.server"}], + ), + getBlacklistUnverifiedDevices: expect.createSpy().andReturn(false), + }; + const ct1 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some text", + }); + expect(mockRoom.getEncryptionTargetMembers).toHaveBeenCalled(); + + // this should have claimed a key for alice as it's starting a new session + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + expect(mockCrypto.downloadKeys).toHaveBeenCalledWith( + ['@alice:home.server'], false, + ); + expect(mockBaseApis.sendToDevice).toHaveBeenCalled(); + expect(mockBaseApis.claimOneTimeKeys).toHaveBeenCalled( + [['@alice:home.server', 'aliceDevice']], 'signed_curve25519', + ); + + mockBaseApis.claimOneTimeKeys.reset(); + + const ct2 = await megolmEncryption.encryptMessage(mockRoom, "a.fake.type", { + body: "Some more text", + }); + + // this should *not* have claimed a key as it should be using the same session + expect(mockBaseApis.claimOneTimeKeys).toNotHaveBeenCalled(); + + // likewise they should show the same session ID + expect(ct2.session_id).toEqual(ct1.session_id); + }); }); }); diff --git a/spec/unit/crypto/algorithms/olm.spec.js b/spec/unit/crypto/algorithms/olm.spec.js new file mode 100644 index 000000000..46fcff38b --- /dev/null +++ b/spec/unit/crypto/algorithms/olm.spec.js @@ -0,0 +1,90 @@ +/* +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. +*/ + +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run megolm tests: libolm not available"); +} + +import expect from 'expect'; +import WebStorageSessionStore from '../../../../lib/store/session/webstorage'; +import MemoryCryptoStore from '../../../../lib/crypto/store/memory-crypto-store.js'; +import MockStorageApi from '../../../MockStorageApi'; +import testUtils from '../../../test-utils'; + +import OlmDevice from '../../../../lib/crypto/OlmDevice'; + +function makeOlmDevice() { + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + const cryptoStore = new MemoryCryptoStore(mockStorage); + const olmDevice = new OlmDevice(sessionStore, cryptoStore); + return olmDevice; +} + +async function setupSession(initiator, opponent) { + await opponent.generateOneTimeKeys(1); + const keys = await opponent.getOneTimeKeys(); + const firstKey = Object.values(keys['curve25519'])[0]; + + const sid = await initiator.createOutboundSession( + opponent.deviceCurve25519Key, firstKey, + ); + return sid; +} + +describe("OlmDecryption", function() { + if (!global.Olm) { + console.warn('Not running megolm unit tests: libolm not present'); + return; + } + + let aliceOlmDevice; + let bobOlmDevice; + + beforeEach(async function() { + testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + + await global.Olm.init(); + + aliceOlmDevice = makeOlmDevice(); + bobOlmDevice = makeOlmDevice(); + await aliceOlmDevice.init(); + await bobOlmDevice.init(); + }); + + describe('olm', function() { + it("can decrypt messages", async function() { + const sid = await setupSession(aliceOlmDevice, bobOlmDevice); + + const ciphertext = await aliceOlmDevice.encryptMessage( + bobOlmDevice.deviceCurve25519Key, + sid, + "The olm or proteus is an aquatic salamander in the family Proteidae", + ); + + const result = await bobOlmDevice.createInboundSession( + aliceOlmDevice.deviceCurve25519Key, + ciphertext.type, + ciphertext.body, + ); + expect(result.payload).toEqual( + "The olm or proteus is an aquatic salamander in the family Proteidae", + ); + }); + }); +}); diff --git a/src/base-apis.js b/src/base-apis.js index 6dc9169dd..1515ca735 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -262,7 +262,19 @@ MatrixBaseApis.prototype.login = function(loginType, data, callback) { utils.extend(login_data, data); return this._http.authedRequest( - callback, "POST", "/login", undefined, login_data, + (error, response) => { + if (loginType === "m.login.password" && response && + response.access_token && response.user_id) { + this._http.opts.accessToken = response.access_token; + this.credentials = { + userId: response.user_id, + }; + } + + if (callback) { + callback(error, response); + } + }, "POST", "/login", undefined, login_data, ); }; @@ -927,6 +939,28 @@ MatrixBaseApis.prototype.setRoomReadMarkersHttpRequest = ); }; +/** + * @return {module:client.Promise} Resolves: A list of the user's current rooms + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getJoinedRooms = function() { + const path = utils.encodeUri("/joined_rooms"); + return this._http.authedRequest(undefined, "GET", path); +}; + +/** + * Retrieve membership info. for a room. + * @param {string} roomId ID of the room to get membership for + * @return {module:client.Promise} Resolves: A list of currently joined users + * and their profile data. + * @return {module:http-api.MatrixError} Rejects: with an error response. + */ +MatrixBaseApis.prototype.getJoinedRoomMembers = function(roomId) { + const path = utils.encodeUri("/rooms/$roomId/joined_members", { + $roomId: roomId, + }); + return this._http.authedRequest(undefined, "GET", path); +}; // Room Directory operations // ========================= diff --git a/src/client.js b/src/client.js index 9f66b7898..9ff1762c0 100644 --- a/src/client.js +++ b/src/client.js @@ -1246,6 +1246,8 @@ MatrixClient.prototype.isUserIgnored = function(userId) { * Default: true. * @param {boolean} opts.inviteSignUrl If the caller has a keypair 3pid invite, * the signing URL is passed in this parameter. + * @param {string[]} opts.viaServers The server names to try and join through in + * addition to those that are automatically chosen. * @param {module:client.callback} callback Optional. * @return {module:client.Promise} Resolves: Room object. * @return {module:http-api.MatrixError} Rejects: with an error response. @@ -1274,6 +1276,13 @@ MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) { ); } + const queryString = {}; + if (opts.viaServers) { + queryString["server_name"] = opts.viaServers; + } + + const reqOpts = {qsStringifyOptions: {arrayFormat: 'repeat'}}; + const defer = Promise.defer(); const self = this; @@ -1284,7 +1293,8 @@ MatrixClient.prototype.joinRoom = function(roomIdOrAlias, opts, callback) { } const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias}); - return self._http.authedRequest(undefined, "POST", path, undefined, data); + return self._http.authedRequest( + undefined, "POST", path, queryString, data, reqOpts); }).then(function(res) { const roomId = res.room_id; const syncApi = new SyncApi(self, self._clientOpts); @@ -1504,6 +1514,13 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, room.addPendingEvent(localEvent, txnId); } + // addPendingEvent can change the state to NOT_SENT if it believes + // that there's other events that have failed. We won't bother to + // try sending the event if the state has changed as such. + if (localEvent.status === EventStatus.NOT_SENT) { + return Promise.reject(new Error("Event blocked by other events not yet sent")); + } + return _sendEvent(this, room, localEvent, callback); }; diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index d7f182881..5868ca7bd 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -295,8 +295,8 @@ OlmDevice.prototype._storeAccount = function(txn, account) { */ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { this._cryptoStore.getEndToEndSession( - deviceKey, sessionId, txn, (pickledSession) => { - this._unpickleSession(pickledSession, func); + deviceKey, sessionId, txn, (sessionInfo) => { + this._unpickleSession(sessionInfo, func); }, ); }; @@ -306,15 +306,17 @@ OlmDevice.prototype._getSession = function(deviceKey, sessionId, txn, func) { * function with it. The session object is destroyed once the function * returns. * - * @param {string} pickledSession + * @param {object} sessionInfo * @param {function} func * @private */ -OlmDevice.prototype._unpickleSession = function(pickledSession, func) { +OlmDevice.prototype._unpickleSession = function(sessionInfo, func) { const session = new global.Olm.Session(); try { - session.unpickle(this._pickleKey, pickledSession); - func(session); + session.unpickle(this._pickleKey, sessionInfo.session); + const unpickledSessInfo = Object.assign({}, sessionInfo, {session}); + + func(unpickledSessInfo); } finally { session.free(); } @@ -324,14 +326,17 @@ OlmDevice.prototype._unpickleSession = function(pickledSession, func) { * store our OlmSession in the session store * * @param {string} deviceKey - * @param {OlmSession} session + * @param {object} sessionInfo {session: OlmSession, lastReceivedMessageTs: int} * @param {*} txn Opaque transaction object from cryptoStore.doTxn() * @private */ -OlmDevice.prototype._saveSession = function(deviceKey, session, txn) { - const pickledSession = session.pickle(this._pickleKey); +OlmDevice.prototype._saveSession = function(deviceKey, sessionInfo, txn) { + const sessionId = sessionInfo.session.session_id(); + const pickledSessionInfo = Object.assign(sessionInfo, { + session: sessionInfo.session.pickle(this._pickleKey), + }); this._cryptoStore.storeEndToEndSession( - deviceKey, session.session_id(), pickledSession, txn, + deviceKey, sessionId, pickledSessionInfo, txn, ); }; @@ -461,7 +466,14 @@ OlmDevice.prototype.createOutboundSession = async function( session.create_outbound(account, theirIdentityKey, theirOneTimeKey); newSessionId = session.session_id(); this._storeAccount(txn, account); - this._saveSession(theirIdentityKey, session, txn); + const sessionInfo = { + session, + // Pretend we've received a message at this point, otherwise + // if we try to send a message to the device, it won't use + // this session + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirIdentityKey, sessionInfo, txn); } finally { session.free(); } @@ -510,7 +522,13 @@ OlmDevice.prototype.createInboundSession = async function( const payloadString = session.decrypt(messageType, ciphertext); - this._saveSession(theirDeviceIdentityKey, session, txn); + const sessionInfo = { + session, + // this counts as a received message: set last received message time + // to now + lastReceivedMessageTs: Date.now(), + }; + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); result = { payload: payloadString, @@ -558,13 +576,30 @@ OlmDevice.prototype.getSessionIdsForDevice = async function(theirDeviceIdentityK * @return {Promise} session id, or null if no established session */ OlmDevice.prototype.getSessionIdForDevice = async function(theirDeviceIdentityKey) { - const sessionIds = await this.getSessionIdsForDevice(theirDeviceIdentityKey); - if (sessionIds.length === 0) { + const sessionInfos = await this.getSessionInfoForDevice(theirDeviceIdentityKey); + if (sessionInfos.length === 0) { return null; } - // Use the session with the lowest ID. - sessionIds.sort(); - return sessionIds[0]; + // Use the session that has most recently received a message + let idxOfBest = 0; + for (let i = 1; i < sessionInfos.length; i++) { + const thisSessInfo = sessionInfos[i]; + const thisLastReceived = thisSessInfo.lastReceivedMessageTs === undefined ? + 0 : thisSessInfo.lastReceivedMessageTs; + + const bestSessInfo = sessionInfos[idxOfBest]; + const bestLastReceived = bestSessInfo.lastReceivedMessageTs === undefined ? + 0 : bestSessInfo.lastReceivedMessageTs; + if ( + thisLastReceived > bestLastReceived || ( + thisLastReceived === bestLastReceived && + thisSessInfo.sessionId < bestSessInfo.sessionId + ) + ) { + idxOfBest = i; + } + } + return sessionInfos[idxOfBest].sessionId; }; /** @@ -587,9 +622,10 @@ OlmDevice.prototype.getSessionInfoForDevice = async function(deviceIdentityKey) this._cryptoStore.getEndToEndSessions(deviceIdentityKey, txn, (sessions) => { const sessionIds = Object.keys(sessions).sort(); for (const sessionId of sessionIds) { - this._unpickleSession(sessions[sessionId], (session) => { + this._unpickleSession(sessions[sessionId], (sessInfo) => { info.push({ - hasReceivedMessage: session.has_received_message(), + lastReceivedMessageTs: sessInfo.lastReceivedMessageTs, + hasReceivedMessage: sessInfo.session.has_received_message(), sessionId: sessionId, }); }); @@ -620,9 +656,9 @@ OlmDevice.prototype.encryptMessage = async function( await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - res = session.encrypt(payloadString); - this._saveSession(theirDeviceIdentityKey, session, txn); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + res = sessionInfo.session.encrypt(payloadString); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, ); @@ -647,9 +683,10 @@ OlmDevice.prototype.decryptMessage = async function( await this._cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - payloadString = session.decrypt(messageType, ciphertext); - this._saveSession(theirDeviceIdentityKey, session, txn); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + payloadString = sessionInfo.session.decrypt(messageType, ciphertext); + sessionInfo.lastReceivedMessageTs = Date.now(); + this._saveSession(theirDeviceIdentityKey, sessionInfo, txn); }); }, ); @@ -679,8 +716,8 @@ OlmDevice.prototype.matchesSession = async function( await this._cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_SESSIONS], (txn) => { - this._getSession(theirDeviceIdentityKey, sessionId, txn, (session) => { - matches = session.matches_inbound(ciphertext); + this._getSession(theirDeviceIdentityKey, sessionId, txn, (sessionInfo) => { + matches = sessionInfo.session.matches_inbound(ciphertext); }); }, ); @@ -714,7 +751,7 @@ OlmDevice.prototype._saveOutboundGroupSession = function(session) { */ OlmDevice.prototype._getOutboundGroupSession = function(sessionId, func) { const pickled = this._outboundGroupSessionStore[sessionId]; - if (pickled === null) { + if (pickled === undefined) { throw new Error("Unknown outbound group session " + sessionId); } @@ -1048,6 +1085,8 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se * @param {string} roomId room in which the message was received * @param {string} senderKey base64-encoded curve25519 key of the sender * @param {string} sessionId session identifier + * @param {integer} chainIndex The chain index at which to export the session. + * If omitted, export at the first index we know about. * * @returns {Promise<{chain_index: number, key: string, * forwarding_curve25519_key_chain: Array, @@ -1055,9 +1094,12 @@ OlmDevice.prototype.hasInboundSessionKeys = async function(roomId, senderKey, se * }>} * details of the session key. The key is a base64-encoded megolm key in * export format. + * + * @throws Error If the given chain index could not be obtained from the known + * index (ie. the given chain index is before the first we have). */ OlmDevice.prototype.getInboundGroupSessionKey = async function( - roomId, senderKey, sessionId, + roomId, senderKey, sessionId, chainIndex, ) { let result; await this._cryptoStore.doTxn( @@ -1068,14 +1110,19 @@ OlmDevice.prototype.getInboundGroupSessionKey = async function( result = null; return; } - const messageIndex = session.first_known_index(); + + if (chainIndex === undefined) { + chainIndex = session.first_known_index(); + } + + const exportedSession = session.export_session(chainIndex); const claimedKeys = sessionData.keysClaimed || {}; const senderEd25519Key = claimedKeys.ed25519 || null; result = { - "chain_index": messageIndex, - "key": session.export_session(messageIndex), + "chain_index": chainIndex, + "key": exportedSession, "forwarding_curve25519_key_chain": sessionData.forwardingCurve25519KeyChain || [], "sender_claimed_ed25519_key": senderEd25519Key, diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.js index 4c9b7cbf5..bfde6019b 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.js +++ b/src/crypto/OutgoingRoomKeyRequestManager.js @@ -244,6 +244,21 @@ export default class OutgoingRoomKeyRequestManager { }); } + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * + * @return {Promise} resolves to a list of all the + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + */ + getOutgoingSentRoomKeyRequest(userId, deviceId) { + return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget( + userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT], + ); + } + // start the background timer to send queued requests, if the timer isn't // already running _startTimer() { diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index c13ca4dfa..f244d7add 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -144,6 +144,11 @@ function MegolmEncryption(params) { // room). this._setupPromise = Promise.resolve(); + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using _setupPromise). + this._outboundSessions = {}; + // default rotation periods this._sessionRotationPeriodMsgs = 100; this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; @@ -195,6 +200,7 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { if (!session) { logger.log(`Starting new megolm session for room ${self._roomId}`); session = await self._prepareNewSession(); + self._outboundSessions[session.sessionId] = session; } // now check if we need to share with any devices @@ -421,8 +427,98 @@ MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( }; /** - * @private + * Re-shares a megolm session key with devices if the key has already been + * sent to them. * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ +MegolmEncryption.prototype.reshareKeyWithDevice = async function( + senderKey, sessionId, userId, device, +) { + const obSessionInfo = this._outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug("Session ID " + sessionId + " not found: not re-sharing keys"); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug("Session ID " + sessionId + " never shared with user " + userId); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "Session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this._olmDevice.getInboundGroupSessionKey( + this._roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + "No outbound session key found for " + sessionId + ": not re-sharing keys", + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, { + [userId]: { + [device.deviceId]: device, + }, + }, + ); + + const payload = { + type: "m.forwarded_room_key", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: this._roomId, + session_id: sessionId, + session_key: key.key, + chain_index: key.chain_index, + sender_key: senderKey, + sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, + forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + userId, + device, + payload, + ), + + await this._baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug( + `Re-shared key for session ${sessionId} with ${userId}:${device.deviceId}`, + ); +}; + +/** * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session * * @param {object} devicesByUser diff --git a/src/crypto/index.js b/src/crypto/index.js index 6fce09bcd..89c152f5b 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -41,6 +41,8 @@ export function isCryptoAvailable() { return Boolean(global.Olm); } +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + /** * Cryptography bits * @@ -128,6 +130,15 @@ export default function Crypto(baseApis, sessionStore, userId, deviceId, // has happened for a given room. This is delayed // to avoid loading room members as long as possible. this._roomDeviceTrackingState = {}; + + // The timestamp of the last time we forced establishment + // of a new session for each device, in milliseconds. + // { + // userId: { + // deviceId: 1234567890000, + // }, + // } + this._lastNewSessionForced = {}; } utils.inherits(Crypto, EventEmitter); @@ -1378,6 +1389,8 @@ Crypto.prototype._onToDeviceEvent = function(event) { this._onRoomKeyEvent(event); } else if (event.getType() == "m.room_key_request") { this._onRoomKeyRequestEvent(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this._onToDeviceBadEncrypted(event); } else if (event.isBeingDecrypted()) { // once the event has been decrypted, try again event.once('Event.decrypted', (ev) => { @@ -1413,6 +1426,87 @@ Crypto.prototype._onRoomKeyEvent = function(event) { alg.onRoomKeyEvent(event); }; +/** + * Handle a toDevice event that couldn't be decrypted + * + * @private + * @param {module:models/event.MatrixEvent} event undecryptable event + */ +Crypto.prototype._onToDeviceBadEncrypted = async function(event) { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {}; + const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0; + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + logger.debug( + "New session already forced with device " + sender + ":" + deviceKey + + " at " + lastNewSessionForced + ": not forcing another", + ); + return; + } + this._lastNewSessionForced[sender][deviceKey] = Date.now(); + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + const device = this._deviceList.getDeviceByIdentityKey(sender, algorithm, deviceKey); + const devicesByUser = {}; + devicesByUser[sender] = [device]; + await olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, devicesByUser, true, + ); + + // Now send a blank message on that session so the other side knows about it. + // (The keyshare request is sent in the clear so that won't do) + // We send this first such that, as long as the toDevice messages arrive in the + // same order we sent them, the other end will get this first, set up the new session, + // then get the keyshare request and send the key over this new session (because it + // is the session it has most recently received a message on). + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + sender, + device, + {type: "m.dummy"}, + ); + + await this._baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = + await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( + sender, device.deviceId, + ); + for (const keyReq of requestsToResend) { + this.cancelRoomKeyRequest(keyReq.requestBody, true); + } +}; + /** * Handle a change in the membership state of a member of a room * @@ -1538,9 +1632,27 @@ Crypto.prototype._processReceivedRoomKeyRequest = async function(req) { ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); if (userId !== this._userId) { - // TODO: determine if we sent this device the keys already: in - // which case we can do so again. - logger.log("Ignoring room key request from other user for now"); + if (!this._roomEncryptors[roomId]) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this._roomEncryptors[roomId]; + const device = this._deviceList.getStoredDevice(userId, deviceId); + if (!device) { + logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); + return; + } + + try { + await encryptor.reshareKeyWithDevice( + body.sender_key, body.session_id, userId, device, + ); + } catch (e) { + logger.warn( + "Failed to re-share keys for session " + body.session_id + + " with device " + userId + ":" + device.deviceId, e, + ); + } return; } diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.js index 2098ffe72..4ee89bf8d 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.js @@ -121,14 +121,17 @@ module.exports.encryptMessageForDevice = async function( * @param {module:base-apis~MatrixBaseApis} baseApis * * @param {object} devicesByUser - * map from userid to list of devices + * map from userid to list of devices to ensure sessions for + * + * @param {bolean} force If true, establish a new session even if one already exists. + * Optional. * * @return {module:client.Promise} resolves once the sessions are complete, to * an Object mapping from userId to deviceId to * {@link module:crypto~OlmSessionResult} */ module.exports.ensureOlmSessionsForDevices = async function( - olmDevice, baseApis, devicesByUser, + olmDevice, baseApis, devicesByUser, force, ) { const devicesWithoutSession = [ // [userId, deviceId], ... @@ -146,7 +149,7 @@ module.exports.ensureOlmSessionsForDevices = async function( const deviceId = deviceInfo.deviceId; const key = deviceInfo.getIdentityKey(); const sessionId = await olmDevice.getSessionIdForDevice(key); - if (sessionId === null) { + if (sessionId === null || force) { devicesWithoutSession.push([userId, deviceId]); } result[userId][deviceId] = { @@ -182,7 +185,7 @@ module.exports.ensureOlmSessionsForDevices = async function( for (let j = 0; j < devices.length; j++) { const deviceInfo = devices[j]; const deviceId = deviceInfo.deviceId; - if (result[userId][deviceId].sessionId) { + if (result[userId][deviceId].sessionId && !force) { // we already have a result for this device continue; } diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js index ac21e6f07..566ef9384 100644 --- a/src/crypto/store/indexeddb-crypto-store-backend.js +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -206,6 +206,42 @@ export class Backend { return promiseifyTxn(txn).then(() => result); } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + let stateIndex = 0; + const results = []; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (cursor) { + const keyReq = cursor.value; + if (keyReq.recipients.includes({userId, deviceId})) { + results.push(keyReq); + } + cursor.continue(); + } else { + // try the next state in the list + stateIndex++; + if (stateIndex >= wantedStates.length) { + // no matches + return; + } + + const wantedState = wantedStates[stateIndex]; + const cursorReq = ev.target.source.openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + } + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const wantedState = wantedStates[stateIndex]; + const cursorReq = store.index("state").openCursor(wantedState); + cursorReq.onsuccess = onsuccess; + + return promiseifyTxn(txn).then(() => results); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -314,7 +350,10 @@ export class Backend { getReq.onsuccess = function() { const cursor = getReq.result; if (cursor) { - results[cursor.value.sessionId] = cursor.value.session; + results[cursor.value.sessionId] = { + session: cursor.value.session, + lastReceivedMessageTs: cursor.value.lastReceivedMessageTs, + }; cursor.continue(); } else { try { @@ -332,7 +371,10 @@ export class Backend { getReq.onsuccess = function() { try { if (getReq.result) { - func(getReq.result.session); + func({ + session: getReq.result.session, + lastReceivedMessageTs: getReq.result.lastReceivedMessageTs, + }); } else { func(null); } @@ -342,9 +384,14 @@ export class Backend { }; } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { const objectStore = txn.objectStore("sessions"); - objectStore.put({deviceKey, sessionId, session}); + objectStore.put({ + deviceKey, + sessionId, + session: sessionInfo.session, + lastReceivedMessageTs: sessionInfo.lastReceivedMessageTs, + }); } // Inbound group sessions diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index fb49ee1e8..cb8ea23fe 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -207,6 +207,24 @@ export default class IndexedDBCryptoStore { }); } + /** + * Look for room key requests by target device and state + * + * @param {string} userId Target user ID + * @param {string} deviceId Target device ID + * @param {Array} wantedStates list of acceptable states + * + * @return {Promise} resolves to a list of all the + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + */ + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequestsByTarget( + userId, deviceId, wantedStates, + ); + }); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -284,7 +302,10 @@ export default class IndexedDBCryptoStore { * @param {string} sessionId The ID of the session to retrieve * @param {*} txn An active transaction. See doTxn(). * @param {function(object)} func Called with A map from sessionId - * to Base64 end-to-end session. + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. */ getEndToEndSession(deviceKey, sessionId, txn, func) { this._backendPromise.value().getEndToEndSession(deviceKey, sessionId, txn, func); @@ -296,7 +317,10 @@ export default class IndexedDBCryptoStore { * @param {string} deviceKey The public key of the other device. * @param {*} txn An active transaction. See doTxn(). * @param {function(object)} func Called with A map from sessionId - * to Base64 end-to-end session. + * to session information object with 'session' key being the + * Base64 end-to-end session and lastReceivedMessageTs being the + * timestamp in milliseconds at which the session last received + * a message. */ getEndToEndSessions(deviceKey, txn, func) { this._backendPromise.value().getEndToEndSessions(deviceKey, txn, func); @@ -306,12 +330,12 @@ export default class IndexedDBCryptoStore { * Store a session between the logged-in user and another device * @param {string} deviceKey The public key of the other device. * @param {string} sessionId The ID for this end-to-end session. - * @param {string} session Base64 encoded end-to-end session. + * @param {string} sessionInfo Session information object * @param {*} txn An active transaction. See doTxn(). */ - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { this._backendPromise.value().storeEndToEndSession( - deviceKey, sessionId, session, txn, + deviceKey, sessionId, sessionInfo, txn, ); } diff --git a/src/crypto/store/localStorage-crypto-store.js b/src/crypto/store/localStorage-crypto-store.js index 65d94eda5..5ca48a16c 100644 --- a/src/crypto/store/localStorage-crypto-store.js +++ b/src/crypto/store/localStorage-crypto-store.js @@ -68,7 +68,21 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { } _getEndToEndSessions(deviceKey, txn, func) { - return getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const sessions = getJsonItem(this.store, keyEndToEndSessions(deviceKey)); + const fixedSessions = {}; + + // fix up any old sessions to be objects rather than just the base64 pickle + for (const [sid, val] of Object.entries(sessions || {})) { + if (typeof val === 'string') { + fixedSessions[sid] = { + session: val, + }; + } else { + fixedSessions[sid] = val; + } + } + + return fixedSessions; } getEndToEndSession(deviceKey, sessionId, txn, func) { @@ -80,9 +94,9 @@ export default class LocalStorageCryptoStore extends MemoryCryptoStore { func(this._getEndToEndSessions(deviceKey) || {}); } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { const sessions = this._getEndToEndSessions(deviceKey) || {}; - sessions[sessionId] = session; + sessions[sessionId] = sessionInfo; setJsonItem( this.store, keyEndToEndSessions(deviceKey), sessions, ); diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index d9cfb8b98..03753e0e1 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -147,6 +147,19 @@ export default class MemoryCryptoStore { return Promise.resolve(null); } + getOutgoingRoomKeyRequestsByTarget(userId, deviceId, wantedStates) { + const results = []; + + for (const req of this._outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state && req.recipients.includes({userId, deviceId})) { + results.push(req); + } + } + } + return Promise.resolve(results); + } + /** * Look for an existing room key request by id and state, and update it if * found @@ -236,13 +249,13 @@ export default class MemoryCryptoStore { func(this._sessions[deviceKey] || {}); } - storeEndToEndSession(deviceKey, sessionId, session, txn) { + storeEndToEndSession(deviceKey, sessionId, sessionInfo, txn) { let deviceSessions = this._sessions[deviceKey]; if (deviceSessions === undefined) { deviceSessions = {}; this._sessions[deviceKey] = deviceSessions; } - deviceSessions[sessionId] = session; + deviceSessions[sessionId] = sessionInfo; } // Inbound Group Sessions diff --git a/src/http-api.js b/src/http-api.js index b753d6bf8..c29250d2a 100644 --- a/src/http-api.js +++ b/src/http-api.js @@ -752,6 +752,8 @@ module.exports.MatrixHttpApi.prototype = { method: method, withCredentials: false, qs: queryParams, + qsStringifyOptions: opts.qsStringifyOptions, + useQuerystring: true, body: data, json: false, timeout: localTimeoutMs, diff --git a/src/models/room.js b/src/models/room.js index 4b685c878..c58b4a632 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -999,6 +999,10 @@ Room.prototype.addPendingEvent = function(event, txnId) { this._txnToEvent[txnId] = event; if (this._opts.pendingEventOrdering == "detached") { + if (this._pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { + console.warn("Setting event as NOT_SENT due to messages in the same state"); + event.status = EventStatus.NOT_SENT; + } this._pendingEventList.push(event); } else { for (let i = 0; i < this._timelineSets.length; i++) { diff --git a/src/sync.js b/src/sync.js index 76122874b..1ce75ba82 100644 --- a/src/sync.js +++ b/src/sync.js @@ -516,7 +516,7 @@ SyncApi.prototype.sync = function() { console.warn("InvalidStoreError: store is not usable: stopping sync."); return; } - if (this.opts.lazyLoadMembers && this._crypto) { + if (this.opts.lazyLoadMembers && this.opts.crypto) { this.opts.crypto.enableLazyLoading(); } await this.client._storeClientOptions();