diff --git a/.travis.yml b/.travis.yml index 0fde128e1fe11cfca344e677fd9f5f97ca651d90..67382e7601e0e6cab3858213a284a34bedfe5982 100644 --- a/.travis.yml +++ b/.travis.yml @@ -2,5 +2,4 @@ language: node_js node_js: - node # Latest stable version of nodejs. script: - - npm run lint - - npm run test + - ./travis.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f16b237f5cac5300c246ef273399f0e806bfdfc..7f3aec99ec439ff0411c62c9e6971e52e8a8eb67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,44 @@ +Changes in [0.7.13](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.13) (2017-06-22) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.12...v0.7.13) + + * Fix failure on Tor browser + [\#473](https://github.com/matrix-org/matrix-js-sdk/pull/473) + * Fix issues with firefox private browsing + [\#472](https://github.com/matrix-org/matrix-js-sdk/pull/472) + +Changes in [0.7.12](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.12) (2017-06-19) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.12-rc.1...v0.7.12) + + * No changes + + +Changes in [0.7.12-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.12-rc.1) (2017-06-15) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.11...v0.7.12-rc.1) + + * allow setting iceTransportPolicy to relay through forceTURN option + [\#462](https://github.com/matrix-org/matrix-js-sdk/pull/462) + +Changes in [0.7.11](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.11) (2017-06-12) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.11-rc.1...v0.7.11) + + * Add a bunch of logging around sending messages + [\#460](https://github.com/matrix-org/matrix-js-sdk/pull/460) + +Changes in [0.7.11-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.11-rc.1) (2017-06-09) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.10...v0.7.11-rc.1) + + * Make TimelineWindow.load resolve quicker if we have the events + [\#458](https://github.com/matrix-org/matrix-js-sdk/pull/458) + * Stop peeking when a matrix client is stopped + [\#451](https://github.com/matrix-org/matrix-js-sdk/pull/451) + * Update README: Clarify how to install libolm + [\#450](https://github.com/matrix-org/matrix-js-sdk/pull/450) + Changes in [0.7.10](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v0.7.10) (2017-06-02) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v0.7.9...v0.7.10) diff --git a/README.md b/README.md index 5c549062ba89c4cc36e023373495a08372f67dbc..48f0abf2c96ff6dd8e7cca3d1c1a780f4a3a3e16 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,8 @@ In a browser Download either the full or minified version from https://github.com/matrix-org/matrix-js-sdk/releases/latest and add that as a ``<script>`` to your page. There will be a global variable ``matrixcs`` -attached to ``window`` through which you can access the SDK. +attached to ``window`` through which you can access the SDK. See below for how to +include libolm to enable end-to-end-encryption. Please check [the working browser example](examples/browser) for more information. @@ -29,8 +30,9 @@ In Node.js console.log("Public Rooms: %s", JSON.stringify(data)); }); ``` +See below for how to include libolm to enable end-to-end-encryption. Please check +[the Node.js terminal app](examples/node) for a more complex example. -Please check [the Node.js terminal app](examples/node) for a more complex example. What does this SDK do? ---------------------- @@ -243,16 +245,32 @@ The SDK supports end-to-end encryption via the Olm and Megolm protocols, using [libolm](http://matrix.org/git/olm). It is left up to the application to make libolm available, via the ``Olm`` global. -To enable support in a browser application: +If the ``Olm`` global is not available, the SDK will show a warning: - * download the transpiled libolm (either via ``npm install olm``, or from - https://matrix.org/packages/npm/olm/). +``` +Unable to load crypto module: crypto will be disabled: Error: global.Olm is not defined +``` + +The SDK will continue to work for unencrypted rooms, but it will not support +the E2E parts of the Matrix specification. + +To enable E2E support in a browser application: + + * download the transpiled libolm (from https://matrix.org/packages/npm/olm/). * load ``olm.js`` as a ``<script>`` *before* ``browser-matrix.js``. -To enable support in a node.js application: +To enable E2E support in a node.js application: + + * ``npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz`` + (replace the URL with the latest version you want to use from + https://matrix.org/packages/npm/olm/) + * ``global.Olm = require('olm');`` *before* loading ``matrix-js-sdk``. + +If you want to package Olm as dependency for your node.js application, you +can use ``npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz +--save-optional`` (if your application also works without e2e crypto enabled) +or ``--save`` (if it doesn't) to do so. - * ``npm install olm`` - * ``require('olm');`` *before* ``matrix-js-sdk``. Contributing ============ diff --git a/browser-index.js b/browser-index.js index b6b8541a23836b0240cbb826d503a0442810be7c..66a6036f25052719bb483f5aa876e42cd0d3c076 100644 --- a/browser-index.js +++ b/browser-index.js @@ -1,13 +1,23 @@ var matrixcs = require("./lib/matrix"); matrixcs.request(require("browser-request")); -matrixcs.setCryptoStoreFactory( - function() { - return new matrixcs.IndexedDBCryptoStore( - global.indexedDB, "matrix-js-sdk:crypto" - ); - } -); +// just *accessing* indexedDB throws an exception in firefox with +// indexeddb disabled. +var indexedDB; +try { + indexedDB = global.indexedDB; +} catch(e) {} + +// if our browser (appears to) support indexeddb, use an indexeddb crypto store. +if (indexedDB) { + matrixcs.setCryptoStoreFactory( + function() { + return new matrixcs.IndexedDBCryptoStore( + indexedDB, "matrix-js-sdk:crypto" + ); + } + ); +} module.exports = matrixcs; // keep export for browserify package deps global.matrixcs = matrixcs; diff --git a/package.json b/package.json index da27fa5a5a7b874582d46b4357cce381b9717faa..67420c22b58b7fb72c152f6e8077e41d6af8d05d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "0.7.10", + "version": "0.7.13", "description": "Matrix Client-Server SDK for Javascript", "main": "index.js", "scripts": { @@ -13,7 +13,7 @@ "build": "babel -s -d lib src && rimraf dist && mkdir dist && browserify -d browser-index.js | exorcist dist/browser-matrix.js.map > dist/browser-matrix.js && uglifyjs -c -m -o dist/browser-matrix.min.js --source-map dist/browser-matrix.min.js.map --in-source-map dist/browser-matrix.js.map dist/browser-matrix.js", "dist": "npm run build", "watch": "watchify -d browser-index.js -o 'exorcist dist/browser-matrix.js.map > dist/browser-matrix.js' -v", - "lint": "eslint --max-warnings 113 src spec", + "lint": "eslint --max-warnings 112 src spec", "prepublish": "npm run build && git rev-parse HEAD > git-revision.txt" }, "repository": { diff --git a/spec/TestClient.js b/spec/TestClient.js index 7eceea395d86b6d9453466c660b7aee5a416f8a5..046b48e2190e5748960ce506f76a37a969fc0852 100644 --- a/spec/TestClient.js +++ b/spec/TestClient.js @@ -17,6 +17,9 @@ limitations under the License. "use strict"; +// load olm before the sdk if possible +import './olm-loader'; + import sdk from '..'; import testUtils from './test-utils'; import MockHttpBackend from './mock-request'; diff --git a/spec/integ/matrix-client-event-timeline.spec.js b/spec/integ/matrix-client-event-timeline.spec.js index 5366e84d90e7172a0166b5ff78c15a50eb348425..989464d4676c8b825f5269e4c9916af95f37913c 100644 --- a/spec/integ/matrix-client-event-timeline.spec.js +++ b/spec/integ/matrix-client-event-timeline.spec.js @@ -91,8 +91,10 @@ function startClient(httpBackend, client) { deferred.resolve(); }); - httpBackend.flush(); - return deferred.promise; + return q.all([ + httpBackend.flush(), + deferred.promise, + ]); } describe("getEventTimeline support", function() { @@ -202,8 +204,11 @@ describe("getEventTimeline support", function() { end: "pagin_end", }); - return httpBackend.flush("/sync", 2); + }).then(() => { + // the sync isn't processed immediately; give the promise chain + // a chance to complete. + return q.delay(0); }).then(function() { expect(room.timeline.length).toEqual(1); expect(room.timeline[0].event).toEqual(EVENTS[1]); @@ -225,7 +230,7 @@ describe("MatrixClient event timelines", function() { let client = null; let httpBackend = null; - beforeEach(function(done) { + beforeEach(function() { utils.beforeEach(this); // eslint-disable-line no-invalid-this httpBackend = new HttpBackend(); sdk.request(httpBackend.requestFn); @@ -237,8 +242,7 @@ describe("MatrixClient event timelines", function() { timelineSupport: true, }); - startClient(httpBackend, client) - .catch(utils.failTest).done(done); + return startClient(httpBackend, client); }); afterEach(function() { diff --git a/spec/integ/megolm.spec.js b/spec/integ/megolm-integ.spec.js similarity index 99% rename from spec/integ/megolm.spec.js rename to spec/integ/megolm-integ.spec.js index ce2869ae1dfe1939928ba95efe0f66e059f39a00..557f36e802a2fe917b93b53a4db670c7fc024abb 100644 --- a/spec/integ/megolm.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -16,18 +16,10 @@ limitations under the License. "use strict"; -import 'source-map-support/register'; - -let Olm = null; -try { - Olm = require('olm'); -} catch (e) {} - const anotherjson = require('another-json'); const q = require('q'); import expect from 'expect'; -const sdk = require('../..'); const utils = require('../../lib/utils'); const testUtils = require('../test-utils'); const TestClient = require('../TestClient').default; @@ -46,7 +38,7 @@ function createOlmSession(olmAccount, recipientTestClient) { const otkId = utils.keys(keys)[0]; const otk = keys[otkId]; - const session = new Olm.Session(); + const session = new global.Olm.Session(); session.create_outbound( olmAccount, recipientTestClient.getDeviceKey(), otk.key, ); @@ -209,9 +201,11 @@ function getSyncResponse(roomMembers) { describe("megolm", function() { - if (!sdk.CRYPTO_ENABLED) { + if (!global.Olm) { + console.warn('not running megolm tests: Olm not present'); return; } + const Olm = global.Olm; let testOlmAccount; let testSenderKey; diff --git a/spec/olm-loader.js b/spec/olm-loader.js new file mode 100644 index 0000000000000000000000000000000000000000..e650594a024c1c9fda9886950bd04fcf56f2e7fb --- /dev/null +++ b/spec/olm-loader.js @@ -0,0 +1,24 @@ +/* +Copyright 2017 Vector creations 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 to load the olm library. + +try { + global.Olm = require('olm'); + console.log('loaded libolm'); +} catch (e) { + console.warn("unable to run crypto tests: libolm not available"); +} diff --git a/spec/test-utils.js b/spec/test-utils.js index 88168130db3048c583ec2bad53a988774aed9184..c7590ff83b1bcbd6d058eafb5cc2a1ce05749760 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -2,7 +2,10 @@ import expect from 'expect'; import q from 'q'; -const sdk = require(".."); +// load olm before the sdk if possible +import './olm-loader'; + +import sdk from '..'; const MatrixEvent = sdk.MatrixEvent; /** diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js new file mode 100644 index 0000000000000000000000000000000000000000..1383cecbd906700a1bf2216549b3f5800c614d18 --- /dev/null +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -0,0 +1,160 @@ +try { + global.Olm = require('olm'); +} catch (e) { + console.warn("unable to run megolm tests: libolm not available"); +} + +import expect from 'expect'; +import q from 'q'; + +import sdk from '../../../..'; +import algorithms from '../../../../lib/crypto/algorithms'; +import WebStorageSessionStore from '../../../../lib/store/session/webstorage'; +import MockStorageApi from '../../../MockStorageApi'; +import testUtils from '../../../test-utils'; + +// Crypto and OlmDevice won't import unless we have global.Olm +let OlmDevice; +let Crypto; +if (global.Olm) { + OlmDevice = require('../../../../lib/crypto/OlmDevice'); + Crypto = require('../../../../lib/crypto'); +} + +const MatrixEvent = sdk.MatrixEvent; +const MegolmDecryption = algorithms.DECRYPTION_CLASSES['m.megolm.v1.aes-sha2']; + +const ROOM_ID = '!ROOM:ID'; + +describe("MegolmDecryption", function() { + if (!global.Olm) { + console.warn('Not running megolm unit tests: libolm not present'); + return; + } + + let megolmDecryption; + let mockOlmLib; + let mockCrypto; + let mockBaseApis; + + beforeEach(function() { + testUtils.beforeEach(this); // eslint-disable-line no-invalid-this + + mockCrypto = testUtils.mock(Crypto, 'Crypto'); + mockBaseApis = {}; + + const mockStorage = new MockStorageApi(); + const sessionStore = new WebStorageSessionStore(mockStorage); + + const olmDevice = new OlmDevice(sessionStore); + + megolmDecryption = new MegolmDecryption({ + userId: '@user:id', + crypto: mockCrypto, + olmDevice: olmDevice, + baseApis: mockBaseApis, + roomId: ROOM_ID, + }); + + + // we stub out the olm encryption bits + mockOlmLib = {}; + mockOlmLib.ensureOlmSessionsForDevices = expect.createSpy(); + mockOlmLib.encryptMessageForDevice = expect.createSpy(); + megolmDecryption.olmlib = mockOlmLib; + }); + + describe('receives some keys:', function() { + let groupSession; + beforeEach(function() { + groupSession = new global.Olm.OutboundGroupSession(); + groupSession.create(); + + const event = new MatrixEvent({}); + event.setClearData( + { + type: 'm.room_key', + content: { + algorithm: 'm.megolm.v1.aes-sha2', + room_id: ROOM_ID, + session_id: groupSession.session_id(), + session_key: groupSession.session_key(), + }, + }, + "SENDER_CURVE25519", + "SENDER_ED25519", + ); + + megolmDecryption.onRoomKeyEvent(event); + }); + + it('can decrypt an event', function() { + const event = new MatrixEvent({ + type: 'm.room.encrypted', + room_id: ROOM_ID, + content: { + algorithm: 'm.megolm.v1.aes-sha2', + sender_key: "SENDER_CURVE25519", + session_id: groupSession.session_id(), + ciphertext: groupSession.encrypt(JSON.stringify({ + room_id: ROOM_ID, + content: 'testytest', + })), + }, + }); + + megolmDecryption.decryptEvent(event); + expect(event.getContent()).toEqual('testytest'); + }); + + it('can respond to a key request event', function() { + const keyRequest = { + userId: '@alice:foo', + deviceId: 'alidevice', + requestBody: { + room_id: ROOM_ID, + sender_key: "SENDER_CURVE25519", + session_id: groupSession.session_id(), + }, + }; + + expect(megolmDecryption.hasKeysForKeyRequest(keyRequest)) + .toBe(true); + + // set up some pre-conditions for the share call + const deviceInfo = {}; + mockCrypto.getStoredDevice.andReturn(deviceInfo); + mockOlmLib.ensureOlmSessionsForDevices.andReturn( + q({'@alice:foo': {'alidevice': { + sessionId: 'alisession', + }}}), + ); + mockBaseApis.sendToDevice = expect.createSpy(); + + + // do the share + megolmDecryption.shareKeysWithDevice(keyRequest); + + // it's asynchronous, so we have to wait a bit + return q.delay(1).then(() => { + // check that it called encryptMessageForDevice with + // appropriate args. + expect(mockOlmLib.encryptMessageForDevice.calls.length) + .toEqual(1); + + const call = mockOlmLib.encryptMessageForDevice.calls[0]; + const payload = call.arguments[6]; + + expect(payload.type).toEqual("m.forwarded_room_key"); + expect(payload.content).toInclude({ + sender_key: "SENDER_CURVE25519", + sender_claimed_ed25519_key: "SENDER_ED25519", + session_id: groupSession.session_id(), + chain_index: 0, + forwarding_curve25519_key_chain: [], + }); + expect(payload.content.session_key).toExist(); + }); + }); + }); +}); diff --git a/src/base-apis.js b/src/base-apis.js index 9ffb6815110d0c6088016cba25eff3622776b34c..5a176870194026862bb485d5d1e1e8407444f109 100644 --- a/src/base-apis.js +++ b/src/base-apis.js @@ -683,6 +683,31 @@ MatrixBaseApis.prototype.setRoomDirectoryVisibilityAppService = ); }; +// User Directory Operations +// ========================= + +/** + * Query the user directory with a term matching user IDs, display names and domains. + * @param {object} opts options + * @param {string} opts.term the term with which to search. + * @param {number} opts.limit the maximum number of results to return. The server will + * apply a limit if unspecified. + * @return {module:client.Promise} Resolves: an array of results. + */ +MatrixBaseApis.prototype.searchUserDirectory = function(opts) { + const body = { + search_term: opts.term, + }; + + if (opts.limit !== undefined) { + body.limit = opts.limit; + } + + return this._http.authedRequest( + undefined, "POST", "/user_directory/search", undefined, body, + ); +}; + // Media operations // ================ diff --git a/src/client.js b/src/client.js index f7beb7b0c8a85c65b3117ea73fbb0ac3ba7ad90c..7beb8c00f9755c277919391ea34da271036f7709 100644 --- a/src/client.js +++ b/src/client.js @@ -40,6 +40,8 @@ const SyncApi = require("./sync"); const MatrixBaseApis = require("./base-apis"); const MatrixError = httpApi.MatrixError; +import reEmit from './reemit'; + const SCROLLBACK_DELAY_MS = 3000; let CRYPTO_ENABLED = false; @@ -166,6 +168,10 @@ function MatrixClient(opts) { this.store, opts.cryptoStore, ); + reEmit(this, this._crypto, [ + "crypto.roomKeyRequest", + "crypto.roomKeyRequestCancellation", + ]); this.olmVersion = Crypto.getOlmVersion(); } @@ -941,6 +947,8 @@ MatrixClient.prototype.sendEvent = function(roomId, eventType, content, txnId, txnId = this.makeTxnId(); } + console.log(`sendEvent of type ${eventType} in ${roomId} with txnId ${txnId}`); + // we always construct a MatrixEvent when sending because the store and // scheduler use them. We'll extract the params back out if it turns out // the client has no scheduler or store. @@ -1065,7 +1073,12 @@ function _sendEventHttpRequest(client, event) { return client._http.authedRequest( undefined, "PUT", path, undefined, event.getWireContent(), - ); + ).then((res) => { + console.log( + `Event sent to ${event.getRoomId()} with event id ${res.event_id}`, + ); + return res; + }); } /** @@ -2850,6 +2863,7 @@ MatrixClient.prototype.startClient = function(opts) { if (this._crypto) { this._crypto.uploadDeviceKeys().done(); + this._crypto.start(); } // periodically poll for turn servers if we support voip @@ -2882,12 +2896,20 @@ MatrixClient.prototype.startClient = function(opts) { * clean shutdown. */ MatrixClient.prototype.stopClient = function() { + console.log('stopping MatrixClient'); + this.clientRunning = false; // TODO: f.e. Room => self.store.storeRoom(room) ? if (this._syncApi) { this._syncApi.stop(); this._syncApi = null; } + if (this._crypto) { + this._crypto.stop(); + } + if (this._peekSync) { + this._peekSync.stopPeeking(); + } global.clearTimeout(this._checkTurnServersTimeoutID); }; diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.js index e3d55e3184950772bc365543c90dafdd4562a059..2b608475b7d2e5cd94fa9b5a192674c18024b008 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.js @@ -76,6 +76,10 @@ export default class DeviceList { if (this._keyDownloadsInProgressByUser[u]) { // already a key download in progress/queued for this user; its results // will be good enough for us. + console.log( + `downloadKeys: already have a download in progress for ` + + `${u}: awaiting its result`, + ); promises.push(this._keyDownloadsInProgressByUser[u]); } else if (forceDownload || trackingStatus != TRACKING_STATUS_UP_TO_DATE) { usersToDownload.push(u); @@ -88,6 +92,10 @@ export default class DeviceList { promises.push(downloadPromise); } + if (promises.length === 0) { + console.log("downloadKeys: already have all necessary keys"); + } + return q.all(promises).then(() => { return this._getDevicesFromStore(userIds); }); diff --git a/src/crypto/OlmDevice.js b/src/crypto/OlmDevice.js index 760d3ccb8e608904c84eadcfdc80e6a0d2ae0d8e..392b059ee53ac0b93298f80c916cf128ad6bdce1 100644 --- a/src/crypto/OlmDevice.js +++ b/src/crypto/OlmDevice.js @@ -55,6 +55,8 @@ function checkPayloadLength(payloadString) { * * @typedef {Object} module:crypto/OlmDevice.MegolmSessionData * @property {String} sender_key Sender's Curve25519 device key + * @property {String[]} forwarding_curve25519_key_chain Devices which forwarded + * this session to us (normally empty). * @property {Object<string, string>} sender_claimed_keys Other keys the sender claims. * @property {String} room_id Room this session is used in * @property {String} session_id Unique id for the session @@ -580,27 +582,30 @@ OlmDevice.prototype.getOutboundGroupSessionKey = function(sessionId) { // Inbound group session // ===================== +/** + * data stored in the session store about an inbound group session + * + * @typedef {Object} InboundGroupSessionData + * @property {string} room_Id + * @property {string} session pickled Olm.InboundGroupSession + * @property {Object<string, string>} keysClaimed + * @property {Array<string>} forwardingCurve25519KeyChain Devices involved in forwarding + * this session to us (normally empty). + */ + /** * store an InboundGroupSession in the session store * - * @param {string} roomId * @param {string} senderCurve25519Key * @param {string} sessionId - * @param {Olm.InboundGroupSession} session - * @param {object} keysClaimed Other keys the sender claims. + * @param {InboundGroupSessionData} sessionData * @private */ OlmDevice.prototype._saveInboundGroupSession = function( - roomId, senderCurve25519Key, sessionId, session, keysClaimed, + senderCurve25519Key, sessionId, sessionData, ) { - const r = { - room_id: roomId, - session: session.pickle(this._pickleKey), - keysClaimed: keysClaimed, - }; - this._sessionStore.storeEndToEndInboundGroupSession( - senderCurve25519Key, sessionId, JSON.stringify(r), + senderCurve25519Key, sessionId, JSON.stringify(sessionData), ); }; @@ -610,8 +615,8 @@ OlmDevice.prototype._saveInboundGroupSession = function( * @param {string} roomId * @param {string} senderKey * @param {string} sessionId - * @param {function(Olm.InboundGroupSession, Object<string, string>): T} func - * function to call. Second argument is the map of keys claimed by the session. + * @param {function(Olm.InboundGroupSession, InboundGroupSessionData): T} func + * function to call. * * @return {null} the sessionId is unknown * @@ -645,7 +650,7 @@ OlmDevice.prototype._getInboundGroupSession = function( const session = new Olm.InboundGroupSession(); try { session.unpickle(this._pickleKey, r.session); - return func(session, r.keysClaimed || {}); + return func(session, r); } finally { session.free(); } @@ -656,17 +661,23 @@ OlmDevice.prototype._getInboundGroupSession = function( * * @param {string} roomId room in which this session will be used * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {Array<string>} forwardingCurve25519KeyChain Devices involved in forwarding + * this session to us. * @param {string} sessionId session identifier * @param {string} sessionKey base64-encoded secret key * @param {Object<string, string>} keysClaimed Other keys the sender claims. + * @param {boolean} exportFormat true if the megolm keys are in export format + * (ie, they lack an ed25519 signature) */ OlmDevice.prototype.addInboundGroupSession = function( - roomId, senderKey, sessionId, sessionKey, keysClaimed, + roomId, senderKey, forwardingCurve25519KeyChain, + sessionId, sessionKey, keysClaimed, + exportFormat, ) { const self = this; /* if we already have this session, consider updating it */ - function updateSession(session) { + function updateSession(session, sessionData) { console.log("Update for megolm session " + senderKey + "/" + sessionId); // for now we just ignore updates. TODO: implement something here @@ -684,14 +695,26 @@ OlmDevice.prototype.addInboundGroupSession = function( // new session. const session = new Olm.InboundGroupSession(); try { - session.create(sessionKey); + if (exportFormat) { + session.import_session(sessionKey); + } else { + session.create(sessionKey); + } if (sessionId != session.session_id()) { throw new Error( "Mismatched group session ID from senderKey: " + senderKey, ); } + + const sessionData = { + room_id: roomId, + session: session.pickle(this._pickleKey), + keysClaimed: keysClaimed, + forwardingCurve25519KeyChain: forwardingCurve25519KeyChain, + }; + self._saveInboundGroupSession( - roomId, senderKey, sessionId, session, keysClaimed, + senderKey, sessionId, sessionData, ); } finally { session.free(); @@ -706,7 +729,7 @@ OlmDevice.prototype.addInboundGroupSession = function( */ OlmDevice.prototype.importInboundGroupSession = function(data) { /* if we already have this session, consider updating it */ - function updateSession(session) { + function updateSession(session, sessionData) { console.log("Update for megolm session " + data.sender_key + "|" + data.session_id); // for now we just ignore updates. TODO: implement something here @@ -731,9 +754,16 @@ OlmDevice.prototype.importInboundGroupSession = function(data) { "Mismatched group session ID from senderKey: " + data.sender_key, ); } + + const sessionData = { + room_id: data.room_id, + session: session.pickle(this._pickleKey), + keysClaimed: data.sender_claimed_keys, + forwardingCurve25519KeyChain: data.forwarding_curve25519_key_chain, + }; + this._saveInboundGroupSession( - data.room_id, data.sender_key, data.session_id, session, - data.sender_claimed_keys, + data.sender_key, data.session_id, sessionData, ); } finally { session.free(); @@ -750,15 +780,16 @@ OlmDevice.prototype.importInboundGroupSession = function(data) { * * @return {null} the sessionId is unknown * - * @return {{result: string, keysProved: Object<string, string>, keysClaimed: - * Object<string, string>}} result + * @return {{result: string, senderKey: string, + * forwardingCurve25519KeyChain: Array<string>, + * keysClaimed: Object<string, string>}} */ OlmDevice.prototype.decryptGroupMessage = function( roomId, senderKey, sessionId, body, ) { const self = this; - function decrypt(session, keysClaimed) { + function decrypt(session, sessionData) { const res = session.decrypt(body); let plaintext = res.plaintext; @@ -777,17 +808,15 @@ OlmDevice.prototype.decryptGroupMessage = function( self._inboundGroupSessionMessageIndexes[messageIndexKey] = true; } - // the sender must have had the senderKey to persuade us to save the - // session. - const keysProved = {curve25519: senderKey}; - + sessionData.session = session.pickle(self._pickleKey); self._saveInboundGroupSession( - roomId, senderKey, sessionId, session, keysClaimed, + senderKey, sessionId, sessionData, ); return { result: plaintext, - keysClaimed: keysClaimed, - keysProved: keysProved, + keysClaimed: sessionData.keysClaimed || {}, + senderKey: senderKey, + forwardingCurve25519KeyChain: sessionData.forwardingCurve25519KeyChain || [], }; } @@ -796,6 +825,72 @@ OlmDevice.prototype.decryptGroupMessage = function( ); }; +/** + * Determine if we have the keys for a given megolm session + * + * @param {string} roomId room in which the message was received + * @param {string} senderKey base64-encoded curve25519 key of the sender + * @param {sring} sessionId session identifier + * + * @returns {boolean} true if we have the keys to this session + */ +OlmDevice.prototype.hasInboundSessionKeys = function(roomId, senderKey, sessionId) { + const s = this._sessionStore.getEndToEndInboundGroupSession( + senderKey, sessionId, + ); + + if (s === null) { + return false; + } + + const r = JSON.parse(s); + if (roomId !== r.room_id) { + console.warn( + `requested keys for inbound group session ${senderKey}|` + + `${sessionId}, with incorrect room_id (expected ${r.room_id}, ` + + `was ${roomId})`, + ); + return false; + } + + return true; +}; + +/** + * Extract the keys to a given megolm session, for sharing + * + * @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 + * + * @returns {{chain_index: number, key: string, + * forwarding_curve25519_key_chain: Array<string>, + * sender_claimed_ed25519_key: string + * }} + * details of the session key. The key is a base64-encoded megolm key in + * export format. + */ +OlmDevice.prototype.getInboundGroupSessionKey = function(roomId, senderKey, sessionId) { + function getKey(session, sessionData) { + const messageIndex = session.first_known_index(); + + const claimedKeys = sessionData.keysClaimed || {}; + const senderEd25519Key = claimedKeys.ed25519 || null; + + return { + "chain_index": messageIndex, + "key": session.export_session(messageIndex), + "forwarding_curve25519_key_chain": + sessionData.forwardingCurve25519KeyChain || [], + "sender_claimed_ed25519_key": senderEd25519Key, + }; + } + + return this._getInboundGroupSession( + roomId, senderKey, sessionId, getKey, + ); +}; + /** * Export an inbound group session * @@ -826,6 +921,8 @@ OlmDevice.prototype.exportInboundGroupSession = function(senderKey, sessionId) { "room_id": r.room_id, "session_id": sessionId, "session_key": session.export_session(messageIndex), + "forwarding_curve25519_key_chain": + session.forwardingCurve25519KeyChain || [], }; } finally { session.free(); diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.js new file mode 100644 index 0000000000000000000000000000000000000000..d9ac431a0639bf1fdc226813f4f305c40a86de80 --- /dev/null +++ b/src/crypto/OutgoingRoomKeyRequestManager.js @@ -0,0 +1,363 @@ +/* +Copyright 2017 Vector Creations 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 q from 'q'; + +import utils from '../utils'; + +/** + * Internal module. Management of outgoing room key requests. + * + * See https://docs.google.com/document/d/1m4gQkcnJkxNuBmb5NoFCIadIY-DyqqNAS3lloE73BlQ + * for draft documentation on what we're supposed to be implementing here. + * + * @module + */ + +// delay between deciding we want some keys, and sending out the request, to +// allow for (a) it turning up anyway, (b) grouping requests together +const SEND_KEY_REQUESTS_DELAY_MS = 500; + +/** possible states for a room key request + * + * The state machine looks like: + * + * | + * V (cancellation requested) + * UNSENT -----------------------------+ + * | | + * | (send successful) | + * V | + * SENT | + * | | + * | (cancellation requested) | + * V | + * CANCELLATION_PENDING | + * | | + * | (cancellation sent) | + * V | + * (deleted) <---------------------------+ + * + * @enum {number} + */ +const ROOM_KEY_REQUEST_STATES = { + /** request not yet sent */ + UNSENT: 0, + + /** request sent, awaiting reply */ + SENT: 1, + + /** reply received, cancellation not yet sent */ + CANCELLATION_PENDING: 2, +}; + +export default class OutgoingRoomKeyRequestManager { + constructor(baseApis, deviceId, cryptoStore) { + this._baseApis = baseApis; + this._deviceId = deviceId; + this._cryptoStore = cryptoStore; + + // handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + this._sendOutgoingRoomKeyRequestsTimer = null; + + // sanity check to ensure that we don't end up with two concurrent runs + // of _sendOutgoingRoomKeyRequests + this._sendOutgoingRoomKeyRequestsRunning = false; + + this._clientRunning = false; + } + + /** + * Called when the client is started. Sets background processes running. + */ + start() { + this._clientRunning = true; + + // set the timer going, to handle any requests which didn't get sent + // on the previous run of the client. + this._startTimer(); + } + + /** + * Called when the client is stopped. Stops any running background processes. + */ + stop() { + console.log('stopping OutgoingRoomKeyRequestManager'); + // stop the timer on the next run + this._clientRunning = false; + } + + /** + * Send off a room key request, if we haven't already done so. + * + * The `requestBody` is compared (with a deep-equality check) against + * previous queued or sent requests and if it matches, no change is made. + * Otherwise, a request is added to the pending list, and a job is started + * in the background to send it. + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * @param {Array<{userId: string, deviceId: string}>} recipients + * + * @returns {Promise} resolves when the request has been added to the + * pending list (or we have established that a similar request already + * exists) + */ + sendRoomKeyRequest(requestBody, recipients) { + return this._cryptoStore.getOrAddOutgoingRoomKeyRequest({ + requestBody: requestBody, + recipients: recipients, + requestId: this._baseApis.makeTxnId(), + state: ROOM_KEY_REQUEST_STATES.UNSENT, + }).then((req) => { + if (req.state === ROOM_KEY_REQUEST_STATES.UNSENT) { + this._startTimer(); + } + }); + } + + /** + * Cancel room key requests, if any match the given details + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * + * @returns {Promise} resolves when the request has been updated in our + * pending list. + */ + cancelRoomKeyRequest(requestBody) { + return this._cryptoStore.getOutgoingRoomKeyRequest( + requestBody, + ).then((req) => { + if (!req) { + // no request was made for this key + return; + } + switch (req.state) { + case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: + // nothing to do here + return; + + case ROOM_KEY_REQUEST_STATES.UNSENT: + // just delete it + + // FIXME: ghahah we may have attempted to send it, and + // not yet got a successful response. So the server + // may have seen it, so we still need to send a cancellation + // in that case :/ + + console.log( + 'deleting unnecessary room key request for ' + + stringifyRequestBody(requestBody), + ); + return this._cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + ); + + case ROOM_KEY_REQUEST_STATES.SENT: + // send a cancellation. + return this._cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { + state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + cancellationTxnId: this._baseApis.makeTxnId(), + }, + ).then((updatedReq) => { + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the + // request in state ROOM_KEY_REQUEST_STATES.SENT, + // so we must have raced with another tab to mark + // the request cancelled. There is no point in + // sending another cancellation since the other tab + // will do it. + console.log( + 'Tried to cancel room key request for ' + + stringifyRequestBody(requestBody) + + ' but it was already cancelled in another tab', + ); + return; + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + this._sendOutgoingRoomKeyRequestCancellation( + updatedReq, + ).catch((e) => { + console.error( + "Error sending room key request cancellation;" + + " will retry later.", e, + ); + this._startTimer(); + }).done(); + }); + + default: + throw new Error('unhandled state: ' + req.state); + } + }); + } + + // start the background timer to send queued requests, if the timer isn't + // already running + _startTimer() { + if (this._sendOutgoingRoomKeyRequestsTimer) { + return; + } + + const startSendingOutgoingRoomKeyRequests = () => { + if (this._sendOutgoingRoomKeyRequestsRunning) { + throw new Error("RoomKeyRequestSend already in progress!"); + } + this._sendOutgoingRoomKeyRequestsRunning = true; + + this._sendOutgoingRoomKeyRequests().finally(() => { + this._sendOutgoingRoomKeyRequestsRunning = false; + }).catch((e) => { + // this should only happen if there is an indexeddb error, + // in which case we're a bit stuffed anyway. + console.warn( + `error in OutgoingRoomKeyRequestManager: ${e}`, + ); + }).done(); + }; + + this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout( + startSendingOutgoingRoomKeyRequests, + SEND_KEY_REQUESTS_DELAY_MS, + ); + } + + // look for and send any queued requests. Runs itself recursively until + // there are no more requests, or there is an error (in which case, the + // timer will be restarted before the promise resolves). + _sendOutgoingRoomKeyRequests() { + if (!this._clientRunning) { + this._sendOutgoingRoomKeyRequestsTimer = null; + return q(); + } + + console.log("Looking for queued outgoing room key requests"); + + return this._cryptoStore.getOutgoingRoomKeyRequestByState([ + ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + ROOM_KEY_REQUEST_STATES.UNSENT, + ]).then((req) => { + if (!req) { + console.log("No more outgoing room key requests"); + this._sendOutgoingRoomKeyRequestsTimer = null; + return; + } + + let prom; + if (req.state === ROOM_KEY_REQUEST_STATES.UNSENT) { + prom = this._sendOutgoingRoomKeyRequest(req); + } else { // must be a cancellation + prom = this._sendOutgoingRoomKeyRequestCancellation(req); + } + + return prom.then(() => { + // go around the loop again + return this._sendOutgoingRoomKeyRequests(); + }).catch((e) => { + console.error("Error sending room key request; will retry later.", e); + this._sendOutgoingRoomKeyRequestsTimer = null; + this._startTimer(); + }).done(); + }); + } + + // given a RoomKeyRequest, send it and update the request record + _sendOutgoingRoomKeyRequest(req) { + console.log( + `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + + ` from ${stringifyRecipientList(req.recipients)}` + + `(id ${req.requestId})`, + ); + + const requestMessage = { + action: "request", + requesting_device_id: this._deviceId, + request_id: req.requestId, + body: req.requestBody, + }; + + return this._sendMessageToDevices( + requestMessage, req.recipients, req.requestId, + ).then(() => { + return this._cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + { state: ROOM_KEY_REQUEST_STATES.SENT }, + ); + }); + } + + // given a RoomKeyRequest, cancel it and delete the request record + _sendOutgoingRoomKeyRequestCancellation(req) { + console.log( + `Sending cancellation for key request for ` + + `${stringifyRequestBody(req.requestBody)} to ` + + `${stringifyRecipientList(req.recipients)} ` + + `(cancellation id ${req.cancellationTxnId})`, + ); + + const requestMessage = { + action: "request_cancellation", + requesting_device_id: this._deviceId, + request_id: req.requestId, + }; + + return this._sendMessageToDevices( + requestMessage, req.recipients, req.cancellationTxnId, + ).then(() => { + return this._cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + ); + }); + } + + // send a RoomKeyRequest to a list of recipients + _sendMessageToDevices(message, recipients, txnId) { + const contentMap = {}; + for (const recip of recipients) { + if (!contentMap[recip.userId]) { + contentMap[recip.userId] = {}; + } + contentMap[recip.userId][recip.deviceId] = message; + } + + return this._baseApis.sendToDevice( + 'm.room_key_request', contentMap, txnId, + ); + } +} + +function stringifyRequestBody(requestBody) { + // we assume that the request is for megolm keys, which are identified by + // room id and session id + return requestBody.room_id + " / " + requestBody.session_id; +} + +function stringifyRecipientList(recipients) { + return '[' + + utils.map(recipients, (r) => `${r.userId}:${r.deviceId}`).join(",") + + ']'; +} + diff --git a/src/crypto/algorithms/base.js b/src/crypto/algorithms/base.js index 0f662f931a971a1e48742080bd0d1cb5c94ef57c..41332c7c541622c6d68d20824277ead0330a1579 100644 --- a/src/crypto/algorithms/base.js +++ b/src/crypto/algorithms/base.js @@ -90,11 +90,11 @@ export {EncryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272 * base type for decryption implementations * * @alias module:crypto/algorithms/base.DecryptionAlgorithm - * * @param {object} params parameters * @param {string} params.userId The UserID for the local user * @param {module:crypto} params.crypto crypto core * @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper + * @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface * @param {string=} params.roomId The ID of the room we will be receiving * from. Null for to-device events. */ @@ -103,6 +103,7 @@ class DecryptionAlgorithm { this._userId = params.userId; this._crypto = params.crypto; this._olmDevice = params.olmDevice; + this._baseApis = params.baseApis; this._roomId = params.roomId; } @@ -140,6 +141,26 @@ class DecryptionAlgorithm { importRoomKey(session) { // ignore by default } + + /** + * Determine if we have the keys necessary to respond to a room key request + * + * @param {module:crypto~IncomingRoomKeyRequest} keyRequest + * @return {boolean} true if we have the keys and could (theoretically) share + * them; else false. + */ + hasKeysForKeyRequest(keyRequest) { + return false; + } + + /** + * Send the response to a room key request + * + * @param {module:crypto~IncomingRoomKeyRequest} keyRequest + */ + shareKeysWithDevice(keyRequest) { + throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); + } } export {DecryptionAlgorithm}; // https://github.com/jsdoc3/jsdoc/issues/1272 diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js index f7d7b770e1b919d9458beab81327b8a31640c729..61c44e5e08e8fad117a05e75f67a0d9cf0bf0074 100644 --- a/src/crypto/algorithms/megolm.js +++ b/src/crypto/algorithms/megolm.js @@ -183,6 +183,7 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { } if (!session) { + console.log(`Starting new megolm session for room ${self._roomId}`); session = self._prepareNewSession(); } @@ -245,15 +246,15 @@ MegolmEncryption.prototype._ensureOutboundSession = function(devicesInRoom) { * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session */ MegolmEncryption.prototype._prepareNewSession = function() { - const session_id = this._olmDevice.createOutboundGroupSession(); - const key = this._olmDevice.getOutboundGroupSessionKey(session_id); + const sessionId = this._olmDevice.createOutboundGroupSession(); + const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); this._olmDevice.addInboundGroupSession( - this._roomId, this._olmDevice.deviceCurve25519Key, session_id, + this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, key.key, {ed25519: this._olmDevice.deviceEd25519Key}, ); - return new OutboundSessionInfo(session_id); + return new OutboundSessionInfo(sessionId); }; /** @@ -353,6 +354,8 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session, devicesByUse // TODO: retries return self._baseApis.sendToDevice("m.room.encrypted", contentMap); }).then(function() { + console.log(`Completed megolm keyshare in ${self._roomId}`); + // Add the devices we have shared with to session.sharedWithDevices. // // we deliberately iterate over devicesByUser (ie, the devices we @@ -387,6 +390,8 @@ MegolmEncryption.prototype._shareKeyWithDevices = function(session, devicesByUse */ MegolmEncryption.prototype.encryptMessage = function(room, eventType, content) { const self = this; + console.log(`Starting to encrypt event for ${this._roomId}`); + return this._getDevicesInRoom(room).then(function(devicesInRoom) { // check if any of these devices are not yet known to the user. // if so, warn the user so they can verify or ignore. @@ -515,6 +520,9 @@ function MegolmDecryption(params) { // events which we couldn't decrypt due to unknown sessions / indexes: map from // senderKey|sessionId to list of MatrixEvents this._pendingEvents = {}; + + // this gets stubbed out by the unit tests. + this.olmlib = olmlib; } utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); @@ -527,6 +535,14 @@ utils.inherits(MegolmDecryption, base.DecryptionAlgorithm); * problem decrypting the event */ MegolmDecryption.prototype.decryptEvent = function(event) { + this._decryptEvent(event, true); +}; + + +// helper for the real decryptEvent and for _retryDecryption. If +// requestKeysOnFail is true, we'll send an m.room_key_request when we fail +// to decrypt the event due to missing megolm keys. +MegolmDecryption.prototype._decryptEvent = function(event, requestKeysOnFail) { const content = event.getWireContent(); if (!content.sender_key || !content.session_id || @@ -543,6 +559,9 @@ MegolmDecryption.prototype.decryptEvent = function(event) { } catch (e) { if (e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { this._addEventToPendingList(event); + if (requestKeysOnFail) { + this._requestKeysForEvent(event); + } } throw new base.DecryptionError( e.toString(), { @@ -554,6 +573,9 @@ MegolmDecryption.prototype.decryptEvent = function(event) { if (res === null) { // We've got a message for a session we don't have. this._addEventToPendingList(event); + if (requestKeysOnFail) { + this._requestKeysForEvent(event); + } throw new base.DecryptionError( "The sender's device has not sent us the keys for this message.", { @@ -573,9 +595,32 @@ MegolmDecryption.prototype.decryptEvent = function(event) { ); } - event.setClearData(payload, res.keysProved, res.keysClaimed); + event.setClearData(payload, res.senderKey, res.keysClaimed.ed25519, + res.forwardingCurve25519KeyChain); }; +MegolmDecryption.prototype._requestKeysForEvent = function(event) { + const sender = event.getSender(); + const wireContent = event.getWireContent(); + + // send the request to all of our own devices, and the + // original sending device if it wasn't us. + const recipients = [{ + userId: this._userId, deviceId: '*', + }]; + if (sender != this._userId) { + recipients.push({ + userId: sender, deviceId: wireContent.device_id, + }); + } + + this._crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, recipients); +}; /** * Add an event to the list of those we couldn't decrypt the first time we @@ -601,8 +646,11 @@ MegolmDecryption.prototype._addEventToPendingList = function(event) { */ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { const content = event.getContent(); - const senderKey = event.getSenderKey(); const sessionId = content.session_id; + let senderKey = event.getSenderKey(); + let forwardingKeyChain = []; + let exportFormat = false; + let keysClaimed; if (!content.room_id || !sessionId || @@ -611,21 +659,159 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) { console.error("key event is missing fields"); return; } + if (!senderKey) { console.error("key event has no sender key (not encrypted?)"); return; } + if (event.getType() == "m.forwarded_room_key") { + exportFormat = true; + forwardingKeyChain = content.forwarding_curve25519_key_chain; + if (!utils.isArray(forwardingKeyChain)) { + forwardingKeyChain = []; + } + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + senderKey = content.sender_key; + if (!senderKey) { + console.error("forwarded_room_key event is missing sender_key field"); + return; + } + + const ed25519Key = content.sender_claimed_ed25519_key; + if (!ed25519Key) { + console.error( + `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, + ); + return; + } + + keysClaimed = { + ed25519: ed25519Key, + }; + } else { + keysClaimed = event.getKeysClaimed(); + } + console.log(`Adding key for megolm session ${senderKey}|${sessionId}`); this._olmDevice.addInboundGroupSession( - content.room_id, senderKey, sessionId, - content.session_key, event.getKeysClaimed(), + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, ); + // cancel any outstanding room key requests for this session + this._crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, + }); + // have another go at decrypting events sent with this session. this._retryDecryption(senderKey, sessionId); }; +/** + * @inheritdoc + */ +MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { + const body = keyRequest.requestBody; + + return this._olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); +}; + +/** + * @inheritdoc + */ +MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + this.olmlib.ensureOlmSessionsForDevices( + this._olmDevice, this._baseApis, { + [userId]: [deviceInfo], + }, + ).then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return; + } + + console.log( + "sharing keys for session " + body.sender_key + "|" + + body.session_id + " with device " + + userId + ":" + deviceId, + ); + + const payload = this._buildKeyForwardingMessage( + body.room_id, body.sender_key, body.session_id, + ); + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this._olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + this.olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this._userId, + this._deviceId, + this._olmDevice, + userId, + deviceInfo, + payload, + ); + + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this._baseApis.sendToDevice("m.room.encrypted", contentMap); + }).done(); +}; + +MegolmDecryption.prototype._buildKeyForwardingMessage = function( + roomId, senderKey, sessionId, +) { + const key = this._olmDevice.getInboundGroupSessionKey( + roomId, senderKey, sessionId, + ); + + return { + type: "m.forwarded_room_key", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + room_id: roomId, + sender_key: senderKey, + sender_claimed_ed25519_key: key.sender_claimed_ed25519_key, + session_id: sessionId, + session_key: key.key, + chain_index: key.chain_index, + forwarding_curve25519_key_chain: key.forwarding_curve25519_key_chain, + }, + }; +}; /** * @inheritdoc @@ -657,7 +843,8 @@ MegolmDecryption.prototype._retryDecryption = function(senderKey, sessionId) { for (let i = 0; i < pending.length; i++) { try { - this.decryptEvent(pending[i]); + // no point sending another m.room_key_request here. + this._decryptEvent(pending[i], false); console.log("successful re-decryption of", pending[i]); } catch (e) { console.log("Still can't decrypt", pending[i], e.stack || e); diff --git a/src/crypto/algorithms/olm.js b/src/crypto/algorithms/olm.js index f8715b1e2d451ba9cb80d253aafdf8380ef7fd06..f3c7967ebd13551ef2e97ca75319e87cdba5063b 100644 --- a/src/crypto/algorithms/olm.js +++ b/src/crypto/algorithms/olm.js @@ -222,7 +222,8 @@ OlmDecryption.prototype.decryptEvent = function(event) { ); } - event.setClearData(payload, {curve25519: deviceKey}, payload.keys || {}); + const claimedKeys = payload.keys || {}; + event.setClearData(payload, deviceKey, claimedKeys.ed25519 || null); }; diff --git a/src/crypto/index.js b/src/crypto/index.js index c404721a5a9f97ec93544376df2c8b723f3e27df..2e50fec38afe665b231c6daf6f77a2bf6d5f1179 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -22,6 +22,7 @@ limitations under the License. const anotherjson = require('another-json'); const q = require("q"); +import {EventEmitter} from 'events'; const utils = require("../utils"); const OlmDevice = require("./OlmDevice"); @@ -31,6 +32,8 @@ const DeviceInfo = require("./deviceinfo"); const DeviceVerification = DeviceInfo.DeviceVerification; const DeviceList = require('./DeviceList').default; +import OutgoingRoomKeyRequestManager from './OutgoingRoomKeyRequestManager'; + /** * Cryptography bits * @@ -67,8 +70,6 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId, this._deviceList = new DeviceList(baseApis, sessionStore, this._olmDevice); this._initialDeviceListInvalidationPending = false; - this._clientRunning = false; - // the last time we did a check for the number of one-time-keys on the // server. this._lastOneTimeKeyCheck = null; @@ -93,6 +94,15 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId, this._globalBlacklistUnverifiedDevices = false; + this._outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( + baseApis, this._deviceId, this._cryptoStore, + ); + + // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations + // we received in the current sync. + this._receivedRoomKeyRequests = []; + this._receivedRoomKeyRequestCancellations = []; + let myDevices = this._sessionStore.getEndToEndDevicesForUser( this._userId, ); @@ -118,15 +128,12 @@ function Crypto(baseApis, eventEmitter, sessionStore, userId, deviceId, _registerEventHandlers(this, eventEmitter); } +utils.inherits(Crypto, EventEmitter); + function _registerEventHandlers(crypto, eventEmitter) { eventEmitter.on("sync", function(syncState, oldState, data) { try { - if (syncState === "STOPPED") { - crypto._clientRunning = false; - } else if (syncState === "PREPARED") { - crypto._clientRunning = true; - } if (syncState === "SYNCING") { crypto._onSyncCompleted(data); } @@ -145,10 +152,13 @@ function _registerEventHandlers(crypto, eventEmitter) { eventEmitter.on("toDeviceEvent", function(event) { try { - if (event.getType() == "m.room_key") { + if (event.getType() == "m.room_key" + || event.getType() == "m.forwarded_room_key") { crypto._onRoomKeyEvent(event); } else if (event.getType() == "m.new_device") { crypto._onNewDeviceEvent(event); + } else if (event.getType() == "m.room_key_request") { + crypto._onRoomKeyRequestEvent(event); } } catch (e) { console.error("Error handling toDeviceEvent:", e); @@ -167,6 +177,16 @@ function _registerEventHandlers(crypto, eventEmitter) { }); } +/** Start background processes related to crypto */ +Crypto.prototype.start = function() { + this._outgoingRoomKeyRequestManager.start(); +}; + +/** Stop background processes related to crypto */ +Crypto.prototype.stop = function() { + this._outgoingRoomKeyRequestManager.stop(); +}; + /** * @return {string} The version of Olm. */ @@ -519,6 +539,13 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) { return null; } + const forwardingChain = event.getForwardingCurve25519KeyChain(); + if (forwardingChain.length > 0) { + // we got this event from somewhere else + // TODO: check if we can trust the forwarders. + return null; + } + // senderKey is the Curve25519 identity key of the device which the event // was sent from. In the case of Megolm, it's actually the Curve25519 // identity key of the device which set up the Megolm session. @@ -540,7 +567,7 @@ Crypto.prototype.getEventSenderDeviceInfo = function(event) { // // (see https://github.com/vector-im/vector-web/issues/2215) - const claimedKey = event.getKeysClaimed().ed25519; + const claimedKey = event.getClaimedEd25519Key(); if (!claimedKey) { console.warn("Event " + event.getId() + " claims no ed25519 key: " + "cannot verify sending device"); @@ -747,18 +774,15 @@ Crypto.prototype.encryptEventIfNeeded = function(event, room) { return null; } - // We can claim and prove ownership of all our device keys in the local - // echo of the event since we know that all the local echos come from - // this device. - const myKeys = { - curve25519: this._olmDevice.deviceCurve25519Key, - ed25519: this._olmDevice.deviceEd25519Key, - }; - return alg.encryptMessage( room, event.getType(), event.getContent(), - ).then(function(encryptedContent) { - event.makeEncrypted("m.room.encrypted", encryptedContent, myKeys); + ).then((encryptedContent) => { + event.makeEncrypted( + "m.room.encrypted", + encryptedContent, + this._olmDevice.deviceCurve25519Key, + this._olmDevice.deviceEd25519Key, + ); }); }; @@ -787,6 +811,36 @@ Crypto.prototype.userDeviceListChanged = function(userId) { // processing the sync. }; +/** + * Send a request for some room keys, if we have not already done so + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * @param {Array<{userId: string, deviceId: string}>} recipients + */ +Crypto.prototype.requestRoomKey = function(requestBody, recipients) { + this._outgoingRoomKeyRequestManager.sendRoomKeyRequest( + requestBody, recipients, + ).catch((e) => { + // this normally means we couldn't talk to the store + console.error( + 'Error requesting key for event', e, + ); + }).done(); +}; + +/** + * Cancel any earlier room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * parameters to match for cancellation + */ +Crypto.prototype.cancelRoomKeyRequest = function(requestBody) { + this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) + .catch((e) => { + console.warn("Error clearing pending room key requests", e); + }).done(); +}; + /** * handle an m.room.encryption event * @@ -868,6 +922,7 @@ Crypto.prototype._onSyncCompleted = function(syncData) { // (https://github.com/vector-im/riot-web/issues/2782). if (!syncData.catchingUp) { _maybeUploadOneTimeKeys(this); + this._processReceivedRoomKeyRequests(); } }; @@ -1056,6 +1111,106 @@ Crypto.prototype._onNewDeviceEvent = function(event) { this._deviceList.invalidateUserDeviceList(userId); }; +/** + * Called when we get an m.room_key_request event. + * + * @private + * @param {module:models/event.MatrixEvent} event key request event + */ +Crypto.prototype._onRoomKeyRequestEvent = function(event) { + const content = event.getContent(); + if (content.action === "request") { + // Queue it up for now, because they tend to arrive before the room state + // events at initial sync, and we want to see if we know anything about the + // room before passing them on to the app. + const req = new IncomingRoomKeyRequest(event); + this._receivedRoomKeyRequests.push(req); + } else if (content.action === "request_cancellation") { + const req = new IncomingRoomKeyRequestCancellation(event); + this._receivedRoomKeyRequestCancellations.push(req); + } +}; + +/** + * Process any m.room_key_request events which were queued up during the + * current sync. + * + * @private + */ +Crypto.prototype._processReceivedRoomKeyRequests = function() { + const requests = this._receivedRoomKeyRequests; + this._receivedRoomKeyRequests = []; + for (const req of requests) { + const userId = req.userId; + const deviceId = req.deviceId; + + const body = req.requestBody; + const roomId = body.room_id; + const alg = body.algorithm; + + console.log(`m.room_key_request from ${userId}:${deviceId}` + + ` 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. + console.log("Ignoring room key request from other user for now"); + return; + } + + // todo: should we queue up requests we don't yet have keys for, + // in case they turn up later? + + // if we don't have a decryptor for this room/alg, we don't have + // the keys for the requested events, and can drop the requests. + if (!this._roomDecryptors[roomId]) { + console.log(`room key request for unencrypted room ${roomId}`); + continue; + } + + const decryptor = this._roomDecryptors[roomId][alg]; + if (!decryptor) { + console.log(`room key request for unknown alg ${alg} in room ${roomId}`); + continue; + } + + if (!decryptor.hasKeysForKeyRequest(req)) { + console.log( + `room key request for unknown session ${roomId} / ` + + body.session_id, + ); + continue; + } + + req.share = () => { + decryptor.shareKeysWithDevice(req); + }; + + // if the device is is verified already, share the keys + const device = this._deviceList.getStoredDevice(userId, deviceId); + if (device && device.isVerified()) { + console.log('device is already verified: sharing keys'); + req.share(); + return; + } + + this.emit("crypto.roomKeyRequest", req); + } + + const cancellations = this._receivedRoomKeyRequestCancellations; + this._receivedRoomKeyRequestCancellations = []; + for (const cancellation of cancellations) { + console.log( + `m.room_key_request cancellation for ${cancellation.userId}:` + + `${cancellation.deviceId} (id ${cancellation.requestId})`, + ); + + // we should probably only notify the app of cancellations we told it + // about, but we don't currently have a record of that, so we just pass + // everything through. + this.emit("crypto.roomKeyRequestCancellation", cancellation); + } +}; /** * Get a decryptor for a given room and algorithm. @@ -1102,6 +1257,7 @@ Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) { userId: this._userId, crypto: this, olmDevice: this._olmDevice, + baseApis: this._baseApis, roomId: roomId, }); @@ -1126,5 +1282,69 @@ Crypto.prototype._signObject = function(obj) { }; +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + * + * @typedef {Object} RoomKeyRequestBody + */ + +/** + * Represents a received m.room_key_request event + * + * @property {string} userId user requesting the key + * @property {string} deviceId device requesting the key + * @property {string} requestId unique id for the request + * @property {module:crypto~RoomKeyRequestBody} requestBody + * @property {function()} share callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ +class IncomingRoomKeyRequest { + constructor(event) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + this.requestBody = content.body || {}; + this.share = () => { + throw new Error("don't know how to share keys for this request yet"); + }; + } +} + +/** + * Represents a received m.room_key_request cancellation + * + * @property {string} userId user requesting the cancellation + * @property {string} deviceId device requesting the cancellation + * @property {string} requestId unique id for the request to be cancelled + */ +class IncomingRoomKeyRequestCancellation { + constructor(event) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + } +} + +/** + * Fires when we receive a room key request + * + * @event module:client~MatrixClient#"crypto.roomKeyRequest" + * @param {module:crypto~IncomingRoomKeyRequest} req request details + */ + +/** + * Fires when we receive a room key request cancellation + * + * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" + * @param {module:crypto~IncomingRoomKeyRequestCancellation} req + */ + /** */ module.exports = Crypto; diff --git a/src/crypto/store/base.js b/src/crypto/store/base.js index 1d26a1ab6f38aca35b49a1c7c14b7376dc463562..d9d1f7a94b03c2fdb689e319ac13bc7781cadd3c 100644 --- a/src/crypto/store/base.js +++ b/src/crypto/store/base.js @@ -9,3 +9,26 @@ * * @interface CryptoStore */ + +/** + * Represents an outgoing room key request + * + * @typedef {Object} OutgoingRoomKeyRequest + * + * @property {string} requestId unique id for this request. Used for both + * an id within the request for later pairing with a cancellation, and for + * the transaction id when sending the to_device messages to our local + * server. + * + * @property {string?} cancellationTxnId + * transaction id for the cancellation, if any + * + * @property {Array<{userId: string, deviceId: string}>} recipients + * list of recipients for the request + * + * @property {module:crypto~RoomKeyRequestBody} requestBody + * parameters for the request. + * + * @property {Number} state current state of this request (states are defined + * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) + */ diff --git a/src/crypto/store/indexeddb-crypto-store-backend.js b/src/crypto/store/indexeddb-crypto-store-backend.js new file mode 100644 index 0000000000000000000000000000000000000000..39c286e49103c3077f406ae6090b5bcbf473547b --- /dev/null +++ b/src/crypto/store/indexeddb-crypto-store-backend.js @@ -0,0 +1,291 @@ +import q from 'q'; +import utils from '../../utils'; + +export const VERSION = 1; + +/** + * Implementation of a CryptoStore which is backed by an existing + * IndexedDB connection. Generally you want IndexedDBCryptoStore + * which connects to the database and defers to one of these. + * + * @implements {module:crypto/store/base~CryptoStore} + */ +export class Backend { + /** + * @param {IDBDatabase} db + */ + constructor(db) { + this._db = db; + + // make sure we close the db on `onversionchange` - otherwise + // attempts to delete the database will block (and subsequent + // attempts to re-create it will also block). + db.onversionchange = (ev) => { + console.log(`versionchange for indexeddb ${this._dbName}: closing`); + db.close(); + }; + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + + const deferred = q.defer(); + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + txn.onerror = deferred.reject; + + // first see if we already have an entry for this request. + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + if (existing) { + // this entry matches the request - return it. + console.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + deferred.resolve(existing); + return; + } + + // we got to the end of the list without finding a match + // - add the new request. + console.log( + `enqueueing key request for ${requestBody.room_id} / ` + + requestBody.session_id, + ); + const store = txn.objectStore("outgoingRoomKeyRequests"); + store.add(request); + txn.onsuccess = () => { deferred.resolve(request); }; + }); + + return deferred.promise; + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + const deferred = q.defer(); + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readonly"); + txn.onerror = deferred.reject; + + this._getOutgoingRoomKeyRequest(txn, requestBody, (existing) => { + deferred.resolve(existing); + }); + return deferred.promise; + } + + /** + * look for an existing room key request in the db + * + * @private + * @param {IDBTransaction} txn database transaction + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * @param {Function} callback function to call with the results of the + * search. Either passed a matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found. + */ + _getOutgoingRoomKeyRequest(txn, requestBody, callback) { + const store = txn.objectStore("outgoingRoomKeyRequests"); + + const idx = store.index("session"); + const cursorReq = idx.openCursor([ + requestBody.room_id, + requestBody.session_id, + ]); + + cursorReq.onsuccess = (ev) => { + const cursor = ev.target.result; + if(!cursor) { + // no match found + callback(null); + return; + } + + const existing = cursor.value; + + if (utils.deepCompare(existing.requestBody, requestBody)) { + // got a match + callback(existing); + return; + } + + // look at the next entry in the index + cursor.continue(); + }; + } + + /** + * Look for room key requests by state + * + * @param {Array<Number>} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + if (wantedStates.length === 0) { + return q(null); + } + + // this is a bit tortuous because we need to make sure we do the lookup + // in a single transaction, to avoid having a race with the insertion + // code. + + // index into the wantedStates array + let stateIndex = 0; + let result; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (cursor) { + // got a match + result = cursor.value; + return; + } + + // 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(() => result); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + let result = null; + + function onsuccess(ev) { + const cursor = ev.target.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + console.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${data.state}`, + ); + return; + } + Object.assign(data, updates); + cursor.update(data); + result = data; + } + + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests") + .openCursor(requestId); + cursorReq.onsuccess = onsuccess; + return promiseifyTxn(txn).then(() => result); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + const txn = this._db.transaction("outgoingRoomKeyRequests", "readwrite"); + const cursorReq = txn.objectStore("outgoingRoomKeyRequests") + .openCursor(requestId); + cursorReq.onsuccess = (ev) => { + const cursor = ev.target.result; + if (!cursor) { + return; + } + const data = cursor.value; + if (data.state != expectedState) { + console.warn( + `Cannot delete room key request in state ${data.state} ` + + `(expected ${expectedState})`, + ); + return; + } + cursor.delete(); + }; + return promiseifyTxn(txn); + } +} + +export function upgradeDatabase(db, oldVersion) { + console.log( + `Upgrading IndexedDBCryptoStore from version ${oldVersion}` + + ` to ${VERSION}`, + ); + if (oldVersion < 1) { // The database did not previously exist. + createDatabase(db); + } + // Expand as needed. +} + +function createDatabase(db) { + const outgoingRoomKeyRequestsStore = + db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + + // we assume that the RoomKeyRequestBody will have room_id and session_id + // properties, to make the index efficient. + outgoingRoomKeyRequestsStore.createIndex("session", + ["requestBody.room_id", "requestBody.session_id"], + ); + + outgoingRoomKeyRequestsStore.createIndex("state", "state"); +} + +function promiseifyTxn(txn) { + return new q.Promise((resolve, reject) => { + txn.oncomplete = resolve; + txn.onerror = reject; + }); +} diff --git a/src/crypto/store/indexeddb-crypto-store.js b/src/crypto/store/indexeddb-crypto-store.js index 713f609449432a2fa01457ede48517dcb110abb6..1682c263db3d08180cbfcbd0984d30475e58f1a4 100644 --- a/src/crypto/store/indexeddb-crypto-store.js +++ b/src/crypto/store/indexeddb-crypto-store.js @@ -16,15 +16,19 @@ limitations under the License. import q from 'q'; +import MemoryCryptoStore from './memory-crypto-store'; +import * as IndexedDBCryptoStoreBackend from './indexeddb-crypto-store-backend'; + /** * Internal module. indexeddb storage for e2e. * * @module */ -const VERSION = 1; - /** + * An implementation of CryptoStore, which is normally backed by an indexeddb, + * but with fallback to MemoryCryptoStore. + * * @implements {module:crypto/store/base~CryptoStore} */ export default class IndexedDBCryptoStore { @@ -35,68 +39,66 @@ export default class IndexedDBCryptoStore { * @param {string} dbName name of db to connect to */ constructor(indexedDB, dbName) { - if (!indexedDB) { - throw new Error("must pass indexedDB into IndexedDBCryptoStore"); - } this._indexedDB = indexedDB; this._dbName = dbName; - this._dbPromise = null; + this._backendPromise = null; } /** - * Ensure the database exists and is up-to-date + * Ensure the database exists and is up-to-date, or fall back to + * an in-memory store. * - * @return {Promise} resolves to an instance of IDBDatabase when - * the database is ready + * @return {Promise} resolves to either an IndexedDBCryptoStoreBackend.Backend, + * or a MemoryCryptoStore */ - connect() { - if (this._dbPromise) { - return this._dbPromise; + _connect() { + if (this._backendPromise) { + return this._backendPromise; } - this._dbPromise = new q.Promise((resolve, reject) => { + this._backendPromise = new q.Promise((resolve, reject) => { + if (!this._indexedDB) { + reject(new Error('no indexeddb support available')); + return; + } + console.log(`connecting to indexeddb ${this._dbName}`); - const req = this._indexedDB.open(this._dbName, VERSION); + + const req = this._indexedDB.open( + this._dbName, IndexedDBCryptoStoreBackend.VERSION, + ); req.onupgradeneeded = (ev) => { const db = ev.target.result; const oldVersion = ev.oldVersion; - console.log( - `Upgrading IndexedDBCryptoStore from version ${oldVersion}` - + ` to ${VERSION}`, - ); - if (oldVersion < 1) { // The database did not previously exist. - createDatabase(db); - } - // Expand as needed. + IndexedDBCryptoStoreBackend.upgradeDatabase(db, oldVersion); }; req.onblocked = () => { - reject(new Error( - "unable to upgrade indexeddb because it is open elsewhere", - )); + console.log( + `can't yet open IndexedDBCryptoStore because it is open elsewhere`, + ); }; req.onerror = (ev) => { - reject(new Error( - "unable to connect to indexeddb: " + ev.target.error, - )); + reject(ev.target.error); }; req.onsuccess = (r) => { const db = r.target.result; - // make sure we close the db on `onversionchange` - otherwise - // attempts to delete the database will block (and subsequent - // attempts to re-create it will also block). - db.onversionchange = (ev) => { - db.close(); - }; - - resolve(db); + console.log(`connected to indexeddb ${this._dbName}`); + resolve(new IndexedDBCryptoStoreBackend.Backend(db)); }; + }).catch((e) => { + console.warn( + `unable to connect to indexeddb ${this._dbName}` + + `: falling back to in-memory store: ${e}`, + ); + return new MemoryCryptoStore(); }); - return this._dbPromise; + + return this._backendPromise; } /** @@ -106,38 +108,116 @@ export default class IndexedDBCryptoStore { */ deleteAllData() { return new q.Promise((resolve, reject) => { + if (!this._indexedDB) { + reject(new Error('no indexeddb support available')); + return; + } + console.log(`Removing indexeddb instance: ${this._dbName}`); const req = this._indexedDB.deleteDatabase(this._dbName); req.onblocked = () => { - reject(new Error( - "unable to delete indexeddb because it is open elsewhere", - )); + console.log( + `can't yet delete IndexedDBCryptoStore because it is open elsewhere`, + ); }; req.onerror = (ev) => { - reject(new Error( - "unable to delete indexeddb: " + ev.target.error, - )); + reject(ev.target.error); }; req.onsuccess = () => { console.log(`Removed indexeddb instance: ${this._dbName}`); resolve(); }; + }).catch((e) => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that people can + // still use the app. + console.warn(`unable to delete IndexedDBCryptoStore: ${e}`); + }); + } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + return this._connect().then((backend) => { + return backend.getOrAddOutgoingRoomKeyRequest(request); + }); + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequest(requestBody); }); } -} -function createDatabase(db) { - const outgoingRoomKeyRequestsStore = - db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" }); + /** + * Look for room key requests by state + * + * @param {Array<Number>} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states. If there are multiple + * requests in those states, an arbitrary one is chosen. + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + return this._connect().then((backend) => { + return backend.getOutgoingRoomKeyRequestByState(wantedStates); + }); + } - // we assume that the RoomKeyRequestBody will have room_id and session_id - // properties, to make the index efficient. - outgoingRoomKeyRequestsStore.createIndex("session", - ["requestBody.room_id", "requestBody.session_id"], - ); + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + return this._connect().then((backend) => { + return backend.updateOutgoingRoomKeyRequest( + requestId, expectedState, updates, + ); + }); + } - outgoingRoomKeyRequestsStore.createIndex("state", "state"); + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + return this._connect().then((backend) => { + return backend.deleteOutgoingRoomKeyRequest(requestId, expectedState); + }); + } } diff --git a/src/crypto/store/memory-crypto-store.js b/src/crypto/store/memory-crypto-store.js index 23bc2fbcae4be76883dd90dc077099d348d972da..3f8e32d7d8e30a3c95aef0233a41dce1cdfe88e1 100644 --- a/src/crypto/store/memory-crypto-store.js +++ b/src/crypto/store/memory-crypto-store.js @@ -16,6 +16,8 @@ limitations under the License. import q from 'q'; +import utils from '../../utils'; + /** * Internal module. in-memory storage for e2e. * @@ -27,6 +29,7 @@ import q from 'q'; */ export default class MemoryCryptoStore { constructor() { + this._outgoingRoomKeyRequests = []; } /** @@ -37,4 +40,143 @@ export default class MemoryCryptoStore { deleteAllData() { return q(); } + + /** + * Look for an existing outgoing room key request, and if none is found, + * add a new one + * + * @param {module:crypto/store/base~OutgoingRoomKeyRequest} request + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}: either the + * same instance as passed in, or the existing one. + */ + getOrAddOutgoingRoomKeyRequest(request) { + const requestBody = request.requestBody; + + // first see if we already have an entry for this request. + return this.getOutgoingRoomKeyRequest(requestBody).then((existing) => { + if (existing) { + // this entry matches the request - return it. + console.log( + `already have key request outstanding for ` + + `${requestBody.room_id} / ${requestBody.session_id}: ` + + `not sending another`, + ); + return existing; + } + + // we got to the end of the list without finding a match + // - add the new request. + console.log( + `enqueueing key request for ${requestBody.room_id} / ` + + requestBody.session_id, + ); + this._outgoingRoomKeyRequests.push(request); + return request; + }); + } + + /** + * Look for an existing room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * existing request to look for + * + * @return {Promise} resolves to the matching + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * not found + */ + getOutgoingRoomKeyRequest(requestBody) { + for (const existing of this._outgoingRoomKeyRequests) { + if (utils.deepCompare(existing.requestBody, requestBody)) { + return q(existing); + } + } + return q(null); + } + + /** + * Look for room key requests by state + * + * @param {Array<Number>} wantedStates list of acceptable states + * + * @return {Promise} resolves to the a + * {@link module:crypto/store/base~OutgoingRoomKeyRequest}, or null if + * there are no pending requests in those states + */ + getOutgoingRoomKeyRequestByState(wantedStates) { + for (const req of this._outgoingRoomKeyRequests) { + for (const state of wantedStates) { + if (req.state === state) { + return q(req); + } + } + } + return q(null); + } + + /** + * Look for an existing room key request by id and state, and update it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * @param {Object} updates name/value map of updates to apply + * + * @returns {Promise} resolves to + * {@link module:crypto/store/base~OutgoingRoomKeyRequest} + * updated request, or null if no matching row was found + */ + updateOutgoingRoomKeyRequest(requestId, expectedState, updates) { + for (const req of this._outgoingRoomKeyRequests) { + if (req.requestId !== requestId) { + continue; + } + + if (req.state != expectedState) { + console.warn( + `Cannot update room key request from ${expectedState} ` + + `as it was already updated to ${req.state}`, + ); + return q(null); + } + Object.assign(req, updates); + return q(req); + } + + return q(null); + } + + /** + * Look for an existing room key request by id and state, and delete it if + * found + * + * @param {string} requestId ID of request to update + * @param {number} expectedState state we expect to find the request in + * + * @returns {Promise} resolves once the operation is completed + */ + deleteOutgoingRoomKeyRequest(requestId, expectedState) { + for (let i = 0; i < this._outgoingRoomKeyRequests.length; i++) { + const req = this._outgoingRoomKeyRequests[i]; + + if (req.requestId !== requestId) { + continue; + } + + if (req.state != expectedState) { + console.warn( + `Cannot delete room key request in state ${req.state} ` + + `(expected ${expectedState})`, + ); + return q(null); + } + + this._outgoingRoomKeyRequests.splice(i, 1); + return q(req); + } + + return q(null); + } } diff --git a/src/models/event.js b/src/models/event.js index a243677cea580dcfdf6bd8393ffe4358f7c90740..748009d983759b45d6736c3aa1089122aacf05cc 100644 --- a/src/models/event.js +++ b/src/models/event.js @@ -112,8 +112,22 @@ module.exports.MatrixEvent = function MatrixEvent( new Date(this.event.origin_server_ts) : null; this._clearEvent = {}; - this._keysProved = {}; - this._keysClaimed = {}; + + /* curve25519 key which we believe belongs to the sender of the event. See + * getSenderKey() + */ + this._senderCurve25519Key = null; + + /* ed25519 key which the sender of this event (for olm) or the creator of + * the megolm session (for megolm) claims to own. See getClaimedEd25519Key() + */ + this._claimedEd25519Key = null; + + /* curve25519 keys of devices involved in telling us about the + * _senderCurve25519Key and _claimedEd25519Key. + * See getForwardingCurve25519KeyChain(). + */ + this._forwardingCurve25519KeyChain = []; }; utils.inherits(module.exports.MatrixEvent, EventEmitter); @@ -261,9 +275,18 @@ utils.extend(module.exports.MatrixEvent.prototype, { * <tt>"m.room.encrypted"</tt> * * @param {object} crypto_content raw 'content' for the encrypted event. - * @param {object} keys The local keys claimed and proved by this event. + * + * @param {string} senderCurve25519Key curve25519 key to record for the + * sender of this event. + * See {@link module:models/event.MatrixEvent#getSenderKey}. + * + * @param {string} claimedEd25519Key claimed ed25519 key to record for the + * sender if this event. + * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} */ - makeEncrypted: function(crypto_type, crypto_content, keys) { + makeEncrypted: function( + crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key, + ) { // keep the plain-text data for 'view source' this._clearEvent = { type: this.event.type, @@ -271,8 +294,8 @@ utils.extend(module.exports.MatrixEvent.prototype, { }; this.event.type = crypto_type; this.event.content = crypto_content; - this._keysProved = keys; - this._keysClaimed = keys; + this._senderCurve25519Key = senderCurve25519Key; + this._claimedEd25519Key = claimedEd25519Key; }, /** @@ -287,16 +310,26 @@ utils.extend(module.exports.MatrixEvent.prototype, { * @param {Object} clearEvent The plaintext payload for the event * (typically containing <tt>type</tt> and <tt>content</tt> fields). * - * @param {Object=} keysProved Keys owned by the sender of this event. - * See {@link module:models/event.MatrixEvent#getKeysProved}. + * @param {string=} senderCurve25519Key Key owned by the sender of this event. + * See {@link module:models/event.MatrixEvent#getSenderKey}. * - * @param {Object=} keysClaimed Keys the sender of this event claims. - * See {@link module:models/event.MatrixEvent#getKeysClaimed}. + * @param {string=} claimedEd25519Key ed25519 key claimed by the sender of + * this event. See {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. + * + * @param {Array<string>=} forwardingCurve25519KeyChain list of curve25519 keys + * involved in telling us about the senderCurve25519Key and claimedEd25519Key. + * See {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. */ - setClearData: function(clearEvent, keysProved, keysClaimed) { + setClearData: function( + clearEvent, + senderCurve25519Key, + claimedEd25519Key, + forwardingCurve25519KeyChain, + ) { this._clearEvent = clearEvent; - this._keysProved = keysProved || {}; - this._keysClaimed = keysClaimed || {}; + this._senderCurve25519Key = senderCurve25519Key || null; + this._claimedEd25519Key = claimedEd25519Key || null; + this._forwardingCurve25519KeyChain = forwardingCurve25519KeyChain || []; this.emit("Event.decrypted", this); }, @@ -309,37 +342,72 @@ utils.extend(module.exports.MatrixEvent.prototype, { }, /** - * The curve25519 key that sent this event + * The curve25519 key for the device that we think sent this event + * + * For an Olm-encrypted event, this is inferred directly from the DH + * exchange at the start of the session: the curve25519 key is involved in + * the DH exchange, so only a device which holds the private part of that + * key can establish such a session. + * + * For a megolm-encrypted event, it is inferred from the Olm message which + * established the megolm session + * * @return {string} */ getSenderKey: function() { - return this.getKeysProved().curve25519 || null; + return this._senderCurve25519Key; }, /** - * The keys that must have been owned by the sender of this encrypted event. - * <p> - * These don't necessarily have to come from this event itself, but may be - * implied by the cryptographic session. + * The additional keys the sender of this encrypted event claims to possess. + * + * Just a wrapper for #getClaimedEd25519Key (q.v.) * * @return {Object<string, string>} */ - getKeysProved: function() { - return this._keysProved; + getKeysClaimed: function() { + return { + ed25519: this._claimedEd25519Key, + }; + }, + + /** + * Get the ed25519 the sender of this event claims to own. + * + * For Olm messages, this claim is encoded directly in the plaintext of the + * event itself. For megolm messages, it is implied by the m.room_key event + * which established the megolm session. + * + * Until we download the device list of the sender, it's just a claim: the + * device list gives a proof that the owner of the curve25519 key used for + * this event (and returned by #getSenderKey) also owns the ed25519 key by + * signing the public curve25519 key with the ed25519 key. + * + * In general, applications should not use this method directly, but should + * instead use MatrixClient.getEventSenderDeviceInfo. + * + * @return {string} + */ + getClaimedEd25519Key: function() { + return this._claimedEd25519Key; }, /** - * The additional keys the sender of this encrypted event claims to possess. - * <p> - * These don't necessarily have to come from this event itself, but may be - * implied by the cryptographic session. - * For example megolm messages don't claim keys directly, but instead - * inherit a claim from the olm message that established the session. + * Get the curve25519 keys of the devices which were involved in telling us + * about the claimedEd25519Key and sender curve25519 key. * - * @return {Object<string, string>} + * Normally this will be empty, but in the case of a forwarded megolm + * session, the sender keys are sent to us by another device (the forwarding + * device), which we need to trust to do this. In that case, the result will + * be a list consisting of one entry. + * + * If the device that sent us the key (A) got it from another device which + * it wasn't prepared to vouch for (B), the result will be [A, B]. And so on. + * + * @return {string[]} base64-encoded curve25519 keys, from oldest to newest. */ - getKeysClaimed: function() { - return this._keysClaimed; + getForwardingCurve25519KeyChain: function() { + return this._forwardingCurve25519KeyChain; }, getUnsigned: function() { diff --git a/src/models/room.js b/src/models/room.js index 1d0d7d97fecde4d112332f04f6d09ed5592ebe0b..a1f88cd7c246cb1448ff1f3e349e644692731124 100644 --- a/src/models/room.js +++ b/src/models/room.js @@ -752,6 +752,8 @@ ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" */ Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { + console.log(`setting pendingEvent status to ${newStatus} in ${event.getRoomId()}`); + // if the message was sent, we expect an event id if (newStatus == EventStatus.SENT && !newEventId) { throw new Error("updatePendingEvent called with status=SENT, " + diff --git a/src/store/indexeddb-local-backend.js b/src/store/indexeddb-local-backend.js index 7f87c57390dae14fc1d3d90713da3497b07db22d..5ee7cabb2d5e68c8abab4f9ed7f38bbabeaca2aa 100644 --- a/src/store/indexeddb-local-backend.js +++ b/src/store/indexeddb-local-backend.js @@ -163,8 +163,32 @@ LocalIndexedDBStoreBackend.prototype = { * @return {Promise} Resolved when the database is cleared. */ clearDatabase: function() { - console.log("Removing indexeddb instance: ", this._dbName); - return promiseifyRequest(this.indexedDB.deleteDatabase(this._dbName)); + return new q.Promise((resolve, reject) => { + console.log(`Removing indexeddb instance: ${this._dbName}`); + const req = this.indexedDB.deleteDatabase(this._dbName); + + req.onblocked = () => { + console.log( + `can't yet delete indexeddb ${this._dbName}` + + ` because it is open elsewhere`, + ); + }; + + req.onerror = (ev) => { + // in firefox, with indexedDB disabled, this fails with a + // DOMError. We treat this as non-fatal, so that we can still + // use the app. + console.warn( + `unable to delete js-sdk store indexeddb: ${ev.target.error}`, + ); + resolve(); + }; + + req.onsuccess = () => { + console.log(`Removed indexeddb instance: ${this._dbName}`); + resolve(); + }; + }); }, /** diff --git a/src/store/indexeddb-remote-backend.js b/src/store/indexeddb-remote-backend.js index 78a48e78d243405db1fb219360a75aa9ddc459e5..b6adee40e3fd0a5328b223960170ffcebe7c3993 100644 --- a/src/store/indexeddb-remote-backend.js +++ b/src/store/indexeddb-remote-backend.js @@ -40,6 +40,11 @@ const RemoteIndexedDBStoreBackend = function RemoteIndexedDBStoreBackend( }; this._worker.onmessage = this._onWorkerMessage.bind(this); + + // tell the worker the db name. + this._startPromise = this._doCmd('_setupWorker', [this._dbName]).then(() => { + console.log("IndexedDB worker is ready"); + }); }; @@ -50,10 +55,7 @@ RemoteIndexedDBStoreBackend.prototype = { * @return {Promise} Resolves if successfully connected. */ connect: function() { - return this._doCmd('_setupWorker', [this._dbName]).then(() => { - console.log("IndexedDB worker is ready"); - return this._doCmd('connect'); - }); + return this._startPromise.then(() => this._doCmd('connect')); }, /** @@ -62,7 +64,7 @@ RemoteIndexedDBStoreBackend.prototype = { * @return {Promise} Resolved when the database is cleared. */ clearDatabase: function() { - return this._doCmd('clearDatabase'); + return this._startPromise.then(() => this._doCmd('clearDatabase')); }, /** diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js index e2172dcb7d7e7dd418540d324511f812a312c9ce..d1387eb50821b1fc7278ad754aaba779ad7dda01 100644 --- a/src/store/indexeddb.js +++ b/src/store/indexeddb.js @@ -150,7 +150,7 @@ IndexedDBStore.prototype.deleteAllData = function() { return this.backend.clearDatabase().then(() => { console.log("Deleted indexeddb data."); }, (err) => { - console.error("Failed to delete indexeddb data: ", err); + console.error(`Failed to delete indexeddb data: ${err}`); throw err; }); }; diff --git a/src/sync.js b/src/sync.js index c2a8f8abe012573a21ed7d819e5f61f46fbd365a..cec7914966407dfb73e273840cf03b5964b0aca4 100644 --- a/src/sync.js +++ b/src/sync.js @@ -298,7 +298,7 @@ SyncApi.prototype.peek = function(roomId) { client.store.storeRoom(peekRoom); client.emit("Room", peekRoom); - self._peekPoll(roomId); + self._peekPoll(peekRoom); return peekRoom; }); }; @@ -313,22 +313,26 @@ SyncApi.prototype.stopPeeking = function() { /** * Do a peek room poll. - * @param {string} roomId + * @param {Room} peekRoom * @param {string} token from= token */ -SyncApi.prototype._peekPoll = function(roomId, token) { - if (this._peekRoomId !== roomId) { - debuglog("Stopped peeking in room %s", roomId); +SyncApi.prototype._peekPoll = function(peekRoom, token) { + if (this._peekRoomId !== peekRoom.roomId) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); return; } const self = this; // FIXME: gut wrenching; hard-coded timeout values this.client._http.authedRequest(undefined, "GET", "/events", { - room_id: roomId, + room_id: peekRoom.roomId, timeout: 30 * 1000, from: token, }, undefined, 50 * 1000).done(function(res) { + if (self._peekRoomId !== peekRoom.roomId) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } // We have a problem that we get presence both from /events and /sync // however, /sync only returns presence for users in rooms // you're actually joined to. @@ -354,15 +358,15 @@ SyncApi.prototype._peekPoll = function(roomId, token) { // strip out events which aren't for the given room_id (e.g presence) const events = res.chunk.filter(function(e) { - return e.room_id === roomId; + return e.room_id === peekRoom.roomId; }).map(self.client.getEventMapper()); - const room = self.client.getRoom(roomId); - room.addLiveEvents(events); - self._peekPoll(roomId, res.end); + + peekRoom.addLiveEvents(events); + self._peekPoll(peekRoom, res.end); }, function(err) { - console.error("[%s] Peek poll failed: %s", roomId, err); + console.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); setTimeout(function() { - self._peekPoll(roomId, token); + self._peekPoll(peekRoom, token); }, 30 * 1000); }); }; diff --git a/src/timeline-window.js b/src/timeline-window.js index 523f8fcdacbab613a4f77f2622167a6c1b08702e..b68190cead7743aaf1ac245ebac74e8324f2ac06 100644 --- a/src/timeline-window.js +++ b/src/timeline-window.js @@ -95,10 +95,30 @@ TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { const self = this; initialWindowSize = initialWindowSize || 20; - // given an EventTimeline, and an event index within it, initialise our + // given an EventTimeline, find the event we were looking for, and initialise our // fields so that the event in question is in the middle of the window. - const initFields = function(timeline, eventIndex) { - const endIndex = Math.min(timeline.getEvents().length, + const initFields = function(timeline) { + let eventIndex; + + const events = timeline.getEvents(); + + if (!initialEventId) { + // we were looking for the live timeline: initialise to the end + eventIndex = events.length; + } else { + for (let i = 0; i < events.length; i++) { + if (events[i].getId() == initialEventId) { + eventIndex = i; + break; + } + } + + if (eventIndex === undefined) { + throw new Error("getEventTimeline result didn't include requested event"); + } + } + + const endIndex = Math.min(events.length, eventIndex + Math.ceil(initialWindowSize / 2)); const startIndex = Math.max(0, endIndex - initialWindowSize); self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); @@ -110,24 +130,19 @@ TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { // we already have the data we need, which is important to keep room-switching // feeling snappy. // - // TODO: ideally we'd spot getEventTimeline returning a resolved promise and - // skip straight to the find-event loop. if (initialEventId) { - return this._client.getEventTimeline(this._timelineSet, initialEventId) - .then(function(tl) { - // make sure that our window includes the event - for (let i = 0; i < tl.getEvents().length; i++) { - if (tl.getEvents()[i].getId() == initialEventId) { - initFields(tl, i); - return; - } - } - throw new Error("getEventTimeline result didn't include requested event"); - }); + const prom = this._client.getEventTimeline(this._timelineSet, initialEventId); + + const promState = prom.inspect(); + if (promState.state == 'fulfilled') { + initFields(promState.value); + return q(); + } else { + return prom.then(initFields); + } } else { - // start with the most recent events const tl = this._timelineSet.getLiveTimeline(); - initFields(tl, tl.getEvents().length); + initFields(tl); return q(); } }; diff --git a/src/webrtc/call.js b/src/webrtc/call.js index 6ae945e5fc10ee65b51a7164ed11d5f3e91c2c6a..a44e18ad63a1cb21cd01b1e073924140fd2b7448 100644 --- a/src/webrtc/call.js +++ b/src/webrtc/call.js @@ -71,6 +71,7 @@ const DEBUG = true; // set true to enable console logging. * @param {Object} opts Config options. * @param {string} opts.roomId The room ID for this call. * @param {Object} opts.webRtc The WebRTC globals from the browser. + * @param {boolean} opts.forceTURN whether relay through TURN should be forced. * @param {Object} opts.URL The URL global. * @param {Array<Object>} opts.turnServers Optional. A list of TURN servers. * @param {MatrixClient} opts.client The Matrix Client instance to send events to. @@ -79,6 +80,7 @@ function MatrixCall(opts) { this.roomId = opts.roomId; this.client = opts.client; this.webRtc = opts.webRtc; + this.forceTURN = opts.forceTURN; this.URL = opts.URL; // Array of Objects with urls, username, credential keys this.turnServers = opts.turnServers || []; @@ -1184,6 +1186,7 @@ const _createPeerConnection = function(self) { } const pc = new self.webRtc.RtcPeerConnection({ + iceTransportPolicy: self.forceTURN ? 'relay' : undefined, iceServers: servers, }); pc.oniceconnectionstatechange = hookCallback(self, self._onIceConnectionStateChanged); @@ -1293,9 +1296,11 @@ module.exports.setVideoInput = function(deviceId) { videoInput = deviceId; }; * Create a new Matrix call for the browser. * @param {MatrixClient} client The client instance to use. * @param {string} roomId The room the call is in. + * @param {Object?} options optional options map. + * @param {boolean} options.forceTURN whether relay through TURN should be forced. * @return {MatrixCall} the call or null if the browser doesn't support calling. */ -module.exports.createNewMatrixCall = function(client, roomId) { +module.exports.createNewMatrixCall = function(client, roomId, options) { const w = global.window; const doc = global.document; if (!w || !doc) { @@ -1351,6 +1356,8 @@ module.exports.createNewMatrixCall = function(client, roomId) { URL: w.URL, roomId: roomId, turnServers: client.getTurnServers(), + // call level options + forceTURN: options ? options.forceTURN : false, }; return new MatrixCall(opts); }; diff --git a/travis.sh b/travis.sh new file mode 100755 index 0000000000000000000000000000000000000000..68d915defe43066605e38e6e5f5e34169d2f07b4 --- /dev/null +++ b/travis.sh @@ -0,0 +1,13 @@ +#!/bin/bash + +set -ex + +npm run lint + +# install Olm so that we can run the crypto tests. +npm install https://matrix.org/packages/npm/olm/olm-2.2.2.tgz + +npm run test + +npm run gendoc +