diff --git a/.gitignore b/.gitignore index e38531b4..00e360eb 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ fetchlogs sessionexports bundle.js target +lib diff --git a/index.html b/index.html index cdf0ad4f..74e44c99 100644 --- a/index.html +++ b/index.html @@ -18,7 +18,14 @@ + + + +

+    
+
+
+ 
diff --git a/prototypes/olmtest.html b/prototypes/olmtest.html
index 9a17a189..839fb5ee 100644
--- a/prototypes/olmtest.html
+++ b/prototypes/olmtest.html
@@ -12,13 +12,13 @@
     
 
 
-    
+    
     ` +
+        `` +
         `` +
-        ``);
+        ``);
     removeOrEnableScript(doc("script#service-worker"), offline);
 
     const versionScript = doc("script#version");
@@ -172,23 +186,24 @@ async function buildHtml(doc, version, assetPaths, manifestPath) {
     await fs.writeFile(path.join(targetDir, "index.html"), doc.html(), "utf8");
 }
 
-async function buildJs() {
+async function buildJs(inputFile, outputName) {
     // create js bundle
     const bundle = await rollup({
-        input: 'src/main.js',
+        input: inputFile,
         plugins: [removeJsComments({comments: "none"})]
     });
     const {output} = await bundle.generate({
         format: 'es',
+        // TODO: can remove this?
         name: `${PROJECT_ID}Bundle`
     });
     const code = output[0].code;
-    const bundlePath = resource(`${PROJECT_ID}.js`, code);
+    const bundlePath = resource(outputName, code);
     await fs.writeFile(bundlePath, code, "utf8");
     return bundlePath;
 }
 
-async function buildJsLegacy() {
+async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
     // compile down to whatever IE 11 needs
     const babelPlugin = babel.babel({
         babelHelpers: 'bundled',
@@ -204,9 +219,12 @@ async function buildJsLegacy() {
             ]
         ]
     });
+    if (!polyfillFile) {
+        polyfillFile = 'src/legacy-polyfill.js';
+    }
     // create js bundle
     const rollupConfig = {
-        input: ['src/legacy-polyfill.js', 'src/main.js'],
+        input: [polyfillFile, inputFile],
         plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})]
     };
     const bundle = await rollup(rollupConfig);
@@ -215,11 +233,16 @@ async function buildJsLegacy() {
         name: `${PROJECT_ID}Bundle`
     });
     const code = output[0].code;
-    const bundlePath = resource(`${PROJECT_ID}-legacy.js`, code);
+    const bundlePath = resource(outputName, code);
     await fs.writeFile(bundlePath, code, "utf8");
     return bundlePath;
 }
 
+function buildWorkerJsLegacy(inputFile, outputName) {
+    const polyfillFile = 'src/worker-polyfill.js';
+    return buildJsLegacy(inputFile, outputName, polyfillFile);
+}
+
 async function buildOffline(version, assetPaths) {
     // write offline availability
     const offlineFiles = [
@@ -338,7 +361,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
         if (dirEnt.isDirectory()) {
             await fs.mkdir(dstPath);
             Object.assign(assetPaths, await copyFolder(srcPath, dstPath, filter));
-        } else if (dirEnt.isFile() && filter(srcPath)) {
+        } else if ((dirEnt.isFile() || dirEnt.isSymbolicLink()) && (!filter || filter(srcPath))) {
             const content = await fs.readFile(srcPath);
             const hashedDstPath = resource(dstPath, content);
             await fs.writeFile(hashedDstPath, content);
@@ -350,7 +373,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
 
 function resource(relPath, content) {
     let fullPath = relPath;
-    if (!relPath.startsWith("/")) {
+    if (!path.isAbsolute(relPath)) {
         fullPath = path.join(targetDir, relPath);
     }
     const hash = contentHash(Buffer.from(content));
diff --git a/scripts/common.mjs b/scripts/common.mjs
new file mode 100644
index 00000000..e135022a
--- /dev/null
+++ b/scripts/common.mjs
@@ -0,0 +1,12 @@
+import fsRoot from "fs";
+const fs = fsRoot.promises;
+
+export async function removeDirIfExists(targetDir) {
+    try {
+        await fs.rmdir(targetDir, {recursive: true});
+    } catch (err) {
+        if (err.code !== "ENOENT") {
+            throw err;
+        }
+    }
+}
diff --git a/scripts/post-install.mjs b/scripts/post-install.mjs
new file mode 100644
index 00000000..328f8c7c
--- /dev/null
+++ b/scripts/post-install.mjs
@@ -0,0 +1,64 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 fsRoot from "fs";
+const fs = fsRoot.promises;
+import path from "path";
+import { rollup } from 'rollup';
+import { fileURLToPath } from 'url';
+import { dirname } from 'path';
+// needed to translate commonjs modules to esm
+import commonjs from '@rollup/plugin-commonjs';
+// multi-entry plugin so we can add polyfill file to main
+import {removeDirIfExists} from "./common.mjs";
+
+const __filename = fileURLToPath(import.meta.url);
+const __dirname = dirname(__filename);
+const projectDir = path.join(__dirname, "../");
+
+async function commonjsToESM(src, dst) {
+    // create js bundle
+    const bundle = await rollup({
+        input: src,
+        plugins: [commonjs()]
+    });
+    const {output} = await bundle.generate({
+        format: 'es'
+    });
+    const code = output[0].code;
+    await fs.writeFile(dst, code, "utf8");
+}
+
+async function populateLib() {
+    const libDir = path.join(projectDir, "lib/");
+    const modulesDir = path.join(projectDir, "node_modules/");
+    await removeDirIfExists(libDir);
+    await fs.mkdir(libDir);
+    const olmSrcDir = path.join(modulesDir, "olm/");
+    const olmDstDir = path.join(libDir, "olm/");
+    await fs.mkdir(olmDstDir);
+    for (const file of ["olm.js", "olm.wasm", "olm_legacy.js"]) {
+        await fs.symlink(path.join(olmSrcDir, file), path.join(olmDstDir, file));
+    }
+    // transpile another-json to esm
+    await fs.mkdir(path.join(libDir, "another-json/"));
+    await commonjsToESM(
+        path.join(modulesDir, 'another-json/another-json.js'),
+        path.join(libDir, "another-json/index.js")
+    );
+}
+
+populateLib();
diff --git a/src/domain/SessionLoadViewModel.js b/src/domain/SessionLoadViewModel.js
index e747ce00..0511d8a7 100644
--- a/src/domain/SessionLoadViewModel.js
+++ b/src/domain/SessionLoadViewModel.js
@@ -82,7 +82,7 @@ export class SessionLoadViewModel extends ViewModel {
             if (this._sessionContainer) {
                 this._sessionContainer.stop();
                 if (this._deleteSessionOnCancel) {
-                    await this._sessionContainer.deletSession();
+                    await this._sessionContainer.deleteSession();
                 }
                 this._sessionContainer = null;
             }
@@ -127,6 +127,8 @@ export class SessionLoadViewModel extends ViewModel {
                             return `Something went wrong while checking your login and password.`;
                     }
                     break;
+                case LoadStatus.SessionSetup:
+                    return `Setting up your encryption keys…`;
                 case LoadStatus.Loading:
                     return `Loading your conversations…`;
                 case LoadStatus.FirstSync:
diff --git a/src/domain/ViewModel.js b/src/domain/ViewModel.js
index bc35fabd..15812f8c 100644
--- a/src/domain/ViewModel.js
+++ b/src/domain/ViewModel.js
@@ -36,8 +36,7 @@ export class ViewModel extends EventEmitter {
         if (!this.disposables) {
             this.disposables = new Disposables();
         }
-        this.disposables.track(disposable);
-        return disposable;
+        return this.disposables.track(disposable);
     }
 
     dispose() {
diff --git a/src/domain/session/room/RoomViewModel.js b/src/domain/session/room/RoomViewModel.js
index 32e09fbe..ab453e01 100644
--- a/src/domain/session/room/RoomViewModel.js
+++ b/src/domain/session/room/RoomViewModel.js
@@ -38,7 +38,8 @@ export class RoomViewModel extends ViewModel {
     async load() {
         this._room.on("change", this._onRoomChange);
         try {
-            this._timeline = await this._room.openTimeline();
+            this._timeline = this.track(this._room.openTimeline());
+            await this._timeline.load();
             this._timelineVM = new TimelineViewModel(this.childOptions({
                 room: this._room,
                 timeline: this._timeline,
@@ -62,17 +63,15 @@ export class RoomViewModel extends ViewModel {
     }
 
     dispose() {
-        // this races with enable, on the await openTimeline()
-        if (this._timeline) {
-            // will stop the timeline from delivering updates on entries
-            this._timeline.close();
-        }
+        super.dispose();
         if (this._clearUnreadTimout) {
             this._clearUnreadTimout.abort();
             this._clearUnreadTimout = null;
         }
     }
 
+    // called from view to close room
+    // parent vm will dispose this vm
     close() {
         this._closeCallback();
     }
@@ -91,6 +90,10 @@ export class RoomViewModel extends ViewModel {
         return this._timelineVM;
     }
 
+    get isEncrypted() {
+        return this._room.isEncrypted;
+    }
+
     get error() {
         if (this._timelineError) {
             return `Something went wrong loading the timeline: ${this._timelineError.message}`;
@@ -148,6 +151,10 @@ class ComposerViewModel extends ViewModel {
         this._isEmpty = true;
     }
 
+    get isEncrypted() {
+        return this._roomVM.isEncrypted;
+    }
+
     sendMessage(message) {
         const success = this._roomVM._sendMessage(message);
         if (success) {
diff --git a/src/domain/session/room/timeline/TimelineViewModel.js b/src/domain/session/room/timeline/TimelineViewModel.js
index 1527d3ae..16d529cb 100644
--- a/src/domain/session/room/timeline/TimelineViewModel.js
+++ b/src/domain/session/room/timeline/TimelineViewModel.js
@@ -54,7 +54,7 @@ export class TimelineViewModel extends ViewModel {
         if (firstTile.shape === "gap") {
             return firstTile.fill();
         } else {
-            await this._timeline.loadAtTop(50);
+            await this._timeline.loadAtTop(10);
             return false;
         }
     }
diff --git a/src/domain/session/room/timeline/tiles/MessageTile.js b/src/domain/session/room/timeline/tiles/MessageTile.js
index 5b3bb603..9db96eff 100644
--- a/src/domain/session/room/timeline/tiles/MessageTile.js
+++ b/src/domain/session/room/timeline/tiles/MessageTile.js
@@ -31,8 +31,12 @@ export class MessageTile extends SimpleTile {
         return "message";
     }
 
+    get displayName() {
+        return this._entry.displayName || this.sender;
+    }
+
     get sender() {
-        return this._entry.displayName || this._entry.sender;
+        return this._entry.sender;
     }
 
     // Avatar view model contract
@@ -52,7 +56,7 @@ export class MessageTile extends SimpleTile {
     }
 
     get avatarTitle() {
-        return this.sender;
+        return this.displayName;
     }
 
     get date() {
@@ -71,6 +75,10 @@ export class MessageTile extends SimpleTile {
         return this._isContinuation;
     }
 
+    get isUnverified() {
+        return this._entry.isUnverified;
+    }
+
     _getContent() {
         return this._entry.content;
     }
diff --git a/src/legacy-polyfill.js b/src/legacy-polyfill.js
index 5665158c..a48416c7 100644
--- a/src/legacy-polyfill.js
+++ b/src/legacy-polyfill.js
@@ -23,4 +23,4 @@ if (!Element.prototype.remove) {
     Element.prototype.remove = function remove() {
         this.parentNode.removeChild(this);
     };
-}
\ No newline at end of file
+}
diff --git a/src/main.js b/src/main.js
index 2c86b9d9..8a7cbf0d 100644
--- a/src/main.js
+++ b/src/main.js
@@ -25,12 +25,67 @@ import {BrawlViewModel} from "./domain/BrawlViewModel.js";
 import {BrawlView} from "./ui/web/BrawlView.js";
 import {Clock} from "./ui/web/dom/Clock.js";
 import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
+import {WorkerPool} from "./utils/WorkerPool.js";
+import {OlmWorker} from "./matrix/e2ee/OlmWorker.js";
+
+function addScript(src) {
+    return new Promise(function (resolve, reject) {
+        var s = document.createElement("script");
+        s.setAttribute("src", src );
+        s.onload=resolve;
+        s.onerror=reject;
+        document.body.appendChild(s);
+    });
+}
+
+async function loadOlm(olmPaths) {
+    // make crypto.getRandomValues available without
+    // a prefix on IE11, needed by olm to work
+    if (window.msCrypto && !window.crypto) {
+        window.crypto = window.msCrypto;
+    }
+    if (olmPaths) {
+        if (window.WebAssembly) {
+            await addScript(olmPaths.wasmBundle);
+            await window.Olm.init({locateFile: () => olmPaths.wasm});
+        } else {
+            await addScript(olmPaths.legacyBundle);
+            await window.Olm.init();
+        }
+        return window.Olm;
+    }
+    return null;
+}
+
+// make path relative to basePath,
+// assuming it and basePath are relative to document
+function relPath(path, basePath) {
+    const idx = basePath.lastIndexOf("/");
+    const dir = idx === -1 ? "" : basePath.slice(0, idx);
+    const dirCount = dir.length ? dir.split("/").length : 0;
+    return "../".repeat(dirCount) + path;
+}
+
+async function loadOlmWorker(paths) {
+    const workerPool = new WorkerPool(paths.worker, 4);
+    await workerPool.init();
+    const path = relPath(paths.olm.legacyBundle, paths.worker);
+    await workerPool.sendAll({type: "load_olm", path});
+    const olmWorker = new OlmWorker(workerPool);
+    return olmWorker;
+}
 
 // Don't use a default export here, as we use multiple entries during legacy build,
 // which does not support default exports,
 // see https://github.com/rollup/plugins/tree/master/packages/multi-entry
-export async function main(container) {
+export async function main(container, paths) {
     try {
+        const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
+        if (isIE11) {
+            document.body.className += " ie11";
+        } else {
+            document.body.className += " not-ie11";
+        }
         // to replay:
         // const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json();
         // const replay = new ReplayRequester(fetchLog, {delay: false});
@@ -50,6 +105,13 @@ export async function main(container) {
         const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
         const storageFactory = new StorageFactory();
 
+        // if wasm is not supported, we'll want
+        // to run some olm operations in a worker (mainly for IE11)
+        let workerPromise;
+        if (!window.WebAssembly) {
+            workerPromise = loadOlmWorker(paths);
+        }
+
         const vm = new BrawlViewModel({
             createSessionContainer: () => {
                 return new SessionContainer({
@@ -59,6 +121,8 @@ export async function main(container) {
                     sessionInfoStorage,
                     request,
                     clock,
+                    olmPromise: loadOlm(paths.olm),
+                    workerPromise,
                 });
             },
             sessionInfoStorage,
diff --git a/src/matrix/DeviceMessageHandler.js b/src/matrix/DeviceMessageHandler.js
new file mode 100644
index 00000000..4c7d0e75
--- /dev/null
+++ b/src/matrix/DeviceMessageHandler.js
@@ -0,0 +1,110 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./e2ee/common.js";
+import {groupBy} from "../utils/groupBy.js";
+
+// key to store in session store
+const PENDING_ENCRYPTED_EVENTS = "pendingEncryptedDeviceEvents";
+
+export class DeviceMessageHandler {
+    constructor({storage}) {
+        this._storage = storage;
+        this._olmDecryption = null;
+        this._megolmDecryption = null;
+    }
+
+    enableEncryption({olmDecryption, megolmDecryption}) {
+        this._olmDecryption = olmDecryption;
+        this._megolmDecryption = megolmDecryption;
+    }
+
+    async writeSync(toDeviceEvents, txn) {
+        const encryptedEvents = toDeviceEvents.filter(e => e.type === "m.room.encrypted");
+        // store encryptedEvents
+        let pendingEvents = await this._getPendingEvents(txn);
+        pendingEvents = pendingEvents.concat(encryptedEvents);
+        txn.session.set(PENDING_ENCRYPTED_EVENTS, pendingEvents);
+        // we don't handle anything other for now
+    }
+
+    /**
+     * [_writeDecryptedEvents description]
+     * @param  {Array} olmResults
+     * @param  {[type]} txn        [description]
+     * @return {[type]}            [description]
+     */
+    async _writeDecryptedEvents(olmResults, txn) {
+        const megOlmRoomKeysResults = olmResults.filter(r => {
+            return r.event?.type === "m.room_key" && r.event.content?.algorithm === MEGOLM_ALGORITHM;
+        });
+        let roomKeys;
+        if (megOlmRoomKeysResults.length) {
+            console.log("new room keys", megOlmRoomKeysResults);
+            roomKeys = await this._megolmDecryption.addRoomKeys(megOlmRoomKeysResults, txn);
+        }
+        return {roomKeys};
+    }
+
+    _applyDecryptChanges(rooms, {roomKeys}) {
+        if (roomKeys && roomKeys.length) {
+            const roomKeysByRoom = groupBy(roomKeys, s => s.roomId);
+            for (const [roomId, roomKeys] of roomKeysByRoom) {
+                const room = rooms.get(roomId);
+                room?.notifyRoomKeys(roomKeys);
+            }
+        }
+    }
+
+    // not safe to call multiple times without awaiting first call
+    async decryptPending(rooms) {
+        if (!this._olmDecryption) {
+            return;
+        }
+        const readTxn = await this._storage.readTxn([this._storage.storeNames.session]);
+        const pendingEvents = await this._getPendingEvents(readTxn);
+        if (pendingEvents.length === 0) {
+           return;
+        }
+        // only know olm for now
+        const olmEvents = pendingEvents.filter(e => e.content?.algorithm === OLM_ALGORITHM);
+        const decryptChanges = await this._olmDecryption.decryptAll(olmEvents);
+        for (const err of decryptChanges.errors) {
+            console.warn("decryption failed for event", err, err.event);
+        }
+        const txn = await this._storage.readWriteTxn([
+            // both to remove the pending events and to modify the olm account
+            this._storage.storeNames.session,
+            this._storage.storeNames.olmSessions,
+            this._storage.storeNames.inboundGroupSessions,
+        ]);
+        let changes;
+        try {
+            changes = await this._writeDecryptedEvents(decryptChanges.results, txn);
+            decryptChanges.write(txn);
+            txn.session.remove(PENDING_ENCRYPTED_EVENTS);
+        } catch (err) {
+            txn.abort();
+            throw err;
+        }
+        await txn.complete();
+        this._applyDecryptChanges(rooms, changes);
+    }
+
+    async _getPendingEvents(txn) {
+        return (await txn.session.get(PENDING_ENCRYPTED_EVENTS)) || [];
+    }
+}
diff --git a/src/matrix/SendScheduler.js b/src/matrix/SendScheduler.js
index 6e6196d3..e7627c81 100644
--- a/src/matrix/SendScheduler.js
+++ b/src/matrix/SendScheduler.js
@@ -121,7 +121,7 @@ export class SendScheduler {
                     }
                     this._sendRequests = [];
                 }
-                console.error("error for request", request);
+                console.error("error for request", err);
                 request.reject(err);
                 break;
             }
diff --git a/src/matrix/Session.js b/src/matrix/Session.js
index b477c3d9..f8ac2f40 100644
--- a/src/matrix/Session.js
+++ b/src/matrix/Session.js
@@ -18,10 +18,24 @@ import {Room} from "./room/Room.js";
 import { ObservableMap } from "../observable/index.js";
 import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
 import {User} from "./User.js";
+import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
+import {Account as E2EEAccount} from "./e2ee/Account.js";
+import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js";
+import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
+import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption.js";
+import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js";
+import {MEGOLM_ALGORITHM} from "./e2ee/common.js";
+import {RoomEncryption} from "./e2ee/RoomEncryption.js";
+import {DeviceTracker} from "./e2ee/DeviceTracker.js";
+import {LockMap} from "../utils/LockMap.js";
+import {groupBy} from "../utils/groupBy.js";
+
+const PICKLE_KEY = "DEFAULT_KEY";
 
 export class Session {
     // sessionInfo contains deviceId, userId and homeServer
-    constructor({storage, hsApi, sessionInfo}) {
+    constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker}) {
+        this._clock = clock;
         this._storage = storage;
         this._hsApi = hsApi;
         this._syncInfo = null;
@@ -30,6 +44,118 @@ export class Session {
         this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()});
         this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
         this._user = new User(sessionInfo.userId);
+        this._deviceMessageHandler = new DeviceMessageHandler({storage});
+        this._olm = olm;
+        this._olmUtil = null;
+        this._e2eeAccount = null;
+        this._deviceTracker = null;
+        this._olmEncryption = null;
+        this._megolmEncryption = null;
+        this._megolmDecryption = null;
+        this._getSyncToken = () => this.syncToken;
+        this._olmWorker = olmWorker;
+
+        if (olm) {
+            this._olmUtil = new olm.Utility();
+            this._deviceTracker = new DeviceTracker({
+                storage,
+                getSyncToken: this._getSyncToken,
+                olmUtil: this._olmUtil,
+                ownUserId: sessionInfo.userId,
+                ownDeviceId: sessionInfo.deviceId,
+            });
+        }
+        this._createRoomEncryption = this._createRoomEncryption.bind(this);
+    }
+
+    // called once this._e2eeAccount is assigned
+    _setupEncryption() {
+        console.log("loaded e2ee account with keys", this._e2eeAccount.identityKeys);
+        const senderKeyLock = new LockMap();
+        const olmDecryption = new OlmDecryption({
+            account: this._e2eeAccount,
+            pickleKey: PICKLE_KEY,
+            olm: this._olm,
+            storage: this._storage,
+            now: this._clock.now,
+            ownUserId: this._user.id,
+            senderKeyLock
+        });
+        this._olmEncryption = new OlmEncryption({
+            account: this._e2eeAccount,
+            pickleKey: PICKLE_KEY,
+            olm: this._olm,
+            storage: this._storage,
+            now: this._clock.now,
+            ownUserId: this._user.id,
+            olmUtil: this._olmUtil,
+            senderKeyLock
+        });
+        this._megolmEncryption = new MegOlmEncryption({
+            account: this._e2eeAccount,
+            pickleKey: PICKLE_KEY,
+            olm: this._olm,
+            storage: this._storage,
+            now: this._clock.now,
+            ownDeviceId: this._sessionInfo.deviceId,
+        });
+        this._megolmDecryption = new MegOlmDecryption({
+            pickleKey: PICKLE_KEY,
+            olm: this._olm,
+            olmWorker: this._olmWorker,
+        });
+        this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption});
+    }
+
+    _createRoomEncryption(room, encryptionParams) {
+        // TODO: this will actually happen when users start using the e2ee version for the first time
+
+        // this should never happen because either a session was already synced once
+        // and thus an e2ee account was created as well and _setupEncryption is called from load
+        // OR
+        // this is a new session and loading it will load zero rooms, thus not calling this method.
+        // in this case _setupEncryption is called from beforeFirstSync, right after load,
+        // so any incoming synced rooms won't be there yet
+        if (!this._olmEncryption) {
+            throw new Error("creating room encryption before encryption got globally enabled");
+        }
+        // only support megolm
+        if (encryptionParams.algorithm !== MEGOLM_ALGORITHM) {
+            return null;
+        }
+        return new RoomEncryption({
+            room,
+            deviceTracker: this._deviceTracker,
+            olmEncryption: this._olmEncryption,
+            megolmEncryption: this._megolmEncryption,
+            megolmDecryption: this._megolmDecryption,
+            storage: this._storage,
+            encryptionParams
+        });
+    }
+
+    // called after load
+    async beforeFirstSync(isNewLogin) {
+        if (this._olm) {
+            if (isNewLogin && this._e2eeAccount) {
+                throw new Error("there should not be an e2ee account already on a fresh login");
+            }
+            if (!this._e2eeAccount) {
+                this._e2eeAccount = await E2EEAccount.create({
+                    hsApi: this._hsApi,
+                    olm: this._olm,
+                    pickleKey: PICKLE_KEY,
+                    userId: this._sessionInfo.userId,
+                    deviceId: this._sessionInfo.deviceId,
+                    olmWorker: this._olmWorker,
+                    storage: this._storage,
+                });
+                this._setupEncryption();
+            }
+            await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
+            await this._e2eeAccount.uploadKeys(this._storage);
+            await this._deviceMessageHandler.decryptPending(this.rooms);
+        }
     }
 
     async load() {
@@ -43,6 +169,21 @@ export class Session {
         ]);
         // restore session object
         this._syncInfo = await txn.session.get("sync");
+        // restore e2ee account, if any
+        if (this._olm) {
+            this._e2eeAccount = await E2EEAccount.load({
+                hsApi: this._hsApi,
+                olm: this._olm,
+                pickleKey: PICKLE_KEY,
+                userId: this._sessionInfo.userId,
+                deviceId: this._sessionInfo.deviceId,
+                olmWorker: this._olmWorker,
+                txn
+            });
+            if (this._e2eeAccount) {
+                this._setupEncryption();
+            }
+        }
         const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
         // load rooms
         const rooms = await txn.roomSummary.getAll();
@@ -57,6 +198,7 @@ export class Session {
     }
 
     stop() {
+        this._olmWorker?.dispose();
         this._sendScheduler.stop();
     }
 
@@ -71,9 +213,20 @@ export class Session {
             await txn.complete();
         }
 
+        const opsTxn = await this._storage.readWriteTxn([
+            this._storage.storeNames.operations
+        ]);
+        const operations = await opsTxn.operations.getAll();
+        const operationsByScope = groupBy(operations, o => o.scope);
+
         this._sendScheduler.start();
         for (const [, room] of this._rooms) {
-            room.resumeSending();
+            let roomOperationsByType;
+            const roomOperations = operationsByScope.get(room.id);
+            if (roomOperations) {
+                roomOperationsByType = groupBy(roomOperations, r => r.type);
+            }
+            room.start(roomOperationsByType);
         }
     }
 
@@ -97,31 +250,68 @@ export class Session {
     createRoom(roomId, pendingEvents) {
         const room = new Room({
             roomId,
+            getSyncToken: this._getSyncToken,
             storage: this._storage,
             emitCollectionChange: this._roomUpdateCallback,
             hsApi: this._hsApi,
             sendScheduler: this._sendScheduler,
             pendingEvents,
             user: this._user,
+            createRoomEncryption: this._createRoomEncryption,
+            clock: this._clock
         });
         this._rooms.add(roomId, room);
         return room;
     }
 
-    writeSync(syncToken, syncFilterId, accountData, txn) {
+    async writeSync(syncResponse, syncFilterId, txn) {
+        const changes = {};
+        const syncToken = syncResponse.next_batch;
+        const deviceOneTimeKeysCount = syncResponse.device_one_time_keys_count;
+
+        if (this._e2eeAccount && deviceOneTimeKeysCount) {
+            changes.e2eeAccountChanges = this._e2eeAccount.writeSync(deviceOneTimeKeysCount, txn);
+        }
         if (syncToken !== this.syncToken) {
             const syncInfo = {token: syncToken, filterId: syncFilterId};
             // don't modify `this` because transaction might still fail
             txn.session.set("sync", syncInfo);
-            return syncInfo;
+            changes.syncInfo = syncInfo;
         }
+        if (this._deviceTracker) {
+            const deviceLists = syncResponse.device_lists;
+            if (deviceLists) {
+                await this._deviceTracker.writeDeviceChanges(deviceLists, txn);
+            }
+        }
+
+        const toDeviceEvents = syncResponse.to_device?.events;
+        if (Array.isArray(toDeviceEvents)) {
+            this._deviceMessageHandler.writeSync(toDeviceEvents, txn);
+        }
+        return changes;
     }
 
-    afterSync(syncInfo) {
+    afterSync({syncInfo, e2eeAccountChanges}) {
         if (syncInfo) {
             // sync transaction succeeded, modify object state now
             this._syncInfo = syncInfo;
         }
+        if (this._e2eeAccount && e2eeAccountChanges) {
+            this._e2eeAccount.afterSync(e2eeAccountChanges);
+        }
+    }
+
+    async afterSyncCompleted() {
+        const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
+        const promises = [this._deviceMessageHandler.decryptPending(this.rooms)];
+        if (needsToUploadOTKs) {
+            // TODO: we could do this in parallel with sync if it proves to be too slow
+            // but I'm not sure how to not swallow errors in that case
+            promises.push(this._e2eeAccount.uploadKeys(this._storage));
+        }
+        // run key upload and decryption in parallel
+        await Promise.all(promises);
     }
 
     get syncToken() {
@@ -181,7 +371,7 @@ export function tests() {
                     }
                 }
             };
-            const newSessionData = session.writeSync("b", 6, {}, syncTxn);
+            const newSessionData = await session.writeSync({next_batch: "b"}, 6, syncTxn);
             assert(syncSet);
             assert.equal(session.syncToken, "a");
             assert.equal(session.syncFilterId, 5);
diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js
index 74184a0a..98ffd2b1 100644
--- a/src/matrix/SessionContainer.js
+++ b/src/matrix/SessionContainer.js
@@ -28,6 +28,7 @@ export const LoadStatus = createEnum(
     "Login",
     "LoginFailed",
     "Loading",
+    "SessionSetup", // upload e2ee keys, ...
     "Migrating",    //not used atm, but would fit here
     "FirstSync",
     "Error",
@@ -41,7 +42,7 @@ export const LoginFailure = createEnum(
 );
 
 export class SessionContainer {
-    constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage}) {
+    constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise}) {
         this._random = random;
         this._clock = clock;
         this._onlineStatus = onlineStatus;
@@ -57,6 +58,8 @@ export class SessionContainer {
         this._sync = null;
         this._sessionId = null;
         this._storage = null;
+        this._olmPromise = olmPromise;
+        this._workerPromise = workerPromise;
     }
 
     createNewSessionId() {
@@ -73,7 +76,7 @@ export class SessionContainer {
             if (!sessionInfo) {
                 throw new Error("Invalid session id: " + sessionId);
             }
-            await this._loadSessionInfo(sessionInfo);
+            await this._loadSessionInfo(sessionInfo, false);
         } catch (err) {
             this._error = err;
             this._status.set(LoadStatus.Error);
@@ -88,7 +91,7 @@ export class SessionContainer {
         let sessionInfo;
         try {
             const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout});
-            const loginData = await hsApi.passwordLogin(username, password).response();
+            const loginData = await hsApi.passwordLogin(username, password, "Hydrogen").response();
             const sessionId = this.createNewSessionId();
             sessionInfo = {
                 id: sessionId,
@@ -120,14 +123,14 @@ export class SessionContainer {
         // LoadStatus.Error in case of an error,
         // so separate try/catch
         try {
-            await this._loadSessionInfo(sessionInfo);
+            await this._loadSessionInfo(sessionInfo, true);
         } catch (err) {
             this._error = err;
             this._status.set(LoadStatus.Error);
         }
     }
 
-    async _loadSessionInfo(sessionInfo) {
+    async _loadSessionInfo(sessionInfo, isNewLogin) {
         this._status.set(LoadStatus.Loading);
         this._reconnector = new Reconnector({
             onlineStatus: this._onlineStatus,
@@ -149,8 +152,17 @@ export class SessionContainer {
             userId: sessionInfo.userId,
             homeServer: sessionInfo.homeServer,
         };
-        this._session = new Session({storage: this._storage, sessionInfo: filteredSessionInfo, hsApi});
+        const olm = await this._olmPromise;
+        let olmWorker = null;
+        if (this._workerPromise) {
+            olmWorker = await this._workerPromise;
+        }
+        this._session = new Session({storage: this._storage,
+            sessionInfo: filteredSessionInfo, hsApi, olm,
+            clock: this._clock, olmWorker});
         await this._session.load();
+        this._status.set(LoadStatus.SessionSetup);
+        await this._session.beforeFirstSync(isNewLogin);
         
         this._sync = new Sync({hsApi, storage: this._storage, session: this._session});
         // notify sync and session when back online
@@ -234,10 +246,16 @@ export class SessionContainer {
     }
 
     stop() {
-        this._reconnectSubscription();
-        this._reconnectSubscription = null;
-        this._sync.stop();
-        this._session.stop();
+        if (this._reconnectSubscription) {
+            this._reconnectSubscription();
+            this._reconnectSubscription = null;
+        }
+        if (this._sync) {
+            this._sync.stop();
+        }
+        if (this._session) {
+            this._session.stop();
+        }
         if (this._waitForFirstSyncHandle) {
             this._waitForFirstSyncHandle.dispose();
             this._waitForFirstSyncHandle = null;
diff --git a/src/matrix/Sync.js b/src/matrix/Sync.js
index e6de7146..ca76e57a 100644
--- a/src/matrix/Sync.js
+++ b/src/matrix/Sync.js
@@ -29,21 +29,6 @@ export const SyncStatus = createEnum(
     "Stopped"
 );
 
-function parseRooms(roomsSection, roomCallback) {
-    if (roomsSection) {
-        const allMemberships = ["join", "invite", "leave"];
-        for(const membership of allMemberships) {
-            const membershipSection = roomsSection[membership];
-            if (membershipSection) {
-                return Object.entries(membershipSection).map(([roomId, roomResponse]) => {
-                    return roomCallback(roomId, roomResponse, membership);
-                });
-            }
-        }
-    }
-    return [];
-}
-
 function timelineIsEmpty(roomResponse) {
     try {
         const events = roomResponse?.timeline?.events;
@@ -53,6 +38,26 @@ function timelineIsEmpty(roomResponse) {
     }
 }
 
+/**
+ * Sync steps in js-pseudocode:
+ * ```js
+ * let preparation;
+ * if (room.needsPrepareSync) {
+ *     // can only read some stores
+ *     preparation = await room.prepareSync(roomResponse, prepareTxn);
+ *     // can do async work that is not related to storage (such as decryption)
+ *     preparation = await room.afterPrepareSync(preparation);
+ * }
+ * // writes and calculates changes
+ * const changes = await room.writeSync(roomResponse, membership, isInitialSync, preparation, syncTxn);
+ * // applies and emits changes once syncTxn is committed
+ * room.afterSync(changes);
+ * if (room.needsAfterSyncCompleted(changes)) {
+ *     // can do network requests
+ *     await room.afterSyncCompleted(changes);
+ * }
+ * ```
+ */
 export class Sync {
     constructor({hsApi, session, storage}) {
         this._hsApi = hsApi;
@@ -87,12 +92,16 @@ export class Sync {
     }
 
     async _syncLoop(syncToken) {
+        let afterSyncCompletedPromise = Promise.resolve();
         // if syncToken is falsy, it will first do an initial sync ... 
         while(this._status.get() !== SyncStatus.Stopped) {
+            let roomStates;
             try {
                 console.log(`starting sync request with since ${syncToken} ...`);
                 const timeout = syncToken ? INCREMENTAL_TIMEOUT : undefined; 
-                syncToken = await this._syncRequest(syncToken, timeout);
+                const syncResult = await this._syncRequest(syncToken, timeout, afterSyncCompletedPromise);
+                syncToken = syncResult.syncToken;
+                roomStates = syncResult.roomStates;
                 this._status.set(SyncStatus.Syncing);
             } catch (err) {
                 if (!(err instanceof AbortError)) {
@@ -100,10 +109,39 @@ export class Sync {
                     this._status.set(SyncStatus.Stopped);
                 }
             }
+            if (!this._error) {
+                afterSyncCompletedPromise = this._runAfterSyncCompleted(roomStates);
+            }
         }
     }
 
-    async _syncRequest(syncToken, timeout) {
+    async _runAfterSyncCompleted(roomStates) {
+        const sessionPromise = (async () => {
+            try {
+                await this._session.afterSyncCompleted();
+            } catch (err) {
+                console.error("error during session afterSyncCompleted, continuing",  err.stack);
+            }
+        })();
+
+        const roomsNeedingAfterSyncCompleted = roomStates.filter(rs => {
+            return rs.room.needsAfterSyncCompleted(rs.changes);
+        });
+        const roomsPromises = roomsNeedingAfterSyncCompleted.map(async rs => {
+            try {
+                await rs.room.afterSyncCompleted(rs.changes);
+            } catch (err) {
+                console.error(`error during room ${rs.room.id} afterSyncCompleted, continuing`,  err.stack);
+            }
+        });
+        // run everything in parallel,
+        // we don't want to delay the next sync too much
+        // Also, since all promises won't reject (as they have a try/catch)
+        // it's fine to use Promise.all
+        await Promise.all(roomsPromises.concat(sessionPromise));
+    }
+
+    async _syncRequest(syncToken, timeout, prevAfterSyncCompletedPromise) {
         let {syncFilterId} = this._session;
         if (typeof syncFilterId !== "string") {
             this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}});
@@ -112,41 +150,23 @@ export class Sync {
         const totalRequestTimeout = timeout + (80 * 1000);  // same as riot-web, don't get stuck on wedged long requests
         this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout, {timeout: totalRequestTimeout});
         const response = await this._currentRequest.response();
+        // wait here for the afterSyncCompleted step of the previous sync to complete
+        // before we continue processing this sync response
+        await prevAfterSyncCompletedPromise;
+
         const isInitialSync = !syncToken;
         syncToken = response.next_batch;
-        const storeNames = this._storage.storeNames;
-        const syncTxn = await this._storage.readWriteTxn([
-            storeNames.session,
-            storeNames.roomSummary,
-            storeNames.roomState,
-            storeNames.roomMembers,
-            storeNames.timelineEvents,
-            storeNames.timelineFragments,
-            storeNames.pendingEvents,
-        ]);
-        const roomChanges = [];
+        const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync);
+        await this._prepareRooms(roomStates);
         let sessionChanges;
+        const syncTxn = await this._openSyncTxn();
         try {
-            sessionChanges = this._session.writeSync(syncToken, syncFilterId, response.account_data,  syncTxn);
-            // to_device
-            // presence
-            if (response.rooms) {
-                const promises = parseRooms(response.rooms, async (roomId, roomResponse, membership) => {
-                    // ignore rooms with empty timelines during initial sync,
-                    // see https://github.com/vector-im/hydrogen-web/issues/15
-                    if (isInitialSync && timelineIsEmpty(roomResponse)) {
-                        return;
-                    }
-                    let room = this._session.rooms.get(roomId);
-                    if (!room) {
-                        room = this._session.createRoom(roomId);
-                    }
-                    console.log(` * applying sync response to room ${roomId} ...`);
-                    const changes = await room.writeSync(roomResponse, membership, isInitialSync, syncTxn);
-                    roomChanges.push({room, changes});
-                });
-                await Promise.all(promises);
-            }
+            await Promise.all(roomStates.map(async rs => {
+                console.log(` * applying sync response to room ${rs.room.id} ...`);
+                rs.changes = await rs.room.writeSync(
+                    rs.roomResponse, rs.membership, isInitialSync, rs.preparation, syncTxn);
+            }));
+            sessionChanges = await this._session.writeSync(response, syncFilterId, syncTxn);
         } catch(err) {
             console.warn("aborting syncTxn because of error");
             console.error(err);
@@ -165,13 +185,80 @@ export class Sync {
         }
         this._session.afterSync(sessionChanges);
         // emit room related events after txn has been closed
-        for(let {room, changes} of roomChanges) {
-            room.afterSync(changes);
+        for(let rs of roomStates) {
+            rs.room.afterSync(rs.changes);
         }
 
-        return syncToken;
+        return {syncToken, roomStates};
     }
 
+    async _openPrepareSyncTxn() {
+        const storeNames = this._storage.storeNames;
+        return await this._storage.readTxn([
+            storeNames.inboundGroupSessions,
+        ]);
+    }
+
+    async _prepareRooms(roomStates) {
+        const prepareRoomStates = roomStates.filter(rs => rs.room.needsPrepareSync);
+        if (prepareRoomStates.length) {
+            const prepareTxn = await this._openPrepareSyncTxn();
+            await Promise.all(prepareRoomStates.map(async rs => {
+                rs.preparation = await rs.room.prepareSync(rs.roomResponse, prepareTxn);
+            }));
+            await Promise.all(prepareRoomStates.map(async rs => {
+                rs.preparation = await rs.room.afterPrepareSync(rs.preparation);
+            }));
+        }
+    }
+
+    async _openSyncTxn() {
+        const storeNames = this._storage.storeNames;
+        return await this._storage.readWriteTxn([
+            storeNames.session,
+            storeNames.roomSummary,
+            storeNames.roomState,
+            storeNames.roomMembers,
+            storeNames.timelineEvents,
+            storeNames.timelineFragments,
+            storeNames.pendingEvents,
+            storeNames.userIdentities,
+            storeNames.groupSessionDecryptions,
+            storeNames.deviceIdentities,
+            // to discard outbound session when somebody leaves a room
+            // and to create room key messages when somebody leaves
+            storeNames.outboundGroupSessions,
+            storeNames.operations
+        ]);
+    }
+    
+    _parseRoomsResponse(roomsSection, isInitialSync) {
+        const roomStates = [];
+        if (roomsSection) {
+            // don't do "invite", "leave" for now
+            const allMemberships = ["join"];
+            for(const membership of allMemberships) {
+                const membershipSection = roomsSection[membership];
+                if (membershipSection) {
+                    for (const [roomId, roomResponse] of Object.entries(membershipSection)) {
+                        // ignore rooms with empty timelines during initial sync,
+                        // see https://github.com/vector-im/hydrogen-web/issues/15
+                        if (isInitialSync && timelineIsEmpty(roomResponse)) {
+                            return;
+                        }
+                        let room = this._session.rooms.get(roomId);
+                        if (!room) {
+                            room = this._session.createRoom(roomId);
+                        }
+                        roomStates.push(new RoomSyncProcessState(room, roomResponse, membership));
+                    }
+                }
+            }
+        }
+        return roomStates;
+    }
+
+
     stop() {
         if (this._status.get() === SyncStatus.Stopped) {
             return;
@@ -183,3 +270,13 @@ export class Sync {
         }
     }
 }
+
+class RoomSyncProcessState {
+    constructor(room, roomResponse, membership) {
+        this.room = room;
+        this.roomResponse = roomResponse;
+        this.membership = membership;
+        this.preparation = null;
+        this.changes = null;
+    }
+}
diff --git a/src/matrix/common.js b/src/matrix/common.js
new file mode 100644
index 00000000..3c893234
--- /dev/null
+++ b/src/matrix/common.js
@@ -0,0 +1,22 @@
+/*
+Copyright 2020 Bruno Windels 
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export function makeTxnId() {
+    const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
+    const str = n.toString(16);
+    return "t" + "0".repeat(14 - str.length) + str;
+}
\ No newline at end of file
diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js
new file mode 100644
index 00000000..37fab7d2
--- /dev/null
+++ b/src/matrix/e2ee/Account.js
@@ -0,0 +1,242 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 anotherjson from "../../../lib/another-json/index.js";
+import {SESSION_KEY_PREFIX, OLM_ALGORITHM, MEGOLM_ALGORITHM} from "./common.js";
+
+// use common prefix so it's easy to clear properties that are not e2ee related during session clear
+const ACCOUNT_SESSION_KEY = SESSION_KEY_PREFIX + "olmAccount";
+const DEVICE_KEY_FLAG_SESSION_KEY = SESSION_KEY_PREFIX + "areDeviceKeysUploaded";
+const SERVER_OTK_COUNT_SESSION_KEY = SESSION_KEY_PREFIX + "serverOTKCount";
+
+export class Account {
+    static async load({olm, pickleKey, hsApi, userId, deviceId, olmWorker, txn}) {
+        const pickledAccount = await txn.session.get(ACCOUNT_SESSION_KEY);
+        if (pickledAccount) {
+            const account = new olm.Account();
+            const areDeviceKeysUploaded = await txn.session.get(DEVICE_KEY_FLAG_SESSION_KEY);
+            account.unpickle(pickleKey, pickledAccount);
+            const serverOTKCount = await txn.session.get(SERVER_OTK_COUNT_SESSION_KEY);
+            return new Account({pickleKey, hsApi, account, userId,
+                deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker});
+        }
+    }
+
+    static async create({olm, pickleKey, hsApi, userId, deviceId, olmWorker, storage}) {
+        const account = new olm.Account();
+        if (olmWorker) {
+            await olmWorker.createAccountAndOTKs(account, account.max_number_of_one_time_keys());
+        } else {
+            account.create();
+            account.generate_one_time_keys(account.max_number_of_one_time_keys());
+        }
+        const pickledAccount = account.pickle(pickleKey);
+        const areDeviceKeysUploaded = false;
+        const txn = await storage.readWriteTxn([
+            storage.storeNames.session
+        ]);
+        try {
+            // add will throw if the key already exists
+            // we would not want to overwrite olmAccount here
+            txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount);
+            txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded);
+            txn.session.add(SERVER_OTK_COUNT_SESSION_KEY, 0);
+        } catch (err) {
+            txn.abort();
+            throw err;
+        }
+        await txn.complete();
+        return new Account({pickleKey, hsApi, account, userId,
+            deviceId, areDeviceKeysUploaded, serverOTKCount: 0, olm, olmWorker});
+    }
+
+    constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded, serverOTKCount, olm, olmWorker}) {
+        this._olm = olm;
+        this._pickleKey = pickleKey;
+        this._hsApi = hsApi;
+        this._account = account;
+        this._userId = userId;
+        this._deviceId = deviceId;
+        this._areDeviceKeysUploaded = areDeviceKeysUploaded;
+        this._serverOTKCount = serverOTKCount;
+        this._olmWorker = olmWorker;
+        this._identityKeys = JSON.parse(this._account.identity_keys());
+    }
+
+    get identityKeys() {
+        return this._identityKeys;
+    }
+
+    async uploadKeys(storage) {
+        const oneTimeKeys = JSON.parse(this._account.one_time_keys());
+        // only one algorithm supported by olm atm, so hardcode its name
+        const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
+        if (oneTimeKeysEntries.length || !this._areDeviceKeysUploaded) {
+            const payload = {};
+            if (!this._areDeviceKeysUploaded) {
+                const identityKeys = JSON.parse(this._account.identity_keys());
+                payload.device_keys = this._deviceKeysPayload(identityKeys);
+            }
+            if (oneTimeKeysEntries.length) {
+                payload.one_time_keys = this._oneTimeKeysPayload(oneTimeKeysEntries);
+            }
+            const response = await this._hsApi.uploadKeys(payload).response();
+            this._serverOTKCount = response?.one_time_key_counts?.signed_curve25519;
+            // TODO: should we not modify this in the txn like we do elsewhere?
+            // we'd have to pickle and unpickle the account to clone it though ...
+            // and the upload has succeed at this point, so in-memory would be correct
+            // but in-storage not if the txn fails. 
+            await this._updateSessionStorage(storage, sessionStore => {
+                if (oneTimeKeysEntries.length) {
+                    this._account.mark_keys_as_published();
+                    sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
+                    sessionStore.set(SERVER_OTK_COUNT_SESSION_KEY, this._serverOTKCount);
+                }
+                if (!this._areDeviceKeysUploaded) {
+                    this._areDeviceKeysUploaded = true;
+                    sessionStore.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded);
+                }
+            });
+        }
+    }
+
+    async generateOTKsIfNeeded(storage) {
+        const maxOTKs = this._account.max_number_of_one_time_keys();
+        const limit = maxOTKs / 2;
+        if (this._serverOTKCount < limit) {
+            // TODO: cache unpublishedOTKCount, so we don't have to parse this JSON on every sync iteration
+            // for now, we only determine it when serverOTKCount is sufficiently low, which is should rarely be,
+            // and recheck
+            const oneTimeKeys = JSON.parse(this._account.one_time_keys());
+            const oneTimeKeysEntries = Object.entries(oneTimeKeys.curve25519);
+            const unpublishedOTKCount = oneTimeKeysEntries.length;
+            const totalOTKCount = this._serverOTKCount + unpublishedOTKCount;
+            if (totalOTKCount < limit) {
+                // we could in theory also generated the keys and store them in
+                // writeSync, but then we would have to clone the account to avoid side-effects.
+                await this._updateSessionStorage(storage, sessionStore => {
+                    const newKeyCount = maxOTKs - totalOTKCount;
+                    this._account.generate_one_time_keys(newKeyCount);
+                    sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
+                });
+                return true;
+            }
+        }
+        return false;
+    }
+
+    createInboundOlmSession(senderKey, body) {
+        const newSession = new this._olm.Session();
+        try {
+            newSession.create_inbound_from(this._account, senderKey, body);
+            return newSession;
+        } catch (err) {
+            newSession.free();
+            throw err;
+        }
+    }
+
+    createOutboundOlmSession(theirIdentityKey, theirOneTimeKey) {
+        const newSession = new this._olm.Session();
+        try {
+            newSession.create_outbound(this._account, theirIdentityKey, theirOneTimeKey);
+            return newSession;
+        } catch (err) {
+            newSession.free();
+            throw err;
+        }
+    }
+
+    writeRemoveOneTimeKey(session, txn) {
+        // this is side-effecty and will have applied the change if the txn fails,
+        // but don't want to clone the account for now
+        // and it is not the worst thing to think we have used a OTK when
+        // decrypting the message that actually used it threw for some reason.
+        this._account.remove_one_time_keys(session);
+        txn.session.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey));
+    }
+
+    writeSync(deviceOneTimeKeysCount, txn) {
+        // we only upload signed_curve25519 otks
+        const otkCount = deviceOneTimeKeysCount.signed_curve25519;
+        if (Number.isSafeInteger(otkCount) && otkCount !== this._serverOTKCount) {
+            txn.session.set(SERVER_OTK_COUNT_SESSION_KEY, otkCount);
+            return otkCount;
+        }
+    }
+
+    afterSync(otkCount) {
+        // could also be undefined
+        if (Number.isSafeInteger(otkCount)) {
+            this._serverOTKCount = otkCount;
+        }
+    }
+
+    _deviceKeysPayload(identityKeys) {
+        const obj = {
+            user_id: this._userId,
+            device_id: this._deviceId,
+            algorithms: [OLM_ALGORITHM, MEGOLM_ALGORITHM],
+            keys: {}
+        };
+        for (const [algorithm, pubKey] of Object.entries(identityKeys)) {
+            obj.keys[`${algorithm}:${this._deviceId}`] = pubKey;
+        }
+        this.signObject(obj);
+        return obj;
+    }
+
+    _oneTimeKeysPayload(oneTimeKeysEntries) {
+        const obj = {};
+        for (const [keyId, pubKey] of oneTimeKeysEntries) {
+            const keyObj = {
+                key: pubKey  
+            };
+            this.signObject(keyObj);
+            obj[`signed_curve25519:${keyId}`] = keyObj;
+        }
+        return obj;
+    }
+
+    async _updateSessionStorage(storage, callback) {
+        const txn = await storage.readWriteTxn([
+            storage.storeNames.session
+        ]);
+        try {
+            await callback(txn.session);
+        } catch (err) {
+            txn.abort();
+            throw err;
+        }
+        await txn.complete();
+    }
+
+    signObject(obj) {
+        const sigs = obj.signatures || {};
+        const unsigned = obj.unsigned;
+
+        delete obj.signatures;
+        delete obj.unsigned;
+
+        sigs[this._userId] = sigs[this._userId] || {};
+        sigs[this._userId]["ed25519:" + this._deviceId] = 
+            this._account.sign(anotherjson.stringify(obj));
+        obj.signatures = sigs;
+        if (unsigned !== undefined) {
+            obj.unsigned = unsigned;
+        }
+    }
+}
diff --git a/src/matrix/e2ee/DecryptionResult.js b/src/matrix/e2ee/DecryptionResult.js
new file mode 100644
index 00000000..c109e689
--- /dev/null
+++ b/src/matrix/e2ee/DecryptionResult.js
@@ -0,0 +1,70 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+
+/**
+ * @property {object} event the plaintext event (type and content property)
+ * @property {string} senderCurve25519Key the curve25519 sender key of the olm event
+ * @property {string} claimedEd25519Key The ed25519 fingerprint key retrieved from the decryption payload.
+ *                                      The sender of the olm event claims this is the ed25519 fingerprint key
+ *                                      that matches the curve25519 sender key.
+ *                                      The caller needs to check if this key does indeed match the senderKey
+ *                                      for a device with a valid signature returned from /keys/query,
+ *                                      see DeviceTracker
+ */
+
+
+
+export class DecryptionResult {
+    constructor(event, senderCurve25519Key, claimedKeys) {
+        this.event = event;
+        this.senderCurve25519Key = senderCurve25519Key;
+        this.claimedEd25519Key = claimedKeys.ed25519;
+        this._device = null;
+        this._roomTracked = true;
+    }
+
+    setDevice(device) {
+        this._device = device;
+    }
+
+    setRoomNotTrackedYet() {
+        this._roomTracked = false;
+    }
+
+    get isVerified() {
+        if (this._device) {
+            const comesFromDevice = this._device.ed25519Key === this.claimedEd25519Key;
+            return comesFromDevice;
+        }
+        return false;
+    }
+
+    get isUnverified() {
+        if (this._device) {
+            return !this.isVerified;
+        } else if (this.isVerificationUnknown) {
+            return false;
+        } else {
+            return true;
+        }
+    }
+
+    get isVerificationUnknown() {
+        // verification is unknown if we haven't yet fetched the devices for the room
+        return !this._device && !this._roomTracked;
+    }
+}
diff --git a/src/matrix/e2ee/DeviceTracker.js b/src/matrix/e2ee/DeviceTracker.js
new file mode 100644
index 00000000..aef62e10
--- /dev/null
+++ b/src/matrix/e2ee/DeviceTracker.js
@@ -0,0 +1,301 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {verifyEd25519Signature, SIGNATURE_ALGORITHM} from "./common.js";
+
+const TRACKING_STATUS_OUTDATED = 0;
+const TRACKING_STATUS_UPTODATE = 1;
+
+// map 1 device from /keys/query response to DeviceIdentity
+function deviceKeysAsDeviceIdentity(deviceSection) {
+    const deviceId = deviceSection["device_id"];
+    const userId = deviceSection["user_id"];
+    return {
+        userId,
+        deviceId,
+        ed25519Key: deviceSection.keys[`ed25519:${deviceId}`],
+        curve25519Key: deviceSection.keys[`curve25519:${deviceId}`],
+        algorithms: deviceSection.algorithms,
+        displayName: deviceSection.unsigned?.device_display_name,
+    };
+}
+
+export class DeviceTracker {
+    constructor({storage, getSyncToken, olmUtil, ownUserId, ownDeviceId}) {
+        this._storage = storage;
+        this._getSyncToken = getSyncToken;
+        this._identityChangedForRoom = null;
+        this._olmUtil = olmUtil;
+        this._ownUserId = ownUserId;
+        this._ownDeviceId = ownDeviceId;
+    }
+
+    async writeDeviceChanges(deviceLists, txn) {
+        const {userIdentities} = txn;
+        if (Array.isArray(deviceLists.changed) && deviceLists.changed.length) {
+            await Promise.all(deviceLists.changed.map(async userId => {
+                const user = await userIdentities.get(userId);
+                if (user) {
+                    user.deviceTrackingStatus = TRACKING_STATUS_OUTDATED;
+                    userIdentities.set(user);
+                }
+            }));
+        }
+    }
+
+    writeMemberChanges(room, memberChanges, txn) {
+        return Promise.all(Array.from(memberChanges.values()).map(async memberChange => {
+            return this._applyMemberChange(memberChange, txn);
+        }));
+    }
+
+    async trackRoom(room) {
+        if (room.isTrackingMembers || !room.isEncrypted) {
+            return;
+        }
+        const memberList = await room.loadMemberList();
+        try {
+            const txn = await this._storage.readWriteTxn([
+                this._storage.storeNames.roomSummary,
+                this._storage.storeNames.userIdentities,
+            ]);
+            let isTrackingChanges;
+            try {
+                isTrackingChanges = room.writeIsTrackingMembers(true, txn);
+                const members = Array.from(memberList.members.values());
+                await this._writeJoinedMembers(members, txn);
+            } catch (err) {
+                txn.abort();
+                throw err;
+            }
+            await txn.complete();
+            room.applyIsTrackingMembersChanges(isTrackingChanges);
+        } finally {
+            memberList.release();
+        }
+    }
+
+    async _writeJoinedMembers(members, txn) {
+        await Promise.all(members.map(async member => {
+            if (member.membership === "join") {
+                await this._writeMember(member, txn);
+            }
+        }));
+    }
+
+    async _writeMember(member, txn) {
+        const {userIdentities} = txn;
+        const identity = await userIdentities.get(member.userId);
+        if (!identity) {
+            userIdentities.set({
+                userId: member.userId,
+                roomIds: [member.roomId],
+                deviceTrackingStatus: TRACKING_STATUS_OUTDATED,
+            });
+        } else {
+            if (!identity.roomIds.includes(member.roomId)) {
+                identity.roomIds.push(member.roomId);
+                userIdentities.set(identity);
+            }
+        }
+    }
+
+    async _applyMemberChange(memberChange, txn) {
+        // TODO: depends whether we encrypt for invited users??
+        // add room
+        if (memberChange.previousMembership !== "join" && memberChange.membership === "join") {
+            await this._writeMember(memberChange.member, txn);
+        }
+        // remove room
+        else if (memberChange.previousMembership === "join" && memberChange.membership !== "join") {
+            const {userIdentities} = txn;
+            const identity = await userIdentities.get(memberChange.userId);
+            if (identity) {
+                identity.roomIds = identity.roomIds.filter(roomId => roomId !== memberChange.roomId);
+                // no more encrypted rooms with this user, remove
+                if (identity.roomIds.length === 0) {
+                    userIdentities.remove(identity.userId);
+                } else {
+                    userIdentities.set(identity);
+                }
+            }
+        }
+    }
+
+    async _queryKeys(userIds, hsApi) {
+        // TODO: we need to handle the race here between /sync and /keys/query just like we need to do for the member list ...
+        // there are multiple requests going out for /keys/query though and only one for /members
+
+        const deviceKeyResponse = await hsApi.queryKeys({
+            "timeout": 10000,
+            "device_keys": userIds.reduce((deviceKeysMap, userId) => {
+                deviceKeysMap[userId] = [];
+                return deviceKeysMap;
+            }, {}),
+            "token": this._getSyncToken()
+        }).response();
+
+        const verifiedKeysPerUser = this._filterVerifiedDeviceKeys(deviceKeyResponse["device_keys"]);
+        const flattenedVerifiedKeysPerUser = verifiedKeysPerUser.reduce((all, {verifiedKeys}) => all.concat(verifiedKeys), []);
+        const deviceIdentitiesWithPossibleChangedKeys = flattenedVerifiedKeysPerUser.map(deviceKeysAsDeviceIdentity);
+
+        const txn = await this._storage.readWriteTxn([
+            this._storage.storeNames.userIdentities,
+            this._storage.storeNames.deviceIdentities,
+        ]);
+        let deviceIdentities;
+        try {
+            // check ed25519 key has not changed if we've seen the device before
+            deviceIdentities = await Promise.all(deviceIdentitiesWithPossibleChangedKeys.map(async (deviceIdentity) => {
+                const existingDevice = await txn.deviceIdentities.get(deviceIdentity.userId, deviceIdentity.deviceId);
+                if (!existingDevice || existingDevice.ed25519Key === deviceIdentity.ed25519Key) {
+                    return deviceIdentity;
+                }
+                // ignore devices where the keys have changed
+                return null;
+            }));
+            // filter out nulls
+            deviceIdentities = deviceIdentities.filter(di => !!di);
+            // store devices
+            for (const deviceIdentity of deviceIdentities) {
+                txn.deviceIdentities.set(deviceIdentity);
+            }
+            // mark user identities as up to date
+            await Promise.all(verifiedKeysPerUser.map(async ({userId}) => {
+                const identity = await txn.userIdentities.get(userId);
+                identity.deviceTrackingStatus = TRACKING_STATUS_UPTODATE;
+                txn.userIdentities.set(identity);
+            }));
+        } catch (err) {
+            txn.abort();
+            throw err;
+        }
+        await txn.complete();
+        return deviceIdentities;
+    }
+
+    _filterVerifiedDeviceKeys(keyQueryDeviceKeysResponse) {
+        const curve25519Keys = new Set();
+        const verifiedKeys = Object.entries(keyQueryDeviceKeysResponse).map(([userId, keysByDevice]) => {
+            const verifiedEntries = Object.entries(keysByDevice).filter(([deviceId, deviceKeys]) => {
+                const deviceIdOnKeys = deviceKeys["device_id"];
+                const userIdOnKeys = deviceKeys["user_id"];
+                if (userIdOnKeys !== userId) {
+                    return false;
+                }
+                if (deviceIdOnKeys !== deviceId) {
+                    return false;
+                }
+                const ed25519Key = deviceKeys.keys?.[`ed25519:${deviceId}`];
+                const curve25519Key = deviceKeys.keys?.[`curve25519:${deviceId}`];
+                if (typeof ed25519Key !== "string" || typeof curve25519Key !== "string") {
+                    return false;
+                }
+                if (curve25519Keys.has(curve25519Key)) {
+                    console.warn("ignoring device with duplicate curve25519 key in /keys/query response", deviceKeys);
+                    return false;
+                }
+                curve25519Keys.add(curve25519Key);
+                return this._hasValidSignature(deviceKeys);
+            });
+            const verifiedKeys = verifiedEntries.map(([, deviceKeys]) => deviceKeys);
+            return {userId, verifiedKeys};
+        });
+        return verifiedKeys;
+    }
+
+    _hasValidSignature(deviceSection) {
+        const deviceId = deviceSection["device_id"];
+        const userId = deviceSection["user_id"];
+        const ed25519Key = deviceSection?.keys?.[`${SIGNATURE_ALGORITHM}:${deviceId}`];
+        return verifyEd25519Signature(this._olmUtil, userId, deviceId, ed25519Key, deviceSection);
+    }
+
+    /**
+     * Gives all the device identities for a room that is already tracked.
+     * Assumes room is already tracked. Call `trackRoom` first if unsure.
+     * @param  {String} roomId [description]
+     * @return {[type]}        [description]
+     */
+    async devicesForTrackedRoom(roomId, hsApi) {
+        const txn = await this._storage.readTxn([
+            this._storage.storeNames.roomMembers,
+            this._storage.storeNames.userIdentities,
+        ]);
+
+        // because we don't have multiEntry support in IE11, we get a set of userIds that is pretty close to what we
+        // need as a good first filter (given that non-join memberships will be in there). After fetching the identities,
+        // we check which ones have the roomId for the room we're looking at.
+        
+        // So, this will also contain non-joined memberships
+        const userIds = await txn.roomMembers.getAllUserIds(roomId);
+
+        return await this._devicesForUserIds(roomId, userIds, txn, hsApi);
+    }
+
+    async devicesForRoomMembers(roomId, userIds, hsApi) {
+        const txn = await this._storage.readTxn([
+            this._storage.storeNames.userIdentities,
+        ]);
+        return await this._devicesForUserIds(roomId, userIds, txn, hsApi);
+    }
+
+    /**
+     * @param  {string} roomId  [description]
+     * @param  {Array} userIds a set of user ids to try and find the identity for. Will be check to belong to roomId.
+     * @param  {Transaction} userIdentityTxn to read the user identities
+     * @param  {HomeServerApi} hsApi
+     * @return {Array}
+     */
+    async _devicesForUserIds(roomId, userIds, userIdentityTxn, hsApi) {
+        const allMemberIdentities = await Promise.all(userIds.map(userId => userIdentityTxn.userIdentities.get(userId)));
+        const identities = allMemberIdentities.filter(identity => {
+            // identity will be missing for any userIds that don't have 
+            // membership join in any of your encrypted rooms
+            return identity && identity.roomIds.includes(roomId);
+        });
+        const upToDateIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_UPTODATE);
+        const outdatedIdentities = identities.filter(i => i.deviceTrackingStatus === TRACKING_STATUS_OUTDATED);
+        let queriedDevices;
+        if (outdatedIdentities.length) {
+            // TODO: ignore the race between /sync and /keys/query for now,
+            // where users could get marked as outdated or added/removed from the room while
+            // querying keys
+            queriedDevices = await this._queryKeys(outdatedIdentities.map(i => i.userId), hsApi);
+        }
+
+        const deviceTxn = await this._storage.readTxn([
+            this._storage.storeNames.deviceIdentities,
+        ]);
+        const devicesPerUser = await Promise.all(upToDateIdentities.map(identity => {
+            return deviceTxn.deviceIdentities.getAllForUserId(identity.userId);
+        }));
+        let flattenedDevices = devicesPerUser.reduce((all, devicesForUser) => all.concat(devicesForUser), []);
+        if (queriedDevices && queriedDevices.length) {
+            flattenedDevices = flattenedDevices.concat(queriedDevices);
+        }
+        // filter out our own device
+        const devices = flattenedDevices.filter(device => {
+            const isOwnDevice = device.userId === this._ownUserId && device.deviceId === this._ownDeviceId;
+            return !isOwnDevice;
+        });
+        return devices;
+    }
+
+    async getDeviceByCurve25519Key(curve25519Key, txn) {
+        return await txn.deviceIdentities.getByCurve25519Key(curve25519Key);
+    }
+}
diff --git a/src/matrix/e2ee/OlmWorker.js b/src/matrix/e2ee/OlmWorker.js
new file mode 100644
index 00000000..a6edd3cc
--- /dev/null
+++ b/src/matrix/e2ee/OlmWorker.js
@@ -0,0 +1,43 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export class OlmWorker {
+    constructor(workerPool) {
+        this._workerPool = workerPool;
+    }
+
+    megolmDecrypt(session, ciphertext) {
+        const sessionKey = session.export_session(session.first_known_index());
+        return this._workerPool.send({type: "megolm_decrypt", ciphertext, sessionKey});
+    }
+
+    async createAccountAndOTKs(account, otkAmount) {
+        // IE11 does not support getRandomValues in a worker, so we have to generate the values upfront.
+        let randomValues;
+        if (window.msCrypto) {
+            randomValues = [
+                window.msCrypto.getRandomValues(new Uint8Array(64)),
+                window.msCrypto.getRandomValues(new Uint8Array(otkAmount * 32)),
+            ];
+        }
+        const pickle = await this._workerPool.send({type: "olm_create_account_otks", randomValues, otkAmount}).response();
+        account.unpickle("", pickle);
+    }
+
+    dispose() {
+        this._workerPool.dispose();
+    }
+}
diff --git a/src/matrix/e2ee/README.md b/src/matrix/e2ee/README.md
new file mode 100644
index 00000000..46f4e95f
--- /dev/null
+++ b/src/matrix/e2ee/README.md
@@ -0,0 +1,44 @@
+## Integratation within the sync lifetime cycle
+
+### prepareSync
+    
+    The session can start its own read/write transactions here, rooms only read from a shared transaction
+
+    - session
+        - device handler
+            - txn
+                - write pending encrypted
+            - txn
+                - olm decryption read
+            - olm async decryption
+                - dispatch to worker
+            - txn
+                - olm decryption write / remove pending encrypted
+    - rooms (with shared read txn)
+        - megolm decryption read
+
+### afterPrepareSync
+
+    - rooms    
+        - megolm async decryption   
+            - dispatch to worker
+
+### writeSync
+
+    - rooms (with shared readwrite txn)
+        - megolm decryption write, yielding decrypted events
+        - use decrypted events to write room summary
+
+### afterSync
+
+    - rooms
+        - emit changes
+
+### afterSyncCompleted
+
+    - session
+        - e2ee account
+            - generate more otks if needed
+            - upload new otks if needed or device keys if not uploaded before
+    - rooms
+        - share new room keys if needed
diff --git a/src/matrix/e2ee/RoomEncryption.js b/src/matrix/e2ee/RoomEncryption.js
new file mode 100644
index 00000000..b5b56ec2
--- /dev/null
+++ b/src/matrix/e2ee/RoomEncryption.js
@@ -0,0 +1,328 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {MEGOLM_ALGORITHM, DecryptionSource} from "./common.js";
+import {groupBy} from "../../utils/groupBy.js";
+import {mergeMap} from "../../utils/mergeMap.js";
+import {makeTxnId} from "../common.js";
+
+const ENCRYPTED_TYPE = "m.room.encrypted";
+
+export class RoomEncryption {
+    constructor({room, deviceTracker, olmEncryption, megolmEncryption, megolmDecryption, encryptionParams, storage}) {
+        this._room = room;
+        this._deviceTracker = deviceTracker;
+        this._olmEncryption = olmEncryption;
+        this._megolmEncryption = megolmEncryption;
+        this._megolmDecryption = megolmDecryption;
+        // content of the m.room.encryption event
+        this._encryptionParams = encryptionParams;
+
+        this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
+        this._megolmSyncCache = this._megolmDecryption.createSessionCache();
+        // not `event_id`, but an internal event id passed in to the decrypt methods
+        this._eventIdsByMissingSession = new Map();
+        this._senderDeviceCache = new Map();
+        this._storage = storage;
+    }
+
+    notifyTimelineClosed() {
+        // empty the backfill cache when closing the timeline
+        this._megolmBackfillCache.dispose();
+        this._megolmBackfillCache = this._megolmDecryption.createSessionCache();
+        this._senderDeviceCache = new Map();    // purge the sender device cache
+    }
+
+    async writeMemberChanges(memberChanges, txn) {
+        const memberChangesArray = Array.from(memberChanges.values());
+        if (memberChangesArray.some(m => m.hasLeft)) {
+            this._megolmEncryption.discardOutboundSession(this._room.id, txn);
+        }
+        if (memberChangesArray.some(m => m.hasJoined)) {
+            await this._addShareRoomKeyOperationForNewMembers(memberChangesArray, txn);
+        }
+        await this._deviceTracker.writeMemberChanges(this._room, memberChanges, txn);
+    }
+
+    // this happens before entries exists, as they are created by the syncwriter
+    // but we want to be able to map it back to something in the timeline easily
+    // when retrying decryption.
+    async prepareDecryptAll(events, source, isTimelineOpen, txn) {
+        const errors = [];
+        const validEvents = [];
+        for (const event of events) {
+            if (event.redacted_because || event.unsigned?.redacted_because) {
+                continue;
+            }
+            if (event.content?.algorithm !== MEGOLM_ALGORITHM) {
+                errors.set(event.event_id, new Error("Unsupported algorithm: " + event.content?.algorithm));
+            }
+            validEvents.push(event);
+        }
+        let customCache;
+        let sessionCache;
+        if (source === DecryptionSource.Sync) {
+            sessionCache = this._megolmSyncCache;
+        } else if (source === DecryptionSource.Timeline) {
+            sessionCache = this._megolmBackfillCache;
+        } else if (source === DecryptionSource.Retry) {
+            // when retrying, we could have mixed events from at the bottom of the timeline (sync)
+            // and somewhere else, so create a custom cache we use just for this operation.
+            customCache = this._megolmEncryption.createSessionCache();
+            sessionCache = customCache;
+        } else {
+            throw new Error("Unknown source: " + source);
+        }
+        const preparation = await this._megolmDecryption.prepareDecryptAll(
+            this._room.id, validEvents, sessionCache, txn);
+        if (customCache) {
+            customCache.dispose();
+        }
+        return new DecryptionPreparation(preparation, errors, {isTimelineOpen}, this);
+    }
+
+    async _processDecryptionResults(results, errors, flags, txn) {
+        for (const error of errors.values()) {
+            if (error.code === "MEGOLM_NO_SESSION") {
+                this._addMissingSessionEvent(error.event);
+            }
+        }
+        if (flags.isTimelineOpen) {
+            for (const result of results.values()) {
+                await this._verifyDecryptionResult(result, txn);
+            }
+        }
+    }
+
+    async _verifyDecryptionResult(result, txn) {
+        let device = this._senderDeviceCache.get(result.senderCurve25519Key);
+        if (!device) {
+            device = await this._deviceTracker.getDeviceByCurve25519Key(result.senderCurve25519Key, txn);
+            this._senderDeviceCache.set(result.senderCurve25519Key, device);
+        }
+        if (device) {
+            result.setDevice(device);
+        } else if (!this._room.isTrackingMembers) {
+            result.setRoomNotTrackedYet();
+        }
+    }
+
+    _addMissingSessionEvent(event) {
+        const senderKey = event.content?.["sender_key"];
+        const sessionId = event.content?.["session_id"];
+        const key = `${senderKey}|${sessionId}`;
+        let eventIds = this._eventIdsByMissingSession.get(key);
+        if (!eventIds) {
+            eventIds = new Set();
+            this._eventIdsByMissingSession.set(key, eventIds);
+        }
+        eventIds.add(event.event_id);
+    }
+
+    applyRoomKeys(roomKeys) {
+        // retry decryption with the new sessions
+        const retryEventIds = [];
+        for (const roomKey of roomKeys) {
+            const key = `${roomKey.senderKey}|${roomKey.sessionId}`;
+            const entriesForSession = this._eventIdsByMissingSession.get(key);
+            if (entriesForSession) {
+                this._eventIdsByMissingSession.delete(key);
+                retryEventIds.push(...entriesForSession);
+            }
+        }
+        return retryEventIds;
+    }
+
+    async encrypt(type, content, hsApi) {
+        await this._deviceTracker.trackRoom(this._room);
+        const megolmResult = await this._megolmEncryption.encrypt(this._room.id, type, content, this._encryptionParams);
+        if (megolmResult.roomKeyMessage) {
+            this._shareNewRoomKey(megolmResult.roomKeyMessage, hsApi);
+        }
+        return {
+            type: ENCRYPTED_TYPE,
+            content: megolmResult.content
+        };
+    }
+
+    needsToShareKeys(memberChanges) {
+        for (const m of memberChanges.values()) {
+            if (m.hasJoined) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    async _shareNewRoomKey(roomKeyMessage, hsApi) {
+        const devices = await this._deviceTracker.devicesForTrackedRoom(this._room.id, hsApi);
+        const userIds = Array.from(devices.reduce((set, device) => set.add(device.userId), new Set()));
+
+        // store operation for room key share, in case we don't finish here
+        const writeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
+        let operationId;
+        try {
+            operationId = this._writeRoomKeyShareOperation(roomKeyMessage, userIds, writeOpTxn);
+        } catch (err) {
+            writeOpTxn.abort();
+            throw err;
+        }
+        await writeOpTxn.complete();
+        // TODO: at this point we have the room key stored, and the rest is sort of optional
+        // it would be nice if we could signal SendQueue that any error from here on is non-fatal and
+        // return the encrypted payload.
+
+        // send the room key
+        await this._sendRoomKey(roomKeyMessage, devices, hsApi);
+
+        // remove the operation
+        const removeOpTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
+        try {
+            removeOpTxn.operations.remove(operationId);
+        } catch (err) {
+            removeOpTxn.abort();
+            throw err;
+        }
+        await removeOpTxn.complete();
+    }
+
+    async _addShareRoomKeyOperationForNewMembers(memberChangesArray, txn) {
+        const userIds = memberChangesArray.filter(m => m.hasJoined).map(m => m.userId);
+        const roomKeyMessage = await this._megolmEncryption.createRoomKeyMessage(
+            this._room.id, txn);
+        if (roomKeyMessage) {
+            this._writeRoomKeyShareOperation(roomKeyMessage, userIds, txn);
+        }
+    }
+
+    _writeRoomKeyShareOperation(roomKeyMessage, userIds, txn) {
+        const id = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString();
+        txn.operations.add({
+            id,
+            type: "share_room_key",
+            scope: this._room.id,
+            userIds,
+            roomKeyMessage,
+        });
+        return id;
+    }
+
+    async flushPendingRoomKeyShares(hsApi, operations = null) {
+        if (!operations) {
+            const txn = await this._storage.readTxn([this._storage.storeNames.operations]);
+            operations = await txn.operations.getAllByTypeAndScope("share_room_key", this._room.id);
+        }
+        for (const operation of operations) {
+            // just to be sure
+            if (operation.type !== "share_room_key") {
+                continue;
+            }
+            const devices = await this._deviceTracker.devicesForRoomMembers(this._room.id, operation.userIds, hsApi);
+            await this._sendRoomKey(operation.roomKeyMessage, devices, hsApi);
+            const removeTxn = await this._storage.readWriteTxn([this._storage.storeNames.operations]);
+            try {
+                removeTxn.operations.remove(operation.id);
+            } catch (err) {
+                removeTxn.abort();
+                throw err;
+            }
+            await removeTxn.complete();
+        }
+    }
+
+    async _sendRoomKey(roomKeyMessage, devices, hsApi) {
+        const messages = await this._olmEncryption.encrypt(
+            "m.room_key", roomKeyMessage, devices, hsApi);
+        await this._sendMessagesToDevices(ENCRYPTED_TYPE, messages, hsApi);
+    }
+
+    async _sendMessagesToDevices(type, messages, hsApi) {
+        const messagesByUser = groupBy(messages, message => message.device.userId);
+        const payload = {
+            messages: Array.from(messagesByUser.entries()).reduce((userMap, [userId, messages]) => {
+                userMap[userId] = messages.reduce((deviceMap, message) => {
+                    deviceMap[message.device.deviceId] = message.content;
+                    return deviceMap;
+                }, {});
+                return userMap;
+            }, {})
+        };
+        const txnId = makeTxnId();
+        await hsApi.sendToDevice(type, payload, txnId).response();
+    }
+}
+
+/**
+ * wrappers around megolm decryption classes to be able to post-process
+ * the decryption results before turning them
+ */
+class DecryptionPreparation {
+    constructor(megolmDecryptionPreparation, extraErrors, flags, roomEncryption) {
+        this._megolmDecryptionPreparation = megolmDecryptionPreparation;
+        this._extraErrors = extraErrors;
+        this._flags = flags;
+        this._roomEncryption = roomEncryption;
+    }
+
+    async decrypt() {
+        return new DecryptionChanges(
+            await this._megolmDecryptionPreparation.decrypt(),
+            this._extraErrors,
+            this._flags,
+            this._roomEncryption);
+    }
+
+    dispose() {
+        this._megolmDecryptionPreparation.dispose();
+    }
+}
+
+class DecryptionChanges {
+    constructor(megolmDecryptionChanges, extraErrors, flags, roomEncryption) {
+        this._megolmDecryptionChanges = megolmDecryptionChanges;
+        this._extraErrors = extraErrors;
+        this._flags = flags;
+        this._roomEncryption = roomEncryption;
+    }
+
+    async write(txn) {
+        const {results, errors} = await this._megolmDecryptionChanges.write(txn);
+        mergeMap(this._extraErrors, errors);
+        await this._roomEncryption._processDecryptionResults(results, errors, this._flags, txn);
+        return new BatchDecryptionResult(results, errors);
+    }
+}
+
+class BatchDecryptionResult {
+    constructor(results, errors) {
+        this.results = results;
+        this.errors = errors;
+    }
+
+    applyToEntries(entries) {
+        for (const entry of entries) {
+            const result = this.results.get(entry.id);
+            if (result) {
+                entry.setDecryptionResult(result);
+            } else {
+                const error = this.errors.get(entry.id);
+                if (error) {
+                    entry.setDecryptionError(error);
+                }
+            }
+        }
+    }
+}
diff --git a/src/matrix/e2ee/common.js b/src/matrix/e2ee/common.js
new file mode 100644
index 00000000..190f2fa2
--- /dev/null
+++ b/src/matrix/e2ee/common.js
@@ -0,0 +1,55 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 anotherjson from "../../../lib/another-json/index.js";
+import {createEnum} from "../../utils/enum.js";
+
+export const DecryptionSource = createEnum(["Sync", "Timeline", "Retry"]);
+
+// use common prefix so it's easy to clear properties that are not e2ee related during session clear
+export const SESSION_KEY_PREFIX = "e2ee:";
+export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2";
+export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2";
+
+export class DecryptionError extends Error {
+    constructor(code, event, detailsObj = null) {
+        super(`Decryption error ${code}${detailsObj ? ": "+JSON.stringify(detailsObj) : ""}`);
+        this.code = code;
+        this.event = event;
+        this.details = detailsObj;
+    }
+}
+
+export const SIGNATURE_ALGORITHM = "ed25519";
+
+export function verifyEd25519Signature(olmUtil, userId, deviceOrKeyId, ed25519Key, value) {
+    const clone = Object.assign({}, value);
+    delete clone.unsigned;
+    delete clone.signatures;
+    const canonicalJson = anotherjson.stringify(clone);
+    const signature = value?.signatures?.[userId]?.[`${SIGNATURE_ALGORITHM}:${deviceOrKeyId}`];
+    try {
+        if (!signature) {
+            throw new Error("no signature");
+        }
+        // throws when signature is invalid
+        olmUtil.ed25519_verify(ed25519Key, canonicalJson, signature);
+        return true;
+    } catch (err) {
+        console.warn("Invalid signature, ignoring.", ed25519Key, canonicalJson, signature, err);
+        return false;
+    }
+}
diff --git a/src/matrix/e2ee/megolm/Decryption.js b/src/matrix/e2ee/megolm/Decryption.js
new file mode 100644
index 00000000..b3f1ea71
--- /dev/null
+++ b/src/matrix/e2ee/megolm/Decryption.js
@@ -0,0 +1,166 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {DecryptionError} from "../common.js";
+import {groupBy} from "../../../utils/groupBy.js";
+
+import {SessionInfo} from "./decryption/SessionInfo.js";
+import {DecryptionPreparation} from "./decryption/DecryptionPreparation.js";
+import {SessionDecryption} from "./decryption/SessionDecryption.js";
+import {SessionCache} from "./decryption/SessionCache.js";
+
+function getSenderKey(event) {
+    return event.content?.["sender_key"];
+}
+
+function getSessionId(event) {
+    return event.content?.["session_id"];
+}
+
+function getCiphertext(event) {
+    return event.content?.ciphertext;
+}
+
+export class Decryption {
+    constructor({pickleKey, olm, olmWorker}) {
+        this._pickleKey = pickleKey;
+        this._olm = olm;
+        this._olmWorker = olmWorker;
+    }
+
+    createSessionCache(fallback) {
+        return new SessionCache(fallback);
+    }
+
+    /**
+     * Reads all the state from storage to be able to decrypt the given events.
+     * Decryption can then happen outside of a storage transaction.
+     * @param  {[type]} roomId       [description]
+     * @param  {[type]} events        [description]
+     * @param  {[type]} sessionCache [description]
+     * @param  {[type]} txn          [description]
+     * @return {DecryptionPreparation}
+     */
+    async prepareDecryptAll(roomId, events, sessionCache, txn) {
+        const errors = new Map();
+        const validEvents = [];
+
+        for (const event of events) {
+            const isValid = typeof getSenderKey(event) === "string" &&
+                            typeof getSessionId(event) === "string" &&
+                            typeof getCiphertext(event) === "string";
+            if (isValid) {
+                validEvents.push(event);
+            } else {
+                errors.set(event.event_id, new DecryptionError("MEGOLM_INVALID_EVENT", event))
+            }
+        }
+
+        const eventsBySession = groupBy(validEvents, event => {
+            return `${getSenderKey(event)}|${getSessionId(event)}`;
+        });
+
+        const sessionDecryptions = [];
+
+        await Promise.all(Array.from(eventsBySession.values()).map(async eventsForSession => {
+            const first = eventsForSession[0];
+            const senderKey = getSenderKey(first);
+            const sessionId = getSessionId(first);
+            const sessionInfo = await this._getSessionInfo(roomId, senderKey, sessionId, sessionCache, txn);
+            if (!sessionInfo) {
+                for (const event of eventsForSession) {
+                    errors.set(event.event_id, new DecryptionError("MEGOLM_NO_SESSION", event));
+                }
+            } else {
+                sessionDecryptions.push(new SessionDecryption(sessionInfo, eventsForSession, this._olmWorker));
+            }
+        }));
+
+        return new DecryptionPreparation(roomId, sessionDecryptions, errors);
+    }
+
+    async _getSessionInfo(roomId, senderKey, sessionId, sessionCache, txn) {
+        let sessionInfo;
+        sessionInfo = sessionCache.get(roomId, senderKey, sessionId);
+        if (!sessionInfo) {
+            const sessionEntry = await txn.inboundGroupSessions.get(roomId, senderKey, sessionId);
+            if (sessionEntry) {
+                let session = new this._olm.InboundGroupSession();
+                try {
+                    session.unpickle(this._pickleKey, sessionEntry.session);
+                    sessionInfo = new SessionInfo(roomId, senderKey, session, sessionEntry.claimedKeys);
+                } catch (err) {
+                    session.free();
+                    throw err;
+                }
+                sessionCache.add(sessionInfo);
+            }
+        }
+        return sessionInfo;
+    }
+
+    /**
+     * @type {MegolmInboundSessionDescription}
+     * @property {string} senderKey the sender key of the session
+     * @property {string} sessionId the session identifier
+     * 
+     * Adds room keys as inbound group sessions
+     * @param {Array} decryptionResults an array of m.room_key decryption results.
+     * @param {[type]} txn      a storage transaction with read/write on inboundGroupSessions
+     * @return {Promise>} an array with the newly added sessions
+     */
+    async addRoomKeys(decryptionResults, txn) {
+        const newSessions = [];
+        for (const {senderCurve25519Key: senderKey, event, claimedEd25519Key} of decryptionResults) {
+            const roomId = event.content?.["room_id"];
+            const sessionId = event.content?.["session_id"];
+            const sessionKey = event.content?.["session_key"];
+
+            if (
+                typeof roomId !== "string" || 
+                typeof sessionId !== "string" || 
+                typeof senderKey !== "string" ||
+                typeof sessionKey !== "string"
+            ) {
+                return;
+            }
+
+            // TODO: compare first_known_index to see which session to keep
+            const hasSession = await txn.inboundGroupSessions.has(roomId, senderKey, sessionId);
+            if (!hasSession) {
+                const session = new this._olm.InboundGroupSession();
+                try {
+                    session.create(sessionKey);
+                    const sessionEntry = {
+                        roomId,
+                        senderKey,
+                        sessionId,
+                        session: session.pickle(this._pickleKey),
+                        claimedKeys: {ed25519: claimedEd25519Key},
+                    };
+                    txn.inboundGroupSessions.set(sessionEntry);
+                    newSessions.push(sessionEntry);
+                } finally {
+                    session.free();
+                }
+            }
+
+        }
+        // this will be passed to the Room in notifyRoomKeys
+        return newSessions;
+    }
+}
+
diff --git a/src/matrix/e2ee/megolm/Encryption.js b/src/matrix/e2ee/megolm/Encryption.js
new file mode 100644
index 00000000..cb0dddf8
--- /dev/null
+++ b/src/matrix/e2ee/megolm/Encryption.js
@@ -0,0 +1,183 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {MEGOLM_ALGORITHM} from "../common.js";
+
+export class Encryption {
+    constructor({pickleKey, olm, account, storage, now, ownDeviceId}) {
+        this._pickleKey = pickleKey;
+        this._olm = olm;
+        this._account = account;
+        this._storage = storage;
+        this._now = now;
+        this._ownDeviceId = ownDeviceId;
+    }
+
+    discardOutboundSession(roomId, txn) {
+        txn.outboundGroupSessions.remove(roomId);
+    }
+
+    async createRoomKeyMessage(roomId, txn) {
+        let sessionEntry = await txn.outboundGroupSessions.get(roomId);
+        if (sessionEntry) {
+            const session = new this._olm.OutboundGroupSession();
+            try {
+                session.unpickle(this._pickleKey, sessionEntry.session);
+                return this._createRoomKeyMessage(session, roomId);
+            } finally {
+                session.free();
+            }
+        }
+    }
+
+    /**
+     * Encrypts a message with megolm
+     * @param  {string} roomId           
+     * @param  {string} type             event type to encrypt
+     * @param  {string} content          content to encrypt
+     * @param  {object} encryptionParams the content of the m.room.encryption event
+     * @return {Promise}
+     */
+    async encrypt(roomId, type, content, encryptionParams) {
+        let session = new this._olm.OutboundGroupSession();
+        try {
+            const txn = await this._storage.readWriteTxn([
+                this._storage.storeNames.inboundGroupSessions,
+                this._storage.storeNames.outboundGroupSessions,
+            ]);
+            let roomKeyMessage;
+            let encryptedContent;
+            try {
+                // TODO: we could consider keeping the session in memory for the current room
+                let sessionEntry = await txn.outboundGroupSessions.get(roomId);
+                if (sessionEntry) {
+                    session.unpickle(this._pickleKey, sessionEntry.session);
+                }
+                if (!sessionEntry || this._needsToRotate(session, sessionEntry.createdAt, encryptionParams)) {
+                    // in the case of rotating, recreate a session as we already unpickled into it
+                    if (sessionEntry) {
+                        session.free();
+                        session = new this._olm.OutboundGroupSession();
+                    }
+                    session.create();
+                    roomKeyMessage = this._createRoomKeyMessage(session, roomId);
+                    this._storeAsInboundSession(session, roomId, txn);
+                    // TODO: we could tell the Decryption here that we have a new session so it can add it to its cache
+                }
+                encryptedContent = this._encryptContent(roomId, session, type, content);
+                txn.outboundGroupSessions.set({
+                    roomId,
+                    session: session.pickle(this._pickleKey),
+                    createdAt: sessionEntry?.createdAt || this._now(),
+                });
+
+            } catch (err) {
+                txn.abort();
+                throw err;
+            }
+            await txn.complete();
+            return new EncryptionResult(encryptedContent, roomKeyMessage);
+        } finally {
+            if (session) {
+                session.free();
+            }
+        }
+    }
+
+    _needsToRotate(session, createdAt, encryptionParams) {
+        let rotationPeriodMs = 604800000; // default
+        if (Number.isSafeInteger(encryptionParams?.rotation_period_ms)) {
+            rotationPeriodMs = encryptionParams?.rotation_period_ms;
+        }
+        let rotationPeriodMsgs = 100; // default
+        if (Number.isSafeInteger(encryptionParams?.rotation_period_msgs)) {
+            rotationPeriodMsgs = encryptionParams?.rotation_period_msgs;
+        }
+
+        if (this._now() > (createdAt + rotationPeriodMs)) {
+            return true;
+        }
+        if (session.message_index() >= rotationPeriodMsgs) {
+            return true;
+        }  
+    }
+
+    _encryptContent(roomId, session, type, content) {
+        const plaintext = JSON.stringify({
+            room_id: roomId,
+            type,
+            content
+        });
+        const ciphertext = session.encrypt(plaintext);
+
+        const encryptedContent = {
+            algorithm: MEGOLM_ALGORITHM,
+            sender_key: this._account.identityKeys.curve25519,
+            ciphertext,
+            session_id: session.session_id(),
+            device_id: this._ownDeviceId
+        };
+
+        return encryptedContent;
+    }
+
+    _createRoomKeyMessage(session, roomId) {
+        return {
+            room_id: roomId,
+            session_id: session.session_id(),
+            session_key: session.session_key(),
+            algorithm: MEGOLM_ALGORITHM,
+            // chain_index is ignored by element-web if not all clients
+            // but let's send it anyway, as element-web does so
+            chain_index: session.message_index()
+        }
+    }
+
+    _storeAsInboundSession(outboundSession, roomId, txn) {
+        const {identityKeys} = this._account;
+        const claimedKeys = {ed25519: identityKeys.ed25519};
+        const session = new this._olm.InboundGroupSession();
+        try {
+            session.create(outboundSession.session_key());
+            const sessionEntry = {
+                roomId,
+                senderKey: identityKeys.curve25519,
+                sessionId: session.session_id(),
+                session: session.pickle(this._pickleKey),
+                claimedKeys,
+            };
+            txn.inboundGroupSessions.set(sessionEntry);
+            return sessionEntry;
+        } finally {
+            session.free();
+        }
+    }
+}
+
+/**
+ * @property {object?} roomKeyMessage  if encrypting this message
+ *                                     created a new outbound session,
+ *                                     this contains the content of the m.room_key message
+ *                                     that should be sent out over olm.
+ * @property {object} content  the encrypted message as the content of
+ *                             the m.room.encrypted event that should be sent out   
+ */
+class EncryptionResult {
+    constructor(content, roomKeyMessage) {
+        this.content = content;
+        this.roomKeyMessage = roomKeyMessage;
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js
new file mode 100644
index 00000000..10a2da87
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/DecryptionChanges.js
@@ -0,0 +1,75 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {DecryptionError} from "../../common.js";
+
+export class DecryptionChanges {
+    constructor(roomId, results, errors, replayEntries) {
+        this._roomId = roomId;
+        this._results = results;
+        this._errors = errors;
+        this._replayEntries = replayEntries;
+    }
+
+    /**
+     * @type MegolmBatchDecryptionResult
+     * @property {Map} results a map of event id to decryption result
+     * @property {Map} errors event id -> errors
+     * 
+     * Handle replay attack detection, and return result
+     * @param  {[type]} txn [description]
+     * @return {MegolmBatchDecryptionResult}
+     */
+    async write(txn) {
+        await Promise.all(this._replayEntries.map(async replayEntry => {
+            try {
+                this._handleReplayAttack(this._roomId, replayEntry, txn);
+            } catch (err) {
+                this._errors.set(replayEntry.eventId, err);
+            }
+        }));
+        return {
+            results: this._results,
+            errors: this._errors
+        };
+    }
+
+    async _handleReplayAttack(roomId, replayEntry, txn) {
+        const {messageIndex, sessionId, eventId, timestamp} = replayEntry;
+        const decryption = await txn.groupSessionDecryptions.get(roomId, sessionId, messageIndex);
+
+        if (decryption && decryption.eventId !== eventId) {
+            // the one with the newest timestamp should be the attack
+            const decryptedEventIsBad = decryption.timestamp < timestamp;
+            const badEventId = decryptedEventIsBad ? eventId : decryption.eventId;
+            // discard result
+            this._results.delete(eventId);
+
+            throw new DecryptionError("MEGOLM_REPLAYED_INDEX", event, {
+                messageIndex,
+                badEventId,
+                otherEventId: decryption.eventId
+            });
+        }
+
+        if (!decryption) {
+            txn.groupSessionDecryptions.set(roomId, sessionId, messageIndex, {
+                eventId,
+                timestamp
+            });
+        }
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/DecryptionPreparation.js b/src/matrix/e2ee/megolm/decryption/DecryptionPreparation.js
new file mode 100644
index 00000000..02ee32df
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/DecryptionPreparation.js
@@ -0,0 +1,52 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {DecryptionChanges} from "./DecryptionChanges.js";
+import {mergeMap} from "../../../../utils/mergeMap.js";
+
+/**
+ * Class that contains all the state loaded from storage to decrypt the given events
+ */
+export class DecryptionPreparation {
+    constructor(roomId, sessionDecryptions, errors) {
+        this._roomId = roomId;
+        this._sessionDecryptions = sessionDecryptions;
+        this._initialErrors = errors;
+    }
+
+    async decrypt() {
+        try {
+            const errors = this._initialErrors;
+            const results = new Map();
+            const replayEntries = [];
+            await Promise.all(this._sessionDecryptions.map(async sessionDecryption => {
+                const sessionResult = await sessionDecryption.decryptAll();
+                mergeMap(sessionResult.errors, errors);
+                mergeMap(sessionResult.results, results);
+                replayEntries.push(...sessionResult.replayEntries);
+            }));
+            return new DecryptionChanges(this._roomId, results, errors, replayEntries);
+        } finally {
+            this.dispose();
+        }
+    }
+
+    dispose() {
+        for (const sd of this._sessionDecryptions) {
+            sd.dispose();
+        }
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/README.md b/src/matrix/e2ee/megolm/decryption/README.md
new file mode 100644
index 00000000..b9bb3568
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/README.md
@@ -0,0 +1,6 @@
+Lots of classes here. The complexity comes from needing to offload decryption to a webworker, mainly for IE11. We can't keep a idb transaction open while waiting for the response from the worker, so need to batch decryption of multiple events and do decryption in multiple steps:
+
+ 1. Read all used inbound sessions for the batch of events, requires a read txn. This happens in `Decryption`. Sessions are loaded into `SessionInfo` objects, which are also kept in a `SessionCache` to prevent having to read and unpickle them all the time.
+ 2. Actually decrypt. No txn can stay open during this step, as it can be offloaded to a worker and is thus async. This happens in `DecryptionPreparation`, which delegates to `SessionDecryption` per session.
+ 3. Read and write for the replay detection, requires a read/write txn. This happens in `DecryptionChanges`
+ 4. Return the decrypted entries, and errors if any
diff --git a/src/matrix/e2ee/megolm/decryption/ReplayDetectionEntry.js b/src/matrix/e2ee/megolm/decryption/ReplayDetectionEntry.js
new file mode 100644
index 00000000..e5ce2845
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/ReplayDetectionEntry.js
@@ -0,0 +1,24 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export class ReplayDetectionEntry {
+    constructor(sessionId, messageIndex, event) {
+        this.sessionId = sessionId;
+        this.messageIndex = messageIndex;
+        this.eventId = event.event_id;
+        this.timestamp = event.origin_server_ts;
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/SessionCache.js b/src/matrix/e2ee/megolm/decryption/SessionCache.js
new file mode 100644
index 00000000..efb7ef54
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/SessionCache.js
@@ -0,0 +1,68 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+const CACHE_MAX_SIZE = 10;
+
+/**
+ * Cache of unpickled inbound megolm session.
+ */
+export class SessionCache {
+    constructor() {
+        this._sessions = [];
+    }
+
+    /**
+     * @param  {string} roomId
+     * @param  {string} senderKey
+     * @param  {string} sessionId
+     * @return {SessionInfo?}
+     */
+    get(roomId, senderKey, sessionId) {
+        const idx = this._sessions.findIndex(s => {
+            return s.roomId === roomId &&
+                s.senderKey === senderKey &&
+                sessionId === s.session.session_id();
+        });
+        if (idx !== -1) {
+            const sessionInfo = this._sessions[idx];
+            // move to top
+            if (idx > 0) {
+                this._sessions.splice(idx, 1);
+                this._sessions.unshift(sessionInfo);
+            }
+            return sessionInfo;
+        }
+    }
+
+    add(sessionInfo) {
+        sessionInfo.retain();
+        // add new at top
+        this._sessions.unshift(sessionInfo);
+        if (this._sessions.length > CACHE_MAX_SIZE) {
+            // free sessions we're about to remove
+            for (let i = CACHE_MAX_SIZE; i < this._sessions.length; i += 1) {
+                this._sessions[i].release();
+            }
+            this._sessions = this._sessions.slice(0, CACHE_MAX_SIZE);
+        }
+    }
+
+    dispose() {
+        for (const sessionInfo of this._sessions) {
+            sessionInfo.release();
+        }
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/SessionDecryption.js b/src/matrix/e2ee/megolm/decryption/SessionDecryption.js
new file mode 100644
index 00000000..137ae9f8
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/SessionDecryption.js
@@ -0,0 +1,90 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {DecryptionResult} from "../../DecryptionResult.js";
+import {DecryptionError} from "../../common.js";
+import {ReplayDetectionEntry} from "./ReplayDetectionEntry.js";
+
+/**
+ * Does the actual decryption of all events for a given megolm session in a batch
+ */
+export class SessionDecryption {
+    constructor(sessionInfo, events, olmWorker) {
+        sessionInfo.retain();
+        this._sessionInfo = sessionInfo;
+        this._events = events;
+        this._olmWorker = olmWorker;
+        this._decryptionRequests = olmWorker ? [] : null;
+    }
+
+    async decryptAll() {
+        const replayEntries = [];
+        const results = new Map();
+        let errors;
+        const roomId = this._sessionInfo.roomId;
+
+        await Promise.all(this._events.map(async event => {
+            try {
+                const {session} = this._sessionInfo;
+                const ciphertext = event.content.ciphertext;
+                let decryptionResult;
+                if (this._olmWorker) {
+                    const request = this._olmWorker.megolmDecrypt(session, ciphertext);
+                    this._decryptionRequests.push(request);
+                    decryptionResult = await request.response();
+                } else {
+                    decryptionResult = session.decrypt(ciphertext);
+                }
+                const plaintext = decryptionResult.plaintext;
+                const messageIndex = decryptionResult.message_index;
+                let payload;
+                try {
+                    payload = JSON.parse(plaintext);
+                } catch (err) {
+                    throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
+                }
+                if (payload.room_id !== roomId) {
+                    throw new DecryptionError("MEGOLM_WRONG_ROOM", event,
+                        {encryptedRoomId: payload.room_id, eventRoomId: roomId});
+                }
+                replayEntries.push(new ReplayDetectionEntry(session.session_id(), messageIndex, event));
+                const result = new DecryptionResult(payload, this._sessionInfo.senderKey, this._sessionInfo.claimedKeys);
+                results.set(event.event_id, result);
+            } catch (err) {
+                // ignore AbortError from cancelling decryption requests in dispose method
+                if (err.name === "AbortError") {
+                    return;
+                }
+                if (!errors) {
+                    errors = new Map();
+                }
+                errors.set(event.event_id, err);
+            }
+        }));
+
+        return {results, errors, replayEntries};
+    }
+
+    dispose() {
+        if (this._decryptionRequests) {
+            for (const r of this._decryptionRequests) {
+                r.abort();
+            }
+        }
+        // TODO: cancel decryptions here
+        this._sessionInfo.release();
+    }
+}
diff --git a/src/matrix/e2ee/megolm/decryption/SessionInfo.js b/src/matrix/e2ee/megolm/decryption/SessionInfo.js
new file mode 100644
index 00000000..dedc3222
--- /dev/null
+++ b/src/matrix/e2ee/megolm/decryption/SessionInfo.js
@@ -0,0 +1,44 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+/**
+ * session loaded in memory with everything needed to create DecryptionResults
+ * and to store/retrieve it in the SessionCache
+ */
+export class SessionInfo {
+    constructor(roomId, senderKey, session, claimedKeys) {
+        this.roomId = roomId;
+        this.senderKey = senderKey;
+        this.session = session;
+        this.claimedKeys = claimedKeys;
+        this._refCounter = 0;
+    }
+
+    retain() {
+        this._refCounter += 1;
+    }
+
+    release() {
+        this._refCounter -= 1;
+        if (this._refCounter <= 0) {
+            this.dispose();
+        }
+    }
+
+    dispose() {
+        this.session.free();
+    }
+}
diff --git a/src/matrix/e2ee/olm/Decryption.js b/src/matrix/e2ee/olm/Decryption.js
new file mode 100644
index 00000000..fc16852a
--- /dev/null
+++ b/src/matrix/e2ee/olm/Decryption.js
@@ -0,0 +1,307 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {DecryptionError} from "../common.js";
+import {groupBy} from "../../../utils/groupBy.js";
+import {Session} from "./Session.js";
+import {DecryptionResult} from "../DecryptionResult.js";
+
+const SESSION_LIMIT_PER_SENDER_KEY = 4;
+
+function isPreKeyMessage(message) {
+    return message.type === 0;
+}
+
+function sortSessions(sessions) {
+    sessions.sort((a, b) => {
+        return b.data.lastUsed - a.data.lastUsed;
+    });
+}
+
+export class Decryption {
+    constructor({account, pickleKey, now, ownUserId, storage, olm, senderKeyLock}) {
+        this._account = account;
+        this._pickleKey = pickleKey;
+        this._now = now;
+        this._ownUserId = ownUserId;
+        this._storage = storage;
+        this._olm = olm;
+        this._senderKeyLock = senderKeyLock;
+    }
+
+    // we need decryptAll because there is some parallelization we can do for decrypting different sender keys at once
+    // but for the same sender key we need to do one by one
+    // 
+    // also we want to store the room key, etc ... in the same txn as we remove the pending encrypted event
+    // 
+    // so we need to decrypt events in a batch (so we can decide which ones can run in parallel and which one one by one)
+    // and also can avoid side-effects before all can be stored this way
+    // 
+    // doing it one by one would be possible, but we would lose the opportunity for parallelization
+    // 
+    
+    /**
+     * [decryptAll description]
+     * @param  {[type]} events
+     * @return {Promise}        [description]
+     */
+    async decryptAll(events) {
+        const eventsPerSenderKey = groupBy(events, event => event.content?.["sender_key"]);
+        const timestamp = this._now();
+        // take a lock on all senderKeys so encryption or other calls to decryptAll (should not happen)
+        // don't modify the sessions at the same time
+        const locks = await Promise.all(Array.from(eventsPerSenderKey.keys()).map(senderKey => {
+            return this._senderKeyLock.takeLock(senderKey);
+        }));
+        try {
+            const readSessionsTxn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
+            // decrypt events for different sender keys in parallel
+            const senderKeyOperations = await Promise.all(Array.from(eventsPerSenderKey.entries()).map(([senderKey, events]) => {
+                return this._decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn);
+            }));
+            const results = senderKeyOperations.reduce((all, r) => all.concat(r.results), []);
+            const errors = senderKeyOperations.reduce((all, r) => all.concat(r.errors), []);
+            const senderKeyDecryptions = senderKeyOperations.map(r => r.senderKeyDecryption);
+            return new DecryptionChanges(senderKeyDecryptions, results, errors, this._account, locks);
+        } catch (err) {
+            // make sure the locks are release if something throws
+            // otherwise they will be released in DecryptionChanges after having written
+            for (const lock of locks) {
+                lock.release();
+            }
+            throw err;
+        }
+    }
+
+    async _decryptAllForSenderKey(senderKey, events, timestamp, readSessionsTxn) {
+        const sessions = await this._getSessions(senderKey, readSessionsTxn);
+        const senderKeyDecryption = new SenderKeyDecryption(senderKey, sessions, this._olm, timestamp);
+        const results = [];
+        const errors = [];
+        // events for a single senderKey need to be decrypted one by one
+        for (const event of events) {
+            try {
+                const result = this._decryptForSenderKey(senderKeyDecryption, event, timestamp);
+                results.push(result);
+            } catch (err) {
+                errors.push(err);
+            }
+        }
+        return {results, errors, senderKeyDecryption};
+    }
+
+    _decryptForSenderKey(senderKeyDecryption, event, timestamp) {
+        const senderKey = senderKeyDecryption.senderKey;
+        const message = this._getMessageAndValidateEvent(event);
+        let plaintext;
+        try {
+            plaintext = senderKeyDecryption.decrypt(message);
+        } catch (err) {
+            // TODO: is it ok that an error on one session prevents other sessions from being attempted?
+            throw new DecryptionError("OLM_BAD_ENCRYPTED_MESSAGE", event, {senderKey, error: err.message});
+        }
+        // could not decrypt with any existing session
+        if (typeof plaintext !== "string" && isPreKeyMessage(message)) {
+            const createResult = this._createSessionAndDecrypt(senderKey, message, timestamp);
+            senderKeyDecryption.addNewSession(createResult.session);
+            plaintext = createResult.plaintext;
+        }
+        if (typeof plaintext === "string") {
+            let payload;
+            try {
+                payload = JSON.parse(plaintext);
+            } catch (err) {
+                throw new DecryptionError("PLAINTEXT_NOT_JSON", event, {plaintext, err});
+            }
+            this._validatePayload(payload, event);
+            return new DecryptionResult(payload, senderKey, payload.keys);
+        } else {
+            throw new DecryptionError("OLM_NO_MATCHING_SESSION", event,
+                {knownSessionIds: senderKeyDecryption.sessions.map(s => s.id)});
+        }
+    }
+
+    // only for pre-key messages after having attempted decryption with existing sessions
+    _createSessionAndDecrypt(senderKey, message, timestamp) {
+        let plaintext;
+        // if we have multiple messages encrypted with the same new session,
+        // this could create multiple sessions as the OTK isn't removed yet
+        // (this only happens in DecryptionChanges.write)
+        // This should be ok though as we'll first try to decrypt with the new session
+        const olmSession = this._account.createInboundOlmSession(senderKey, message.body);
+        try {
+            plaintext = olmSession.decrypt(message.type, message.body);
+            const session = Session.create(senderKey, olmSession, this._olm, this._pickleKey, timestamp);
+            session.unload(olmSession);
+            return {session, plaintext};
+        } catch (err) {
+            olmSession.free();
+            throw err;
+        }
+    }
+
+    _getMessageAndValidateEvent(event) {
+        const ciphertext = event.content?.ciphertext;
+        if (!ciphertext) {
+            throw new DecryptionError("OLM_MISSING_CIPHERTEXT", event);
+        }
+        const message = ciphertext?.[this._account.identityKeys.curve25519];
+        if (!message) {
+            throw new DecryptionError("OLM_NOT_INCLUDED_IN_RECIPIENTS", event);
+        }
+
+        return message;
+    }
+
+    async _getSessions(senderKey, txn) {
+        const sessionEntries = await txn.olmSessions.getAll(senderKey);
+        // sort most recent used sessions first
+        const sessions = sessionEntries.map(s => new Session(s, this._pickleKey, this._olm));
+        sortSessions(sessions);
+        return sessions;
+    }
+
+    _validatePayload(payload, event) {
+        if (payload.sender !== event.sender) {
+            throw new DecryptionError("OLM_FORWARDED_MESSAGE", event, {sentBy: event.sender, encryptedBy: payload.sender});
+        }
+        if (payload.recipient !== this._ownUserId) {
+            throw new DecryptionError("OLM_BAD_RECIPIENT", event, {recipient: payload.recipient});
+        }
+        if (payload.recipient_keys?.ed25519 !== this._account.identityKeys.ed25519) {
+            throw new DecryptionError("OLM_BAD_RECIPIENT_KEY", event, {key: payload.recipient_keys?.ed25519});
+        }
+        // TODO: check room_id
+        if (!payload.type) {
+            throw new DecryptionError("missing type on payload", event, {payload});
+        }
+        if (typeof payload.keys?.ed25519 !== "string") {
+            throw new DecryptionError("Missing or invalid claimed ed25519 key on payload", event, {payload});
+        }
+    }
+}
+
+// decryption helper for a single senderKey
+class SenderKeyDecryption {
+    constructor(senderKey, sessions, olm, timestamp) {
+        this.senderKey = senderKey;
+        this.sessions = sessions;
+        this._olm = olm;
+        this._timestamp = timestamp;
+    }
+
+    addNewSession(session) {
+        // add at top as it is most recent
+        this.sessions.unshift(session);
+    }
+
+    decrypt(message) {
+        for (const session of this.sessions) {
+            const plaintext = this._decryptWithSession(session, message);
+            if (typeof plaintext === "string") {
+                // keep them sorted so will try the same session first for other messages
+                // and so we can assume the excess ones are at the end
+                // if they grow too large
+                sortSessions(this.sessions);
+                return plaintext;
+            }
+        }
+    }
+
+    getModifiedSessions() {
+        return this.sessions.filter(session => session.isModified);
+    }
+
+    get hasNewSessions() {
+        return this.sessions.some(session => session.isNew);
+    }
+
+    // this could internally dispatch to a web-worker
+    // and is why we unpickle/pickle on each iteration
+    // if this turns out to be a real cost for IE11,
+    // we could look into adding a less expensive serialization mechanism
+    // for olm sessions to libolm
+    _decryptWithSession(session, message) {
+        const olmSession = session.load();
+        try {
+            if (isPreKeyMessage(message) && !olmSession.matches_inbound(message.body)) {
+                return;
+            }
+            try {
+                const plaintext = olmSession.decrypt(message.type, message.body);
+                session.save(olmSession);
+                session.lastUsed = this._timestamp;
+                return plaintext;
+            } catch (err) {
+                if (isPreKeyMessage(message)) {
+                    throw new Error(`Error decrypting prekey message with existing session id ${session.id}: ${err.message}`);
+                }
+                // decryption failed, bail out
+                return;
+            }
+        } finally {
+            session.unload(olmSession);
+        }
+    }
+}
+
+/**
+ * @property {Array} results
+ * @property {Array} errors  see DecryptionError.event to retrieve the event that failed to decrypt.
+ */
+class DecryptionChanges {
+    constructor(senderKeyDecryptions, results, errors, account, locks) {
+        this._senderKeyDecryptions = senderKeyDecryptions;
+        this._account = account;    
+        this.results = results;
+        this.errors = errors;
+        this._locks = locks;
+    }
+
+    get hasNewSessions() {
+        return this._senderKeyDecryptions.some(skd => skd.hasNewSessions);
+    }
+
+    write(txn) {
+        try {
+            for (const senderKeyDecryption of this._senderKeyDecryptions) {
+                for (const session of senderKeyDecryption.getModifiedSessions()) {
+                    txn.olmSessions.set(session.data);
+                    if (session.isNew) {
+                        const olmSession = session.load();
+                        try {
+                            this._account.writeRemoveOneTimeKey(olmSession, txn);
+                        } finally {
+                            session.unload(olmSession);
+                        }
+                    }
+                }
+                if (senderKeyDecryption.sessions.length > SESSION_LIMIT_PER_SENDER_KEY) {
+                    const {senderKey, sessions} = senderKeyDecryption;
+                    // >= because index is zero-based
+                    for (let i = sessions.length - 1; i >= SESSION_LIMIT_PER_SENDER_KEY ; i -= 1) {
+                        const session = sessions[i];
+                        txn.olmSessions.remove(senderKey, session.id);
+                    }
+                }
+            }
+        } finally {
+            for (const lock of this._locks) {
+                lock.release();
+            }
+        }
+    }
+}
diff --git a/src/matrix/e2ee/olm/Encryption.js b/src/matrix/e2ee/olm/Encryption.js
new file mode 100644
index 00000000..919acc45
--- /dev/null
+++ b/src/matrix/e2ee/olm/Encryption.js
@@ -0,0 +1,290 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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 {groupByWithCreator} from "../../../utils/groupBy.js";
+import {verifyEd25519Signature, OLM_ALGORITHM} from "../common.js";
+import {createSessionEntry} from "./Session.js";
+
+function findFirstSessionId(sessionIds) {
+    return sessionIds.reduce((first, sessionId) => {
+        if (!first || sessionId < first) {
+            return sessionId;
+        } else {
+            return first;
+        }
+    }, null);
+}
+
+const OTK_ALGORITHM = "signed_curve25519";
+// only encrypt this amount of olm messages at once otherwise we run out of wasm memory
+// with all the sessions loaded at the same time
+const MAX_BATCH_SIZE = 50;
+
+export class Encryption {
+    constructor({account, olm, olmUtil, ownUserId, storage, now, pickleKey, senderKeyLock}) {
+        this._account = account;
+        this._olm = olm;
+        this._olmUtil = olmUtil;
+        this._ownUserId = ownUserId;
+        this._storage = storage;
+        this._now = now;
+        this._pickleKey = pickleKey;
+        this._senderKeyLock = senderKeyLock;
+    }
+
+    async encrypt(type, content, devices, hsApi) {
+        let messages = [];
+        for (let i = 0; i < devices.length ; i += MAX_BATCH_SIZE) {
+            const batchDevices = devices.slice(i, i + MAX_BATCH_SIZE);
+            const batchMessages = await this._encryptForMaxDevices(type, content, batchDevices, hsApi);
+            messages = messages.concat(batchMessages);
+        }
+        return messages;
+    }
+
+    async _encryptForMaxDevices(type, content, devices, hsApi) {
+        // TODO: see if we can only hold some of the locks until after the /keys/claim call (if needed) 
+        // take a lock on all senderKeys so decryption and other calls to encrypt (should not happen)
+        // don't modify the sessions at the same time
+        const locks = await Promise.all(devices.map(device => {
+            return this._senderKeyLock.takeLock(device.curve25519Key);
+        }));
+        try {
+            const {
+                devicesWithoutSession,
+                existingEncryptionTargets,
+            } = await this._findExistingSessions(devices);
+        
+            const timestamp = this._now(); 
+
+            let encryptionTargets = [];
+            try {
+                if (devicesWithoutSession.length) {
+                    const newEncryptionTargets = await this._createNewSessions(
+                        devicesWithoutSession, hsApi, timestamp);
+                    encryptionTargets = encryptionTargets.concat(newEncryptionTargets);
+                }
+                await this._loadSessions(existingEncryptionTargets);
+                encryptionTargets = encryptionTargets.concat(existingEncryptionTargets);
+                const messages = encryptionTargets.map(target => {
+                    const encryptedContent = this._encryptForDevice(type, content, target);
+                    return new EncryptedMessage(encryptedContent, target.device);
+                });
+                await this._storeSessions(encryptionTargets, timestamp);
+                return messages;
+            } finally {
+                for (const target of encryptionTargets) {
+                    target.dispose();
+                }
+            }
+        } finally {
+            for (const lock of locks) {
+                lock.release();
+            }
+        }
+    }
+
+    async _findExistingSessions(devices) {
+        const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
+        const sessionIdsForDevice = await Promise.all(devices.map(async device => {
+            return await txn.olmSessions.getSessionIds(device.curve25519Key);
+        }));
+        const devicesWithoutSession = devices.filter((_, i) => {
+            const sessionIds = sessionIdsForDevice[i];
+            return !(sessionIds?.length);
+        });
+
+        const existingEncryptionTargets = devices.map((device, i) => {
+            const sessionIds = sessionIdsForDevice[i];
+            if (sessionIds?.length > 0) {
+                const sessionId = findFirstSessionId(sessionIds);
+                return EncryptionTarget.fromSessionId(device, sessionId);
+            }
+        }).filter(target => !!target);
+
+        return {devicesWithoutSession, existingEncryptionTargets};
+    }
+
+    _encryptForDevice(type, content, target) {
+        const {session, device} = target;
+        const plaintext = JSON.stringify(this._buildPlainTextMessageForDevice(type, content, device));
+        const message = session.encrypt(plaintext);
+        const encryptedContent = {
+            algorithm: OLM_ALGORITHM,
+            sender_key: this._account.identityKeys.curve25519,
+            ciphertext: {
+                [device.curve25519Key]: message
+            }
+        };
+        return encryptedContent;
+    }
+
+    _buildPlainTextMessageForDevice(type, content, device) {
+        return {
+            keys: {
+                "ed25519": this._account.identityKeys.ed25519
+            },
+            recipient_keys: {
+                "ed25519": device.ed25519Key
+            },
+            recipient: device.userId,
+            sender: this._ownUserId,
+            content,
+            type
+        }
+    }
+
+    async _createNewSessions(devicesWithoutSession, hsApi, timestamp) {
+        const newEncryptionTargets = await this._claimOneTimeKeys(hsApi, devicesWithoutSession);
+        try {
+            for (const target of newEncryptionTargets) {
+                const {device, oneTimeKey} = target;
+                target.session = this._account.createOutboundOlmSession(device.curve25519Key, oneTimeKey);
+            }
+            this._storeSessions(newEncryptionTargets, timestamp);
+        } catch (err) {
+            for (const target of newEncryptionTargets) {
+                target.dispose();
+            }
+            throw err;
+        }
+        return newEncryptionTargets;
+    }
+
+    async _claimOneTimeKeys(hsApi, deviceIdentities) {
+        // create a Map>
+        const devicesByUser = groupByWithCreator(deviceIdentities,
+            device => device.userId,
+            () => new Map(),
+            (deviceMap, device) => deviceMap.set(device.deviceId, device)
+        );
+        const oneTimeKeys = Array.from(devicesByUser.entries()).reduce((usersObj, [userId, deviceMap]) => {
+            usersObj[userId] = Array.from(deviceMap.values()).reduce((devicesObj, device) => {
+                devicesObj[device.deviceId] = OTK_ALGORITHM;
+                return devicesObj;
+            }, {});
+            return usersObj;
+        }, {});
+        const claimResponse = await hsApi.claimKeys({
+            timeout: 10000,
+            one_time_keys: oneTimeKeys
+        }).response();
+        if (Object.keys(claimResponse.failures).length) {
+            console.warn("failures for claiming one time keys", oneTimeKeys, claimResponse.failures);
+        }
+        // TODO: log claimResponse.failures
+        const userKeyMap = claimResponse?.["one_time_keys"];
+        return this._verifyAndCreateOTKTargets(userKeyMap, devicesByUser);
+    }
+
+    _verifyAndCreateOTKTargets(userKeyMap, devicesByUser) {
+        const verifiedEncryptionTargets = [];
+        for (const [userId, userSection] of Object.entries(userKeyMap)) {
+            for (const [deviceId, deviceSection] of Object.entries(userSection)) {
+                const [firstPropName, keySection] = Object.entries(deviceSection)[0];
+                const [keyAlgorithm] = firstPropName.split(":");
+                if (keyAlgorithm === OTK_ALGORITHM) {
+                    const device = devicesByUser.get(userId)?.get(deviceId);
+                    if (device) {
+                        const isValidSignature = verifyEd25519Signature(
+                            this._olmUtil, userId, deviceId, device.ed25519Key, keySection);
+                        if (isValidSignature) {
+                            const target = EncryptionTarget.fromOTK(device, keySection.key);
+                            verifiedEncryptionTargets.push(target);
+                        }
+                    }
+                }
+            } 
+        }
+        return verifiedEncryptionTargets;
+    }
+
+    async _loadSessions(encryptionTargets) {
+        const txn = await this._storage.readTxn([this._storage.storeNames.olmSessions]);
+        // given we run loading in parallel, there might still be some
+        // storage requests that will finish later once one has failed.
+        // those should not allocate a session anymore.
+        let failed = false;
+        try {
+            await Promise.all(encryptionTargets.map(async encryptionTarget => {
+                const sessionEntry = await txn.olmSessions.get(
+                    encryptionTarget.device.curve25519Key, encryptionTarget.sessionId);
+                if (sessionEntry && !failed) {
+                    const olmSession = new this._olm.Session();
+                    olmSession.unpickle(this._pickleKey, sessionEntry.session);
+                    encryptionTarget.session = olmSession;
+                }
+            }));
+        } catch (err) {
+            failed = true;
+            // clean up the sessions that did load
+            for (const target of encryptionTargets) {
+                target.dispose();
+            }
+            throw err;
+        }
+    }
+
+    async _storeSessions(encryptionTargets, timestamp) {
+        const txn = await this._storage.readWriteTxn([this._storage.storeNames.olmSessions]);
+        try {
+            for (const target of encryptionTargets) {
+                const sessionEntry = createSessionEntry(
+                    target.session, target.device.curve25519Key, timestamp, this._pickleKey);
+                txn.olmSessions.set(sessionEntry);
+            }
+        } catch (err) {
+            txn.abort();
+            throw err;
+        }
+        await txn.complete();
+    }
+}
+
+// just a container needed to encrypt a message for a recipient device
+// it is constructed with either a oneTimeKey
+// (and later converted to a session) in case of a new session
+// or an existing session
+class EncryptionTarget {
+    constructor(device, oneTimeKey, sessionId) {
+        this.device = device;
+        this.oneTimeKey = oneTimeKey;
+        this.sessionId = sessionId;
+        // an olmSession, should probably be called olmSession
+        this.session = null;
+    }
+
+    static fromOTK(device, oneTimeKey) {
+        return new EncryptionTarget(device, oneTimeKey, null);
+    }
+
+    static fromSessionId(device, sessionId) {
+        return new EncryptionTarget(device, null, sessionId);
+    }
+
+    dispose() {
+        if (this.session) {
+            this.session.free();
+        }
+    }
+}
+
+class EncryptedMessage {
+    constructor(content, device) {
+        this.content = content;
+        this.device = device;
+    }
+}
diff --git a/src/matrix/e2ee/olm/Session.js b/src/matrix/e2ee/olm/Session.js
new file mode 100644
index 00000000..9b5f4db0
--- /dev/null
+++ b/src/matrix/e2ee/olm/Session.js
@@ -0,0 +1,58 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export function createSessionEntry(olmSession, senderKey, timestamp, pickleKey) {
+    return {
+        session: olmSession.pickle(pickleKey),
+        sessionId: olmSession.session_id(),
+        senderKey,
+        lastUsed: timestamp,
+    };
+}
+
+export class Session {
+    constructor(data, pickleKey, olm, isNew = false) {
+        this.data = data;
+        this._olm = olm;
+        this._pickleKey = pickleKey;
+        this.isNew = isNew;
+        this.isModified = isNew;
+    }
+
+    static create(senderKey, olmSession, olm, pickleKey, timestamp) {
+        const data = createSessionEntry(olmSession, senderKey, timestamp, pickleKey);
+        return new Session(data, pickleKey, olm, true);
+    }
+
+    get id() {
+        return this.data.sessionId;
+    }
+
+    load() {
+        const session = new this._olm.Session();
+        session.unpickle(this._pickleKey, this.data.session);
+        return session;
+    }
+
+    unload(olmSession) {
+        olmSession.free();
+    }
+
+    save(olmSession) {
+        this.data.session = olmSession.pickle(this._pickleKey);
+        this.isModified = true;
+    }
+}
diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js
index 234c8bc3..649a7462 100644
--- a/src/matrix/net/HomeServerApi.js
+++ b/src/matrix/net/HomeServerApi.js
@@ -141,14 +141,15 @@ export class HomeServerApi {
             {}, {}, options);
     }
 
-    passwordLogin(username, password, options = null) {
+    passwordLogin(username, password, initialDeviceDisplayName, options = null) {
         return this._post("/login", null, {
           "type": "m.login.password",
           "identifier": {
             "type": "m.id.user",
             "user": username
           },
-          "password": password
+          "password": password,
+          "initial_device_display_name": initialDeviceDisplayName
         }, options);
     }
 
@@ -160,6 +161,22 @@ export class HomeServerApi {
         return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
     }
 
+    uploadKeys(payload, options = null) {
+        return this._post("/keys/upload", null, payload, options);
+    }
+
+    queryKeys(queryRequest, options = null) {
+        return this._post("/keys/query", null, queryRequest, options);
+    }
+
+    claimKeys(payload, options = null) {
+        return this._post("/keys/claim", null, payload, options);
+    }
+
+    sendToDevice(type, payload, txnId, options = null) {
+        return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, payload, options);
+    }
+
     get mediaRepository() {
         return this._mediaRepository;
     }
diff --git a/src/matrix/room/Room.js b/src/matrix/room/Room.js
index 27031203..1ea18b4e 100644
--- a/src/matrix/room/Room.js
+++ b/src/matrix/room/Room.js
@@ -25,9 +25,13 @@ import {WrappedError} from "../error.js"
 import {fetchOrLoadMembers} from "./members/load.js";
 import {MemberList} from "./members/MemberList.js";
 import {Heroes} from "./members/Heroes.js";
+import {EventEntry} from "./timeline/entries/EventEntry.js";
+import {DecryptionSource} from "../e2ee/common.js";
+
+const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
 
 export class Room extends EventEmitter {
-	constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) {
+	constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) {
         super();
         this._roomId = roomId;
         this._storage = storage;
@@ -40,17 +44,133 @@ export class Room extends EventEmitter {
         this._timeline = null;
         this._user = user;
         this._changedMembersDuringSync = null;
+        this._memberList = null;
+        this._createRoomEncryption = createRoomEncryption;
+        this._roomEncryption = null;
+        this._getSyncToken = getSyncToken;
+        this._clock = clock;
 	}
 
+    async notifyRoomKeys(roomKeys) {
+        if (this._roomEncryption) {
+            let retryEventIds = this._roomEncryption.applyRoomKeys(roomKeys);
+            if (retryEventIds.length) {
+                const retryEntries = [];
+                const txn = await this._storage.readTxn([
+                    this._storage.storeNames.timelineEvents,
+                    this._storage.storeNames.inboundGroupSessions,
+                ]);
+                for (const eventId of retryEventIds) {
+                    const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
+                    if (storageEntry) {
+                        retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer));
+                    }
+                }
+                const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
+                await decryptRequest.complete();
+                if (this._timeline) {
+                    // only adds if already present
+                    this._timeline.replaceEntries(retryEntries);
+                }
+                // pass decryptedEntries to roomSummary
+            }
+        }
+    }
+
+    _enableEncryption(encryptionParams) {
+        this._roomEncryption = this._createRoomEncryption(this, encryptionParams);
+        if (this._roomEncryption) {
+            this._sendQueue.enableEncryption(this._roomEncryption);
+            if (this._timeline) {
+                this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
+            }
+        }
+    }
+
+    /**
+     * Used for decrypting when loading/filling the timeline, and retrying decryption,
+     * not during sync, where it is split up during the multiple phases.
+     */
+    _decryptEntries(source, entries, inboundSessionTxn = null) {
+        const request = new DecryptionRequest(async r => {
+            if (!inboundSessionTxn) {
+                inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
+            }
+            if (r.cancelled) return;
+            const events = entries.filter(entry => {
+                return entry.eventType === EVENT_ENCRYPTED_TYPE;
+            }).map(entry => entry.event);
+            const isTimelineOpen = this._isTimelineOpen;
+            r.preparation = await this._roomEncryption.prepareDecryptAll(events, source, isTimelineOpen, inboundSessionTxn);
+            if (r.cancelled) return;
+            const changes = await r.preparation.decrypt();
+            r.preparation = null;
+            if (r.cancelled) return;
+            const stores = [this._storage.storeNames.groupSessionDecryptions];
+            if (isTimelineOpen) {
+                // read to fetch devices if timeline is open
+                stores.push(this._storage.storeNames.deviceIdentities);
+            }
+            const writeTxn = await this._storage.readWriteTxn(stores);
+            let decryption;
+            try {
+                decryption = await changes.write(writeTxn);
+            } catch (err) {
+                writeTxn.abort();
+                throw err;
+            }
+            await writeTxn.complete();
+            decryption.applyToEntries(entries);
+        });
+        return request;
+    }
+
+    get needsPrepareSync() {
+        // only encrypted rooms need the prepare sync steps
+        return !!this._roomEncryption;
+    }
+
+    async prepareSync(roomResponse, txn) {
+        if (this._roomEncryption) {
+            const events = roomResponse?.timeline?.events;
+            if (Array.isArray(events)) {
+                const eventsToDecrypt = events.filter(event => {
+                    return event?.type === EVENT_ENCRYPTED_TYPE;
+                });
+                const preparation = await this._roomEncryption.prepareDecryptAll(
+                    eventsToDecrypt, DecryptionSource.Sync, this._isTimelineOpen, txn);
+                return preparation;
+            }
+        }
+    }
+
+    async afterPrepareSync(preparation) {
+        if (preparation) {
+            const decryptChanges = await preparation.decrypt();
+            return decryptChanges;
+        }
+    }
+
     /** @package */
-    async writeSync(roomResponse, membership, isInitialSync, txn) {
-        const isTimelineOpen = !!this._timeline;
+    async writeSync(roomResponse, membership, isInitialSync, decryptChanges, txn) {
+        let decryption;
+        if (this._roomEncryption && decryptChanges) {
+            decryption = await decryptChanges.write(txn);
+        }
+		const {entries, newLiveKey, memberChanges} =
+            await this._syncWriter.writeSync(roomResponse, txn);
+        if (decryption) {
+            decryption.applyToEntries(entries);
+        }
+        // pass member changes to device tracker
+        if (this._roomEncryption && this.isTrackingMembers && memberChanges?.size) {
+            await this._roomEncryption.writeMemberChanges(memberChanges, txn);
+        }
 		const summaryChanges = this._summary.writeSync(
             roomResponse,
             membership,
-            isInitialSync, isTimelineOpen,
+            isInitialSync, this._isTimelineOpen,
             txn);
-		const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
         // fetch new members while we have txn open,
         // but don't make any in-memory changes yet
         let heroChanges;
@@ -59,7 +179,7 @@ export class Room extends EventEmitter {
             if (!this._heroes) {
                 this._heroes = new Heroes(this._roomId);
             }
-            heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, changedMembers, txn);
+            heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, memberChanges, txn);
         }
         let removedPendingEvents;
         if (roomResponse.timeline && roomResponse.timeline.events) {
@@ -70,22 +190,29 @@ export class Room extends EventEmitter {
             newTimelineEntries: entries,
             newLiveKey,
             removedPendingEvents,
-            changedMembers,
-            heroChanges
+            memberChanges,
+            heroChanges,
         };
     }
 
-    /** @package */
-    afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers, heroChanges}) {
+    /**
+     * @package
+     * Called with the changes returned from `writeSync` to apply them and emit changes.
+     * No storage or network operations should be done here.
+     */
+    afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) {
         this._syncWriter.afterSync(newLiveKey);
-        if (changedMembers.length) {
+        if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) {
+            this._enableEncryption(summaryChanges.encryption);
+        }
+        if (memberChanges.size) {
             if (this._changedMembersDuringSync) {
-                for (const member of changedMembers) {
-                    this._changedMembersDuringSync.set(member.userId, member);
+                for (const [userId, memberChange] of memberChanges.entries()) {
+                    this._changedMembersDuringSync.set(userId, memberChange.member);
                 }
             }
             if (this._memberList) {
-                this._memberList.afterSync(changedMembers);
+                this._memberList.afterSync(memberChanges);
             }
         }
         let emitChange = false;
@@ -115,8 +242,35 @@ export class Room extends EventEmitter {
         }
 	}
 
+    needsAfterSyncCompleted({memberChanges}) {
+        return this._roomEncryption?.needsToShareKeys(memberChanges);
+    }
+
+    /**
+     * Only called if the result of writeSync had `needsAfterSyncCompleted` set.
+     * Can be used to do longer running operations that resulted from the last sync,
+     * like network operations.
+     */
+    async afterSyncCompleted() {
+        if (this._roomEncryption) {
+            await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi);
+        }
+    }
+
     /** @package */
-    resumeSending() {
+    async start(pendingOperations) {
+        if (this._roomEncryption) {
+            try {
+                const roomKeyShares = pendingOperations?.get("share_room_key");
+                if (roomKeyShares) {
+                    // if we got interrupted last time sending keys to newly joined members
+                    await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, roomKeyShares);
+                }
+            } catch (err) {
+                // we should not throw here
+                console.error(`could not send out (all) pending room keys for room ${this.id}`, err.stack);
+            }
+        }
         this._sendQueue.resumeSending();
     }
 
@@ -124,6 +278,9 @@ export class Room extends EventEmitter {
 	async load(summary, txn) {
         try {
             this._summary.load(summary);
+            if (this._summary.encryption) {
+                this._enableEncryption(this._summary.encryption);
+            }
             // need to load members for name?
             if (this._summary.needsHeroes) {
                 this._heroes = new Heroes(this._roomId);
@@ -144,6 +301,7 @@ export class Room extends EventEmitter {
     /** @public */
     async loadMemberList() {
         if (this._memberList) {
+            // TODO: also await fetchOrLoadMembers promise here
             this._memberList.retain();
             return this._memberList;
         } else {
@@ -152,6 +310,7 @@ export class Room extends EventEmitter {
                 roomId: this._roomId,
                 hsApi: this._hsApi,
                 storage: this._storage,
+                syncToken: this._getSyncToken(),
                 // to handle race between /members and /sync
                 setChangedMembersMap: map => this._changedMembersDuringSync = map,
             });
@@ -193,7 +352,7 @@ export class Room extends EventEmitter {
             const gapWriter = new GapWriter({
                 roomId: this._roomId,
                 storage: this._storage,
-                fragmentIdComparer: this._fragmentIdComparer
+                fragmentIdComparer: this._fragmentIdComparer,
             });
             gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn);
         } catch (err) {
@@ -201,6 +360,10 @@ export class Room extends EventEmitter {
             throw err;
         }
         await txn.complete();
+        if (this._roomEncryption) {
+            const decryptRequest = this._decryptEntries(DecryptionSource.Timeline, gapResult.entries);
+            await decryptRequest.complete();
+        }
         // once txn is committed, update in-memory state & emit events
         for (const fragment of gapResult.fragments) {
             this._fragmentIdComparer.add(fragment);
@@ -256,6 +419,14 @@ export class Room extends EventEmitter {
         return !!(tags && tags['m.lowpriority']);
     }
 
+    get isEncrypted() {
+        return !!this._summary.encryption;
+    }
+
+    get isTrackingMembers() {
+        return this._summary.isTrackingMembers;
+    }
+
     async _getLastEventId() {
         const lastKey = this._syncWriter.lastMessageKey;
         if (lastKey) {
@@ -267,6 +438,10 @@ export class Room extends EventEmitter {
         }
     }
 
+    get _isTimelineOpen() {
+        return !!this._timeline;
+    }
+
     async clearUnread() {
         if (this.isUnread || this.notificationCount) {
             const txn = await this._storage.readWriteTxn([
@@ -299,7 +474,7 @@ export class Room extends EventEmitter {
     }
 
     /** @public */
-    async openTimeline() {
+    openTimeline() {
         if (this._timeline) {
             throw new Error("not dealing with load race here for now");
         }
@@ -312,15 +487,53 @@ export class Room extends EventEmitter {
             closeCallback: () => {
                 console.log(`closing the timeline for ${this._roomId}`);
                 this._timeline = null;
+                if (this._roomEncryption) {
+                    this._roomEncryption.notifyTimelineClosed();
+                }
             },
             user: this._user,
+            clock: this._clock
         });
-        await this._timeline.load();
+        if (this._roomEncryption) {
+            this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
+        }
         return this._timeline;
     }
 
     get mediaRepository() {
         return this._hsApi.mediaRepository;
     }
+
+    /** @package */
+    writeIsTrackingMembers(value, txn) {
+        return this._summary.writeIsTrackingMembers(value, txn);
+    }
+
+    /** @package */
+    applyIsTrackingMembersChanges(changes) {
+        this._summary.applyChanges(changes);
+    }
 }
 
+class DecryptionRequest {
+    constructor(decryptFn) {
+        this._cancelled = false;
+        this.preparation = null;
+        this._promise = decryptFn(this);
+    }
+
+    complete() {
+        return this._promise;
+    }
+
+    get cancelled() {
+        return this._cancelled;
+    }
+
+    dispose() {
+        this._cancelled = true;
+        if (this.preparation) {
+            this.preparation.dispose();
+        }
+    }
+}
diff --git a/src/matrix/room/RoomSummary.js b/src/matrix/room/RoomSummary.js
index e910c4bc..270fa690 100644
--- a/src/matrix/room/RoomSummary.js
+++ b/src/matrix/room/RoomSummary.js
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
+
 function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
     if (roomResponse.summary) {
         data = updateSummary(data, roomResponse.summary);
@@ -29,12 +31,8 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime
     if (roomResponse.state) {
         data = roomResponse.state.events.reduce(processStateEvent, data);
     }
-    if (roomResponse.timeline) {
-        const {timeline} = roomResponse;
-        if (timeline.prev_batch) {
-            data = data.cloneIfNeeded();
-            data.lastPaginationToken = timeline.prev_batch;
-        }
+    const {timeline} = roomResponse;
+    if (timeline && Array.isArray(timeline.events)) {
         data = timeline.events.reduce((data, event) => {
             if (typeof event.state_key === "string") {
                 return processStateEvent(data, event);
@@ -68,9 +66,10 @@ function processRoomAccountData(data, event) {
 
 function processStateEvent(data, event) {
     if (event.type === "m.room.encryption") {
-        if (!data.isEncrypted) {
+        const algorithm = event.content?.algorithm;
+        if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
             data = data.cloneIfNeeded();
-            data.isEncrypted = true;
+            data.encryption = event.content;
         }
     } else if (event.type === "m.room.name") {
         const newName = event.content?.name;
@@ -113,7 +112,9 @@ function updateSummary(data, summary) {
     const heroes = summary["m.heroes"];
     const joinCount = summary["m.joined_member_count"];
     const inviteCount = summary["m.invited_member_count"];
-
+    // TODO: we could easily calculate if all members are available here and set hasFetchedMembers?
+    // so we can avoid calling /members...
+    // we'd need to do a count query in the roomMembers store though ...
     if (heroes && Array.isArray(heroes)) {
         data = data.cloneIfNeeded();
         data.heroes = heroes;
@@ -136,7 +137,7 @@ class SummaryData {
         this.lastMessageBody = copy ? copy.lastMessageBody : null;
         this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
         this.isUnread = copy ? copy.isUnread : false;
-        this.isEncrypted = copy ? copy.isEncrypted : false;
+        this.encryption = copy ? copy.encryption : null;
         this.isDirectMessage = copy ? copy.isDirectMessage : false;
         this.membership = copy ? copy.membership : null;
         this.inviteCount = copy ? copy.inviteCount : 0;
@@ -144,7 +145,7 @@ class SummaryData {
         this.heroes = copy ? copy.heroes : null;
         this.canonicalAlias = copy ? copy.canonicalAlias : null;
         this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false;
-        this.lastPaginationToken = copy ? copy.lastPaginationToken : null;
+        this.isTrackingMembers = copy ? copy.isTrackingMembers : false;
         this.avatarUrl = copy ? copy.avatarUrl : null;
         this.notificationCount = copy ? copy.notificationCount : 0;
         this.highlightCount = copy ? copy.highlightCount : 0;
@@ -190,6 +191,11 @@ export class RoomSummary {
         return this._data.heroes;
     }
 
+    get encryption() {
+        return this._data.encryption;
+    }
+
+    // whether the room name should be determined with Heroes
     get needsHeroes() {
         return needsHeroes(this._data);
     }
@@ -230,10 +236,10 @@ export class RoomSummary {
         return this._data.hasFetchedMembers;
     }
 
-    get lastPaginationToken() {
-        return this._data.lastPaginationToken;
+    get isTrackingMembers() {
+        return this._data.isTrackingMembers;
     }
-
+    
     get tags() {
         return this._data.tags;
     }
@@ -254,6 +260,13 @@ export class RoomSummary {
         return data;
     }
 
+    writeIsTrackingMembers(value, txn) {
+        const data = new SummaryData(this._data);
+        data.isTrackingMembers = value;
+        txn.roomSummary.set(data.serialize());
+        return data;
+    }
+
 	writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
         // clear cloned flag, so cloneIfNeeded makes a copy and
         // this._data is not modified if any field is changed.
diff --git a/src/matrix/room/common.js b/src/matrix/room/common.js
new file mode 100644
index 00000000..922ca115
--- /dev/null
+++ b/src/matrix/room/common.js
@@ -0,0 +1,21 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export function getPrevContentFromStateEvent(event) {
+    // where to look for prev_content is a bit of a mess,
+    // see https://matrix.to/#/!NasysSDfxKxZBzJJoE:matrix.org/$DvrAbZJiILkOmOIuRsNoHmh2v7UO5CWp_rYhlGk34fQ?via=matrix.org&via=pixie.town&via=amorgan.xyz
+    return event.unsigned?.prev_content || event.prev_content;
+}
diff --git a/src/matrix/room/members/Heroes.js b/src/matrix/room/members/Heroes.js
index f7ccb2df..809b61c2 100644
--- a/src/matrix/room/members/Heroes.js
+++ b/src/matrix/room/members/Heroes.js
@@ -42,11 +42,11 @@ export class Heroes {
 
     /**
      * @param  {string[]} newHeroes      array of user ids
-     * @param  {RoomMember[]} changedMembers array of changed members in this sync
+     * @param  {Map} memberChanges map of changed memberships
      * @param  {Transaction} txn
      * @return {Promise}
      */
-    async calculateChanges(newHeroes, changedMembers, txn) {
+    async calculateChanges(newHeroes, memberChanges, txn) {
         const updatedHeroMembers = new Map();
         const removedUserIds = [];
         // remove non-present members
@@ -56,9 +56,9 @@ export class Heroes {
             }
         }
         // update heroes with synced member changes
-        for (const member of changedMembers) {
-            if (this._members.has(member.userId) || newHeroes.indexOf(member.userId) !== -1) {
-                updatedHeroMembers.set(member.userId, member);
+        for (const [userId, memberChange] of memberChanges.entries()) {
+            if (this._members.has(userId) || newHeroes.indexOf(userId) !== -1) {
+                updatedHeroMembers.set(userId, memberChange.member);
             }
         }
         // load member for new heroes from storage
diff --git a/src/matrix/room/members/MemberList.js b/src/matrix/room/members/MemberList.js
index f428ed6c..734887fd 100644
--- a/src/matrix/room/members/MemberList.js
+++ b/src/matrix/room/members/MemberList.js
@@ -26,9 +26,9 @@ export class MemberList {
         this._retentionCount = 1;
     }
 
-    afterSync(updatedMembers) {
-        for (const member of updatedMembers) {
-            this._members.add(member.userId, member);
+    afterSync(memberChanges) {
+        for (const [userId, memberChange] of memberChanges.entries()) {
+            this._members.add(userId, memberChange.member);
         }
     }
 
diff --git a/src/matrix/room/members/RoomMember.js b/src/matrix/room/members/RoomMember.js
index 02c3c292..fe55b5aa 100644
--- a/src/matrix/room/members/RoomMember.js
+++ b/src/matrix/room/members/RoomMember.js
@@ -1,5 +1,4 @@
 /*
-Copyright 2020 Bruno Windels 
 Copyright 2020 The Matrix.org Foundation C.I.C.
 
 Licensed under the Apache License, Version 2.0 (the "License");
@@ -15,6 +14,8 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
+import {getPrevContentFromStateEvent} from "../common.js";
+
 export const EVENT_TYPE = "m.room.member";
 
 export class RoomMember {
@@ -28,7 +29,7 @@ export class RoomMember {
             return;
         }
         const content = memberEvent.content;
-        const prevContent = memberEvent.unsigned?.prev_content;
+        const prevContent = getPrevContentFromStateEvent(memberEvent);
         const membership = content?.membership;
         // fall back to prev_content for these as synapse doesn't (always?)
         // put them on content for "leave" memberships
@@ -45,7 +46,7 @@ export class RoomMember {
         if (typeof userId !== "string") {
             return;
         }
-        const content = memberEvent.unsigned?.prev_content
+        const content = getPrevContentFromStateEvent(memberEvent);
         return this._validateAndCreateMember(roomId, userId,
             content?.membership,
             content?.displayname,
@@ -66,6 +67,10 @@ export class RoomMember {
         });
     }
 
+    get membership() {
+        return this._data.membership;
+    }
+
     /**
      * @return {String?} the display name, if any
      */
@@ -99,3 +104,42 @@ export class RoomMember {
         return this._data;
     }
 }
+
+export class MemberChange {
+    constructor(roomId, memberEvent) {
+        this._roomId = roomId;
+        this._memberEvent = memberEvent;
+        this._member = null;
+    }
+
+    get member() {
+        if (!this._member) {
+            this._member = RoomMember.fromMemberEvent(this._roomId, this._memberEvent);
+        }
+        return this._member;
+    }
+
+    get roomId() {
+        return this._roomId;
+    }
+
+    get userId() {
+        return this._memberEvent.state_key;
+    }
+
+    get previousMembership() {
+        return getPrevContentFromStateEvent(this._memberEvent)?.membership;
+    }
+
+    get membership() {
+        return this._memberEvent.content?.membership;
+    }
+
+    get hasLeft() {
+        return this.previousMembership === "join" && this.membership !== "join";
+    }
+
+    get hasJoined() {
+        return this.previousMembership !== "join" && this.membership === "join";
+    }
+}
diff --git a/src/matrix/room/members/load.js b/src/matrix/room/members/load.js
index 54d7c3dc..18fc4eb4 100644
--- a/src/matrix/room/members/load.js
+++ b/src/matrix/room/members/load.js
@@ -25,13 +25,13 @@ async function loadMembers({roomId, storage}) {
     return memberDatas.map(d => new RoomMember(d));
 }
 
-async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersMap}) {
+async function fetchMembers({summary, syncToken, roomId, hsApi, storage, setChangedMembersMap}) {
     // if any members are changed by sync while we're fetching members,
     // they will end up here, so we check not to override them
     const changedMembersDuringSync = new Map();
     setChangedMembersMap(changedMembersDuringSync);
     
-    const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response;
+    const memberResponse = await hsApi.members(roomId, {at: syncToken}).response();
 
     const txn = await storage.readWriteTxn([
         storage.storeNames.roomSummary,
diff --git a/src/matrix/room/sending/PendingEvent.js b/src/matrix/room/sending/PendingEvent.js
index 2b4f7477..fb2d1a47 100644
--- a/src/matrix/room/sending/PendingEvent.js
+++ b/src/matrix/room/sending/PendingEvent.js
@@ -26,5 +26,12 @@ export class PendingEvent {
     get remoteId() { return this._data.remoteId; }
     set remoteId(value) { this._data.remoteId = value; }
     get content() { return this._data.content; }
+    get needsEncryption() { return this._data.needsEncryption; }
     get data() { return this._data; }
+
+    setEncrypted(type, content) {
+        this._data.eventType = type;
+        this._data.content = content;
+        this._data.needsEncryption = false;
+    }
 }
diff --git a/src/matrix/room/sending/SendQueue.js b/src/matrix/room/sending/SendQueue.js
index ba215e04..fe7afe77 100644
--- a/src/matrix/room/sending/SendQueue.js
+++ b/src/matrix/room/sending/SendQueue.js
@@ -17,12 +17,7 @@ limitations under the License.
 import {SortedArray} from "../../../observable/list/SortedArray.js";
 import {ConnectionError} from "../../error.js";
 import {PendingEvent} from "./PendingEvent.js";
-
-function makeTxnId() {
-    const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
-    const str = n.toString(16);
-    return "t" + "0".repeat(14 - str.length) + str;
-}
+import {makeTxnId} from "../../common.js";
 
 export class SendQueue {
     constructor({roomId, storage, sendScheduler, pendingEvents}) {
@@ -38,6 +33,11 @@ export class SendQueue {
         this._isSending = false;
         this._offline = false;
         this._amountSent = 0;
+        this._roomEncryption = null;
+    }
+
+    enableEncryption(roomEncryption) {
+        this._roomEncryption = roomEncryption;
     }
 
     async _sendLoop() {
@@ -50,6 +50,13 @@ export class SendQueue {
                 if (pendingEvent.remoteId) {
                     continue;
                 }
+                if (pendingEvent.needsEncryption) {
+                    const {type, content} = await this._sendScheduler.request(async hsApi => {
+                        return await this._roomEncryption.encrypt(pendingEvent.eventType, pendingEvent.content, hsApi);
+                    });
+                    pendingEvent.setEncrypted(type, content);
+                    await this._tryUpdateEvent(pendingEvent);
+                }
                 console.log("really sending now");
                 const response = await this._sendScheduler.request(hsApi => {
                     console.log("got sendScheduler slot");
@@ -161,7 +168,8 @@ export class SendQueue {
                 queueIndex,
                 eventType,
                 content,
-                txnId: makeTxnId()
+                txnId: makeTxnId(),
+                needsEncryption: !!this._roomEncryption
             });
             console.log("_createAndStoreEvent: adding to pendingEventsStore");
             pendingEventsStore.add(pendingEvent.data);
diff --git a/src/matrix/room/timeline/Timeline.js b/src/matrix/room/timeline/Timeline.js
index a64be169..6247b4c7 100644
--- a/src/matrix/room/timeline/Timeline.js
+++ b/src/matrix/room/timeline/Timeline.js
@@ -15,24 +15,27 @@ limitations under the License.
 */
 
 import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js";
+import {Disposables} from "../../../utils/Disposables.js";
 import {Direction} from "./Direction.js";
 import {TimelineReader} from "./persistence/TimelineReader.js";
 import {PendingEventEntry} from "./entries/PendingEventEntry.js";
 
 export class Timeline {
-    constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) {
+    constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user, clock}) {
         this._roomId = roomId;
         this._storage = storage;
         this._closeCallback = closeCallback;
         this._fragmentIdComparer = fragmentIdComparer;
+        this._disposables = new Disposables();
         this._remoteEntries = new SortedArray((a, b) => a.compare(b));
         this._timelineReader = new TimelineReader({
             roomId: this._roomId,
             storage: this._storage,
             fragmentIdComparer: this._fragmentIdComparer
         });
+        this._readerRequest = null;
         const localEntries = new MappedList(pendingEvents, pe => {
-            return new PendingEventEntry({pendingEvent: pe, user});
+            return new PendingEventEntry({pendingEvent: pe, user, clock});
         }, (pee, params) => {
             pee.notifyUpdate(params);
         });
@@ -41,8 +44,20 @@ export class Timeline {
 
     /** @package */
     async load() {
-        const entries = await this._timelineReader.readFromEnd(50);
-        this._remoteEntries.setManySorted(entries);
+        // 30 seems to be a good amount to fill the entire screen
+        const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30));
+        try {
+            const entries = await readerRequest.complete();
+            this._remoteEntries.setManySorted(entries);
+        } finally {
+            this._disposables.disposeTracked(readerRequest);
+        }
+    }
+
+    replaceEntries(entries) {
+        for (const entry of entries) {
+            this._remoteEntries.replace(entry);
+        }
     }
 
     // TODO: should we rather have generic methods for
@@ -64,12 +79,17 @@ export class Timeline {
         if (!firstEventEntry) {
             return;
         }
-        const entries = await this._timelineReader.readFrom(
+        const readerRequest = this._disposables.track(this._timelineReader.readFrom(
             firstEventEntry.asEventKey(),
             Direction.Backward,
             amount
-        );
-        this._remoteEntries.setManySorted(entries);
+        ));
+        try {
+            const entries = await readerRequest.complete();
+            this._remoteEntries.setManySorted(entries);
+        } finally {
+            this._disposables.disposeTracked(readerRequest);
+        }
     }
 
     /** @public */
@@ -78,10 +98,15 @@ export class Timeline {
     }
 
     /** @public */
-    close() {
+    dispose() {
         if (this._closeCallback) {
+            this._disposables.dispose();
             this._closeCallback();
             this._closeCallback = null;
         }
     }
+
+    enableEncryption(decryptEntries) {
+        this._timelineReader.enableEncryption(decryptEntries);
+    }
 }
diff --git a/src/matrix/room/timeline/entries/EventEntry.js b/src/matrix/room/timeline/entries/EventEntry.js
index d1d5b64c..8c7029d4 100644
--- a/src/matrix/room/timeline/entries/EventEntry.js
+++ b/src/matrix/room/timeline/entries/EventEntry.js
@@ -15,11 +15,18 @@ limitations under the License.
 */
 
 import {BaseEntry} from "./BaseEntry.js";
+import {getPrevContentFromStateEvent} from "../../common.js";
 
 export class EventEntry extends BaseEntry {
     constructor(eventEntry, fragmentIdComparer) {
         super(fragmentIdComparer);
         this._eventEntry = eventEntry;
+        this._decryptionError = null;
+        this._decryptionResult = null;
+    }
+
+    get event() {
+        return this._eventEntry.event;
     }
 
     get fragmentId() {
@@ -31,15 +38,16 @@ export class EventEntry extends BaseEntry {
     }
 
     get content() {
-        return this._eventEntry.event.content;
+        return this._decryptionResult?.event?.content || this._eventEntry.event.content;
     }
 
     get prevContent() {
-        return this._eventEntry.event.unsigned?.prev_content;
+        // doesn't look at _decryptionResult because state events are not encrypted
+        return getPrevContentFromStateEvent(this._eventEntry.event);
     }
 
     get eventType() {
-        return this._eventEntry.event.type;
+        return this._decryptionResult?.event?.type || this._eventEntry.event.type;
     }
 
     get stateKey() {
@@ -65,4 +73,24 @@ export class EventEntry extends BaseEntry {
     get id() {
         return this._eventEntry.event.event_id;
     }
+
+    setDecryptionResult(result) {
+        this._decryptionResult = result;
+    }
+
+    get isEncrypted() {
+        return this._eventEntry.event.type === "m.room.encrypted";
+    }
+
+    get isVerified() {
+        return this.isEncrypted && this._decryptionResult?.isVerified;
+    }
+
+    get isUnverified() {
+        return this.isEncrypted && this._decryptionResult?.isUnverified;
+    }
+
+    setDecryptionError(err) {
+        this._decryptionError = err;
+    }
 }
diff --git a/src/matrix/room/timeline/entries/PendingEventEntry.js b/src/matrix/room/timeline/entries/PendingEventEntry.js
index e5fd769c..f9376eab 100644
--- a/src/matrix/room/timeline/entries/PendingEventEntry.js
+++ b/src/matrix/room/timeline/entries/PendingEventEntry.js
@@ -17,10 +17,11 @@ limitations under the License.
 import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js";
 
 export class PendingEventEntry extends BaseEntry {
-    constructor({pendingEvent, user}) {
+    constructor({pendingEvent, user, clock}) {
         super(null);
         this._pendingEvent = pendingEvent;
         this._user = user;
+        this._clock = clock;
     }
 
     get fragmentId() {
@@ -52,7 +53,7 @@ export class PendingEventEntry extends BaseEntry {
     }
 
     get timestamp() {
-        return null;
+        return this._clock.now();
     }
 
     get isPending() {
diff --git a/src/matrix/room/timeline/persistence/SyncWriter.js b/src/matrix/room/timeline/persistence/SyncWriter.js
index 84e8a18f..7f2275b1 100644
--- a/src/matrix/room/timeline/persistence/SyncWriter.js
+++ b/src/matrix/room/timeline/persistence/SyncWriter.js
@@ -18,7 +18,7 @@ import {EventKey} from "../EventKey.js";
 import {EventEntry} from "../entries/EventEntry.js";
 import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
 import {createEventEntry} from "./common.js";
-import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
+import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
 
 // Synapse bug? where the m.room.create event appears twice in sync response
 // when first syncing the room
@@ -98,41 +98,41 @@ export class SyncWriter {
         return {oldFragment, newFragment};
     }
 
+    _writeMember(event, txn) {
+        const userId = event.state_key;
+        if (userId) {
+            const memberChange = new MemberChange(this._roomId, event);
+            const {member} = memberChange;
+            if (member) {
+                txn.roomMembers.set(member.serialize());
+                return memberChange;
+            }
+        }
+    }
+
     _writeStateEvent(event, txn) {
         if (event.type === MEMBER_EVENT_TYPE) {
-            const userId = event.state_key;
-            if (userId) {
-                const member = RoomMember.fromMemberEvent(this._roomId, event);
-                if (member) {
-                    // as this is sync, we can just replace the member
-                    // if it is there already
-                    txn.roomMembers.set(member.serialize());
-                }
-                return member;
-            }
+            return this._writeMember(event, txn);
         } else {
             txn.roomState.set(this._roomId, event);
         }
     }
 
-    _writeStateEvents(roomResponse, txn) {
-        const changedMembers = [];
+    _writeStateEvents(roomResponse, memberChanges, txn) {
         // persist state
         const {state} = roomResponse;
         if (Array.isArray(state?.events)) {
             for (const event of state.events) {
-                const member = this._writeStateEvent(event, txn);
-                if (member) {
-                    changedMembers.push(member);
+                const memberChange = this._writeStateEvent(event, txn);
+                if (memberChange) {
+                    memberChanges.set(memberChange.userId, memberChange);
                 }
             }
         }
-        return changedMembers;
     }
 
-    async _writeTimeline(entries, timeline, currentKey, txn) {
-        const changedMembers = [];
-        if (timeline.events) {
+    async _writeTimeline(entries, timeline, currentKey, memberChanges, txn) {
+        if (Array.isArray(timeline.events)) {
             const events = deduplicateEvents(timeline.events);
             for(const event of events) {
                 // store event in timeline
@@ -145,17 +145,17 @@ export class SyncWriter {
                 }
                 txn.timelineEvents.insert(entry);
                 entries.push(new EventEntry(entry, this._fragmentIdComparer));
-                
+
                 // process live state events first, so new member info is available
                 if (typeof event.state_key === "string") {
-                    const member = this._writeStateEvent(event, txn);
-                    if (member) {
-                        changedMembers.push(member);
+                    const memberChange = this._writeStateEvent(event, txn);
+                    if (memberChange) {
+                        memberChanges.set(memberChange.userId, memberChange);
                     }
                 }
             }
         }
-        return {currentKey, changedMembers};
+        return currentKey;
     }
 
     async _findMemberData(userId, events, txn) {
@@ -176,6 +176,16 @@ export class SyncWriter {
         }
     }
 
+    /**
+     * @type {SyncWriterResult}
+     * @property {Array} entries new timeline entries written
+     * @property {EventKey} newLiveKey the advanced key to write events at
+     * @property {Map} memberChanges member changes in the processed sync ny user id
+     * 
+     * @param  {Object}  roomResponse [description]
+     * @param  {Transaction}  txn     
+     * @return {SyncWriterResult}
+     */
     async writeSync(roomResponse, txn) {
         const entries = [];
         const {timeline} = roomResponse;
@@ -196,14 +206,12 @@ export class SyncWriter {
             entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer));
             entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer));
         }
+        const memberChanges = new Map();
         // important this happens before _writeTimeline so
         // members are available in the transaction
-        const changedMembers = this._writeStateEvents(roomResponse, txn);
-        const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn);
-        currentKey = timelineResult.currentKey;
-        changedMembers.push(...timelineResult.changedMembers);
-
-        return {entries, newLiveKey: currentKey, changedMembers};
+        this._writeStateEvents(roomResponse, memberChanges, txn);
+        currentKey = await this._writeTimeline(entries, timeline, currentKey, memberChanges, txn);
+        return {entries, newLiveKey: currentKey, memberChanges};
     }
 
     afterSync(newLiveKey) {
diff --git a/src/matrix/room/timeline/persistence/TimelineReader.js b/src/matrix/room/timeline/persistence/TimelineReader.js
index 928d6b64..f5983a19 100644
--- a/src/matrix/room/timeline/persistence/TimelineReader.js
+++ b/src/matrix/room/timeline/persistence/TimelineReader.js
@@ -19,26 +19,74 @@ import {Direction} from "../Direction.js";
 import {EventEntry} from "../entries/EventEntry.js";
 import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
 
+class ReaderRequest {
+    constructor(fn) {
+        this.decryptRequest = null;
+        this._promise = fn(this);
+    }
+
+    complete() {
+        return this._promise;
+    }
+
+    dispose() {
+        if (this.decryptRequest) {
+            this.decryptRequest.dispose();
+            this.decryptRequest = null;
+        }
+    }
+}
+
 export class TimelineReader {
     constructor({roomId, storage, fragmentIdComparer}) {
         this._roomId = roomId;
         this._storage = storage;
         this._fragmentIdComparer = fragmentIdComparer;
+        this._decryptEntries = null;
+    }
+
+    enableEncryption(decryptEntries) {
+        this._decryptEntries = decryptEntries;
     }
 
     _openTxn() {
-        return this._storage.readTxn([
+        const stores = [
             this._storage.storeNames.timelineEvents,
             this._storage.storeNames.timelineFragments,
-        ]);
+        ];
+        if (this._decryptEntries) {
+            stores.push(this._storage.storeNames.inboundGroupSessions);
+        }
+        return this._storage.readTxn(stores);
     }
 
-    async readFrom(eventKey, direction, amount) {
-        const txn = await this._openTxn();
-        return this._readFrom(eventKey, direction, amount, txn);
+    readFrom(eventKey, direction, amount) {
+        return new ReaderRequest(async r => {
+            const txn = await this._openTxn();
+            return await this._readFrom(eventKey, direction, amount, r, txn);
+        });
     }
 
-    async _readFrom(eventKey, direction, amount, txn) {
+    readFromEnd(amount) {
+        return new ReaderRequest(async r => {
+            const txn = await this._openTxn();
+            const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
+            let entries;
+            // room hasn't been synced yet
+            if (!liveFragment) {
+                entries = [];
+            } else {
+                this._fragmentIdComparer.add(liveFragment);
+                const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
+                const eventKey = liveFragmentEntry.asEventKey();
+                entries = await this._readFrom(eventKey, Direction.Backward, amount, r, txn);
+                entries.unshift(liveFragmentEntry);
+            }
+            return entries;
+        });
+    }
+
+    async _readFrom(eventKey, direction, amount, r, txn) {
         let entries = [];
         const timelineStore = txn.timelineEvents;
         const fragmentStore = txn.timelineFragments;
@@ -50,7 +98,7 @@ export class TimelineReader {
             } else {
                 eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount);
             }
-            const eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
+            let eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
             entries = directionalConcat(entries, eventEntries, direction);
             // prepend or append eventsWithinFragment to entries, and wrap them in EventEntry
 
@@ -73,27 +121,14 @@ export class TimelineReader {
             }
         }
 
-        return entries;
-    }
-
-    async readFromEnd(amount) {
-        const txn = await this._openTxn();
-        const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
-        // room hasn't been synced yet
-        if (!liveFragment) {
-            return [];
+        if (this._decryptEntries) {
+            r.decryptRequest = this._decryptEntries(entries, txn);
+            try {
+                await r.decryptRequest.complete();
+            } finally {
+                r.decryptRequest = null;
+            }
         }
-        this._fragmentIdComparer.add(liveFragment);
-        const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
-        const eventKey = liveFragmentEntry.asEventKey();
-        const entries = await this._readFrom(eventKey, Direction.Backward, amount, txn);
-        entries.unshift(liveFragmentEntry);
         return entries;
     }
-
-    // reads distance up and down from eventId
-    // or just expose eventIdToKey?
-    readAtEventId(eventId, distance) {
-        return null;
-    }
 }
diff --git a/src/matrix/storage/common.js b/src/matrix/storage/common.js
index 0cf5b9b0..f74dafdc 100644
--- a/src/matrix/storage/common.js
+++ b/src/matrix/storage/common.js
@@ -22,6 +22,13 @@ export const STORE_NAMES = Object.freeze([
     "timelineEvents",
     "timelineFragments",
     "pendingEvents",
+    "userIdentities",
+    "deviceIdentities",
+    "olmSessions",
+    "inboundGroupSessions",
+    "outboundGroupSessions",
+    "groupSessionDecryptions",
+    "operations"
 ]);
 
 export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
diff --git a/src/matrix/storage/idb/QueryTarget.js b/src/matrix/storage/idb/QueryTarget.js
index 0738df60..c0c8ed2c 100644
--- a/src/matrix/storage/idb/QueryTarget.js
+++ b/src/matrix/storage/idb/QueryTarget.js
@@ -42,7 +42,15 @@ export class QueryTarget {
     }
 
     getKey(key) {
-        return reqAsPromise(this._target.getKey(key));
+        if (this._target.supports("getKey")) {
+            return reqAsPromise(this._target.getKey(key));
+        } else {
+            return reqAsPromise(this._target.get(key)).then(value => {
+                if (value) {
+                    return value[this._target.keyPath];
+                }
+            });
+        }
     }
 
     reduce(range, reducer, initialValue) {
@@ -105,6 +113,13 @@ export class QueryTarget {
         return maxKey;
     }
 
+    async iterateKeys(range, callback) {
+        const cursor = this._target.openKeyCursor(range, "next");
+        await iterateCursor(cursor, (_, key) => {
+            return {done: callback(key)};
+        });
+    }
+
     /**
      * Checks if a given set of keys exist.
      * Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).
@@ -180,6 +195,14 @@ export class QueryTarget {
         return results;
     }
 
+    async iterateWhile(range, predicate) {
+        const cursor = this._openCursor(range, "next");
+        await iterateCursor(cursor, (value) => {
+            const passesPredicate = predicate(value);
+            return {done: !passesPredicate};
+        });
+    }
+
     async _find(range, predicate, direction) {
         const cursor = this._openCursor(range, direction);
         let result;
diff --git a/src/matrix/storage/idb/Store.js b/src/matrix/storage/idb/Store.js
index 8cb8fd4c..a0ca0b96 100644
--- a/src/matrix/storage/idb/Store.js
+++ b/src/matrix/storage/idb/Store.js
@@ -23,6 +23,14 @@ class QueryTargetWrapper {
         this._qt = qt;
     }
 
+    get keyPath() {
+        if (this._qt.objectStore) {
+            return this._qt.objectStore.keyPath;
+        } else {
+            return this._qt.keyPath;
+        }
+    }
+
     supports(methodName) {
         return !!this._qt[methodName];
     }
diff --git a/src/matrix/storage/idb/Transaction.js b/src/matrix/storage/idb/Transaction.js
index 4f5e3af5..af6d49ca 100644
--- a/src/matrix/storage/idb/Transaction.js
+++ b/src/matrix/storage/idb/Transaction.js
@@ -24,6 +24,13 @@ import {RoomStateStore} from "./stores/RoomStateStore.js";
 import {RoomMemberStore} from "./stores/RoomMemberStore.js";
 import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
 import {PendingEventStore} from "./stores/PendingEventStore.js";
+import {UserIdentityStore} from "./stores/UserIdentityStore.js";
+import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js";
+import {OlmSessionStore} from "./stores/OlmSessionStore.js";
+import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js";
+import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js";
+import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore.js";
+import {OperationStore} from "./stores/OperationStore.js";
 
 export class Transaction {
     constructor(txn, allowedStoreNames) {
@@ -81,6 +88,34 @@ export class Transaction {
         return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
     }
 
+    get userIdentities() {
+        return this._store("userIdentities", idbStore => new UserIdentityStore(idbStore));
+    }
+
+    get deviceIdentities() {
+        return this._store("deviceIdentities", idbStore => new DeviceIdentityStore(idbStore));
+    }
+    
+    get olmSessions() {
+        return this._store("olmSessions", idbStore => new OlmSessionStore(idbStore));
+    }
+    
+    get inboundGroupSessions() {
+        return this._store("inboundGroupSessions", idbStore => new InboundGroupSessionStore(idbStore));
+    }
+    
+    get outboundGroupSessions() {
+        return this._store("outboundGroupSessions", idbStore => new OutboundGroupSessionStore(idbStore));
+    }
+
+    get groupSessionDecryptions() {
+        return this._store("groupSessionDecryptions", idbStore => new GroupSessionDecryptionStore(idbStore));
+    }
+
+    get operations() {
+        return this._store("operations", idbStore => new OperationStore(idbStore));
+    }
+
     complete() {
         return txnAsPromise(this._txn);
     }
diff --git a/src/matrix/storage/idb/schema.js b/src/matrix/storage/idb/schema.js
index 0ab5707a..809f6729 100644
--- a/src/matrix/storage/idb/schema.js
+++ b/src/matrix/storage/idb/schema.js
@@ -9,6 +9,7 @@ export const schema = [
     createInitialStores,
     createMemberStore,
     migrateSession,
+    createE2EEStores
 ];
 // TODO: how to deal with git merge conflicts of this array?
 
@@ -46,7 +47,7 @@ async function createMemberStore(db, txn) {
         }
     });
 }
-
+//v3
 async function migrateSession(db, txn) {
     const session = txn.objectStore("session");
     try {
@@ -64,3 +65,15 @@ async function migrateSession(db, txn) {
         console.error("could not migrate session", err.stack);
     }
 }
+//v4
+function createE2EEStores(db) {
+    db.createObjectStore("userIdentities", {keyPath: "userId"});
+    const deviceIdentities = db.createObjectStore("deviceIdentities", {keyPath: "key"});
+    deviceIdentities.createIndex("byCurve25519Key", "curve25519Key", {unique: true});
+    db.createObjectStore("olmSessions", {keyPath: "key"});
+    db.createObjectStore("inboundGroupSessions", {keyPath: "key"});
+    db.createObjectStore("outboundGroupSessions", {keyPath: "roomId"});
+    db.createObjectStore("groupSessionDecryptions", {keyPath: "key"});
+    const operations = db.createObjectStore("operations", {keyPath: "id"});
+    operations.createIndex("byTypeAndScope", "typeScopeKey", {unique: false});
+}
diff --git a/src/matrix/storage/idb/stores/DeviceIdentityStore.js b/src/matrix/storage/idb/stores/DeviceIdentityStore.js
new file mode 100644
index 00000000..d3aba963
--- /dev/null
+++ b/src/matrix/storage/idb/stores/DeviceIdentityStore.js
@@ -0,0 +1,45 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+function encodeKey(userId, deviceId) {
+    return `${userId}|${deviceId}`;
+}
+
+export class DeviceIdentityStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    getAllForUserId(userId) {
+        const range = IDBKeyRange.lowerBound(encodeKey(userId, ""));
+        return this._store.selectWhile(range, device => {
+            return device.userId === userId;
+        });
+    }
+
+    get(userId, deviceId) {
+        return this._store.get(encodeKey(userId, deviceId));
+    }
+
+    set(deviceIdentity) {
+        deviceIdentity.key = encodeKey(deviceIdentity.userId, deviceIdentity.deviceId);
+        this._store.put(deviceIdentity);
+    }
+
+    getByCurve25519Key(curve25519Key) {
+        return this._store.index("byCurve25519Key").get(curve25519Key);
+    }
+}
diff --git a/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js
new file mode 100644
index 00000000..8f8df3e7
--- /dev/null
+++ b/src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js
@@ -0,0 +1,34 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+function encodeKey(roomId, sessionId, messageIndex) {
+    return `${roomId}|${sessionId}|${messageIndex}`;
+}
+
+export class GroupSessionDecryptionStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    get(roomId, sessionId, messageIndex) {
+        return this._store.get(encodeKey(roomId, sessionId, messageIndex));
+    }
+
+    set(roomId, sessionId, messageIndex, decryption) {
+        decryption.key = encodeKey(roomId, sessionId, messageIndex);
+        this._store.put(decryption);
+    }
+}
diff --git a/src/matrix/storage/idb/stores/InboundGroupSessionStore.js b/src/matrix/storage/idb/stores/InboundGroupSessionStore.js
new file mode 100644
index 00000000..d05c67ff
--- /dev/null
+++ b/src/matrix/storage/idb/stores/InboundGroupSessionStore.js
@@ -0,0 +1,40 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+function encodeKey(roomId, senderKey, sessionId) {
+    return `${roomId}|${senderKey}|${sessionId}`;
+}
+
+export class InboundGroupSessionStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    async has(roomId, senderKey, sessionId) {
+        const key = encodeKey(roomId, senderKey, sessionId);
+        const fetchedKey = await this._store.getKey(key);
+        return key === fetchedKey;
+    }
+
+    get(roomId, senderKey, sessionId) {
+        return this._store.get(encodeKey(roomId, senderKey, sessionId));
+    }
+
+    set(session) {
+        session.key = encodeKey(session.roomId, session.senderKey, session.sessionId);
+        this._store.put(session);
+    }
+}
diff --git a/src/matrix/storage/idb/stores/OlmSessionStore.js b/src/matrix/storage/idb/stores/OlmSessionStore.js
new file mode 100644
index 00000000..4648f09c
--- /dev/null
+++ b/src/matrix/storage/idb/stores/OlmSessionStore.js
@@ -0,0 +1,65 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+function encodeKey(senderKey, sessionId) {
+    return `${senderKey}|${sessionId}`;
+}
+
+function decodeKey(key) {
+    const [senderKey, sessionId] = key.split("|");
+    return {senderKey, sessionId};
+}
+
+export class OlmSessionStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    async getSessionIds(senderKey) {
+        const sessionIds = [];
+        const range = IDBKeyRange.lowerBound(encodeKey(senderKey, ""));
+        await this._store.iterateKeys(range, key => {
+            const decodedKey = decodeKey(key);
+            // prevent running into the next room
+            if (decodedKey.senderKey === senderKey) {
+                sessionIds.push(decodedKey.sessionId);
+                return false;   // fetch more
+            }
+            return true; // done
+        });
+        return sessionIds;
+    }
+
+    getAll(senderKey) {
+        const range = IDBKeyRange.lowerBound(encodeKey(senderKey, ""));
+        return this._store.selectWhile(range, session => {
+            return session.senderKey === senderKey;
+        });
+    }
+
+    get(senderKey, sessionId) {
+        return this._store.get(encodeKey(senderKey, sessionId));
+    }
+
+    set(session) {
+        session.key = encodeKey(session.senderKey, session.sessionId);
+        return this._store.put(session);
+    }
+
+    remove(senderKey, sessionId) {
+        return this._store.delete(encodeKey(senderKey, sessionId));
+    }
+}
diff --git a/src/matrix/storage/idb/stores/OperationStore.js b/src/matrix/storage/idb/stores/OperationStore.js
new file mode 100644
index 00000000..598f80e7
--- /dev/null
+++ b/src/matrix/storage/idb/stores/OperationStore.js
@@ -0,0 +1,55 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+function encodeTypeScopeKey(type, scope) {
+    return `${type}|${scope}`;
+}
+
+export class OperationStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    getAll() {
+        return this._store.selectAll();
+    }
+
+    async getAllByTypeAndScope(type, scope) {
+        const key = encodeTypeScopeKey(type, scope);
+        const results = [];
+        await this._store.index("byTypeAndScope").iterateWhile(key, value => {
+            if (value.typeScopeKey !== key) {
+                return false;
+            }
+            results.push(value);
+            return true;
+        });
+        return results;
+    }
+
+    add(operation) {
+        operation.typeScopeKey = encodeTypeScopeKey(operation.type, operation.scope);
+        this._store.add(operation);
+    }
+
+    update(operation) {
+        this._store.set(operation);
+    }
+
+    remove(id) {
+        this._store.delete(id);
+    }
+}
diff --git a/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js
new file mode 100644
index 00000000..9710765f
--- /dev/null
+++ b/src/matrix/storage/idb/stores/OutboundGroupSessionStore.js
@@ -0,0 +1,33 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export class OutboundGroupSessionStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    remove(roomId) {
+        this._store.delete(roomId);
+    }
+
+    get(roomId) {
+        return this._store.get(roomId);
+    }
+
+    set(session) {
+        this._store.put(session);
+    }
+}
diff --git a/src/matrix/storage/idb/stores/PendingEventStore.js b/src/matrix/storage/idb/stores/PendingEventStore.js
index 6492a272..00898311 100644
--- a/src/matrix/storage/idb/stores/PendingEventStore.js
+++ b/src/matrix/storage/idb/stores/PendingEventStore.js
@@ -52,13 +52,7 @@ export class PendingEventStore {
 
     async exists(roomId, queueIndex) {
         const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex));
-        let key;
-        if (this._eventStore.supports("getKey")) {
-            key = await this._eventStore.getKey(keyRange);
-        } else {
-            const value = await this._eventStore.get(keyRange);
-            key = value && value.key;
-        }
+        const key = await this._eventStore.getKey(keyRange);
         return !!key;
     }
     
diff --git a/src/matrix/storage/idb/stores/RoomMemberStore.js b/src/matrix/storage/idb/stores/RoomMemberStore.js
index aa979056..be2b16ec 100644
--- a/src/matrix/storage/idb/stores/RoomMemberStore.js
+++ b/src/matrix/storage/idb/stores/RoomMemberStore.js
@@ -19,6 +19,11 @@ function encodeKey(roomId, userId) {
     return `${roomId}|${userId}`;
 }
 
+function decodeKey(key) {
+    const [roomId, userId] = key.split("|");
+    return {roomId, userId};
+}
+
 // no historical members
 export class RoomMemberStore {
     constructor(roomMembersStore) {
@@ -40,4 +45,19 @@ export class RoomMemberStore {
             return member.roomId === roomId;
         });
     }
+
+    async getAllUserIds(roomId) {
+        const userIds = [];
+        const range = IDBKeyRange.lowerBound(encodeKey(roomId, ""));
+        await this._roomMembersStore.iterateKeys(range, key => {
+            const decodedKey = decodeKey(key);
+            // prevent running into the next room
+            if (decodedKey.roomId === roomId) {
+                userIds.push(decodedKey.userId);
+                return false;   // fetch more
+            }
+            return true; // done
+        });
+        return userIds;
+    }
 }
diff --git a/src/matrix/storage/idb/stores/SessionStore.js b/src/matrix/storage/idb/stores/SessionStore.js
index 2e74b9df..c6486651 100644
--- a/src/matrix/storage/idb/stores/SessionStore.js
+++ b/src/matrix/storage/idb/stores/SessionStore.js
@@ -14,22 +14,6 @@ See the License for the specific language governing permissions and
 limitations under the License.
 */
 
-/**
-store contains:
-	loginData {
-		device_id
-		home_server
-		access_token
-		user_id
-	}
-	// flags {
-	// 	lazyLoading?
-	// }
-	syncToken
-	displayName
-	avatarUrl
-	lastSynced
-*/
 export class SessionStore {
 	constructor(sessionStore) {
 		this._sessionStore = sessionStore;
@@ -45,4 +29,12 @@ export class SessionStore {
 	set(key, value) {
 		return this._sessionStore.put({key, value});
 	}
+
+    add(key, value) {
+        return this._sessionStore.add({key, value});
+    }
+
+    remove(key) {
+        this._sessionStore.delete(key);
+    }
 }
diff --git a/src/matrix/storage/idb/stores/UserIdentityStore.js b/src/matrix/storage/idb/stores/UserIdentityStore.js
new file mode 100644
index 00000000..1cf6d636
--- /dev/null
+++ b/src/matrix/storage/idb/stores/UserIdentityStore.js
@@ -0,0 +1,33 @@
+/*
+Copyright 2020 The Matrix.org Foundation C.I.C.
+
+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.
+*/
+
+export class UserIdentityStore {
+    constructor(store) {
+        this._store = store;
+    }
+
+    get(userId) {
+        return this._store.get(userId);
+    }
+
+    set(userIdentity) {
+        this._store.put(userIdentity);
+    }
+
+    remove(userId) {
+        return this._store.delete(userId);
+    }
+}
diff --git a/src/observable/list/SortedArray.js b/src/observable/list/SortedArray.js
index 2245dbd9..3348307b 100644
--- a/src/observable/list/SortedArray.js
+++ b/src/observable/list/SortedArray.js
@@ -41,6 +41,22 @@ export class SortedArray extends BaseObservableList {
         }
     }
 
+    replace(item) {
+        const idx = this.indexOf(item);
+        if (idx !== -1) {
+            this._items[idx] = item;
+        }
+    }
+
+    indexOf(item) {
+        const idx = sortedIndex(this._items, item, this._comparator);
+        if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) {
+            return idx;
+        } else {
+            return -1;
+        }
+    }
+
     set(item, updateParams = null) {
         const idx = sortedIndex(this._items, item, this._comparator);
         if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {
diff --git a/src/observable/map/ObservableMap.js b/src/observable/map/ObservableMap.js
index 68e64c89..7fe10d95 100644
--- a/src/observable/map/ObservableMap.js
+++ b/src/observable/map/ObservableMap.js
@@ -70,6 +70,10 @@ export class ObservableMap extends BaseObservableMap {
     [Symbol.iterator]() {
         return this._values.entries();
     }
+
+    values() {
+        return this._values.values();
+    }
 }
 
 export function tests() {
diff --git a/src/ui/web/common.js b/src/ui/web/common.js
index 2883652e..d6c4dddc 100644
--- a/src/ui/web/common.js
+++ b/src/ui/web/common.js
@@ -15,9 +15,18 @@ limitations under the License.
 */
 
 export function spinner(t, extraClasses = undefined) {
-    return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, 
-        t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"})
-    );
+    if (document.body.classList.contains("ie11")) {
+        return t.div({className: "spinner"}, [
+            t.div(),
+            t.div(),
+            t.div(),
+            t.div(),
+        ]);
+    } else {
+        return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, 
+            t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"})
+        );
+    }
 }
 
 /**
diff --git a/src/ui/web/css/room.css b/src/ui/web/css/room.css
index 6bf01da7..07999a2a 100644
--- a/src/ui/web/css/room.css
+++ b/src/ui/web/css/room.css
@@ -81,5 +81,5 @@ limitations under the License.
 }
 
 .TimelineLoadingView div {
-    margin-left: 10px;
+    margin-right: 10px;
 }
diff --git a/src/ui/web/css/spinner.css b/src/ui/web/css/spinner.css
index 62974da6..1548b9b6 100644
--- a/src/ui/web/css/spinner.css
+++ b/src/ui/web/css/spinner.css
@@ -32,24 +32,57 @@ limitations under the License.
     }
 }
 
-.spinner circle {
+.not-ie11 .spinner circle {
     transform-origin: 50% 50%;
     animation-name: spinner;
     animation-duration: 2s;
     animation-iteration-count: infinite;
     animation-timing-function: linear;
-    /**
-     * TODO
-     * see if with IE11 we can just set a static stroke state and make it rotate?
-     */
     stroke-dasharray: 0 0 85 85;
-
     fill: none;
     stroke: currentcolor;
     stroke-width: 12;
     stroke-linecap: butt;
 }
 
+.ie11 .spinner {
+  display: inline-block;
+  position: relative;
+}
+
+.ie11 .spinner div {
+  box-sizing: border-box;
+  display: block;
+  position: absolute;
+  padding: 2px;
+  border: 2px solid currentcolor;
+  border-radius: 50%;
+  animation: ie-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
+  border-color: currentcolor transparent transparent transparent;
+  width: var(--size);
+  height: var(--size);
+}
+
+.ie11 .spinner div:nth-child(1) {
+  animation-delay: -0.45s;
+}
+.ie11 .spinner div:nth-child(2) {
+  animation-delay: -0.3s;
+}
+.ie11 .spinner div:nth-child(3) {
+  animation-delay: -0.15s;
+}
+
+@keyframes ie-spinner {
+  0% {
+    transform: rotate(0deg);
+  }
+  100% {
+    transform: rotate(360deg);
+  }
+}
+
+
 .spinner {
     --size: 20px;
     width: var(--size);
diff --git a/src/ui/web/css/themes/element/theme.css b/src/ui/web/css/themes/element/theme.css
index bf81bcd0..bdcd599f 100644
--- a/src/ui/web/css/themes/element/theme.css
+++ b/src/ui/web/css/themes/element/theme.css
@@ -373,6 +373,10 @@ ul.Timeline > li.continuation time {
     color: #ccc;
 }
 
+.TextMessageView.unverified .message-container {
+    color: #ff4b55;
+}
+
 .message-container p {
     margin: 3px 0;
     line-height: 2.2rem;
diff --git a/src/ui/web/css/timeline.css b/src/ui/web/css/timeline.css
index 469e117f..d3f29fc9 100644
--- a/src/ui/web/css/timeline.css
+++ b/src/ui/web/css/timeline.css
@@ -77,6 +77,6 @@ limitations under the License.
 }
 
 .GapView > div {
-    flex: 1;
-    margin-left: 10px;
+    flex: 1 1 0;
+    margin-right: 10px;
 }
diff --git a/src/ui/web/session/room/MessageComposer.js b/src/ui/web/session/room/MessageComposer.js
index e07e77e3..88d7f903 100644
--- a/src/ui/web/session/room/MessageComposer.js
+++ b/src/ui/web/session/room/MessageComposer.js
@@ -24,7 +24,7 @@ export class MessageComposer extends TemplateView {
 
     render(t, vm) {
         this._input = t.input({
-            placeholder: "Send a message ...",
+            placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
             onKeydown: e => this._onKeyDown(e),
             onInput: () => vm.setInput(this._input.value),
         });
diff --git a/src/ui/web/session/room/TimelineLoadingView.js b/src/ui/web/session/room/TimelineLoadingView.js
index 88d07f43..503c2243 100644
--- a/src/ui/web/session/room/TimelineLoadingView.js
+++ b/src/ui/web/session/room/TimelineLoadingView.js
@@ -21,7 +21,7 @@ export class TimelineLoadingView extends TemplateView {
     render(t, vm) {
         return t.div({className: "TimelineLoadingView"}, [
             spinner(t),
-            t.div(vm.i18n`Loading messages…`)
+            t.div(vm.isEncrypted ? vm.i18n`Loading encrypted messages…` : vm.i18n`Loading messages…`)
         ]);
     }
 }
diff --git a/src/ui/web/session/room/timeline/common.js b/src/ui/web/session/room/timeline/common.js
index 36ccb624..7869731f 100644
--- a/src/ui/web/session/room/timeline/common.js
+++ b/src/ui/web/session/room/timeline/common.js
@@ -22,12 +22,13 @@ export function renderMessage(t, vm, children) {
         "TextMessageView": true,
         own: vm.isOwn,
         pending: vm.isPending,
+        unverified: vm.isUnverified,
         continuation: vm => vm.isContinuation,
     };
 
     const profile = t.div({className: "profile"}, [
         renderAvatar(t, vm, 30),
-        t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.sender)
+        t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.displayName)
     ]);
     children = [profile].concat(children);
     return t.li(
diff --git a/src/ui/web/view-gallery.html b/src/ui/web/view-gallery.html
index 43827afb..ecf328db 100644
--- a/src/ui/web/view-gallery.html
+++ b/src/ui/web/view-gallery.html
@@ -5,7 +5,7 @@
         
         
 	
-	
+