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
+