Merge pull request #81 from vector-im/bwindels/e2ee

Implement end-to-end encryption
This commit is contained in:
Bruno Windels 2020-09-11 12:48:04 +00:00 committed by GitHub
commit 47a238f498
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
92 changed files with 5016 additions and 269 deletions

1
.gitignore vendored
View file

@ -5,3 +5,4 @@ fetchlogs
sessionexports sessionexports
bundle.js bundle.js
target target
lib

View file

@ -18,7 +18,14 @@
</script> </script>
<script id="main" type="module"> <script id="main" type="module">
import {main} from "./src/main.js"; import {main} from "./src/main.js";
main(document.body); main(document.body, {
worker: "src/worker.js",
olm: {
wasm: "lib/olm/olm.wasm",
legacyBundle: "lib/olm/olm_legacy.js",
wasmBundle: "lib/olm/olm.js",
}
});
</script> </script>
<script id="service-worker" type="disabled"> <script id="service-worker" type="disabled">
if('serviceWorker' in navigator) { if('serviceWorker' in navigator) {

View file

@ -1 +0,0 @@
../node_modules/olm/olm.js

View file

@ -1 +0,0 @@
../node_modules/olm/olm.wasm

View file

@ -9,7 +9,8 @@
"scripts": { "scripts": {
"test": "node_modules/.bin/impunity --entry-point src/main.js --force-esm-dirs lib/ src/", "test": "node_modules/.bin/impunity --entry-point src/main.js --force-esm-dirs lib/ src/",
"start": "node scripts/serve-local.js", "start": "node scripts/serve-local.js",
"build": "node --experimental-modules scripts/build.mjs" "build": "node --experimental-modules scripts/build.mjs",
"postinstall": "node ./scripts/post-install.mjs"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -44,5 +45,9 @@
"rollup-plugin-cleanup": "^3.1.1", "rollup-plugin-cleanup": "^3.1.1",
"serve-static": "^1.13.2", "serve-static": "^1.13.2",
"xxhashjs": "^0.2.2" "xxhashjs": "^0.2.2"
},
"dependencies": {
"another-json": "^0.2.0",
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz"
} }
} }

View file

@ -0,0 +1,128 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style type="text/css">
pre {
font-family: monospace;
display: block;
white-space: pre;
font-size: 2em;
}
</style>
</head>
<body>
<script type="text/javascript">
if (!Math.imul) Math.imul = function(a,b) {return (a*b)|0;}/* function(a, b) {
var aHi = (a >>> 16) & 0xffff;
var aLo = a & 0xffff;
var bHi = (b >>> 16) & 0xffff;
var bLo = b & 0xffff;
// the shift by 0 fixes the sign on the high part
// the final |0 converts the unsigned value into a signed value
return ((aLo * bLo) + (((aHi * bLo + aLo * bHi) << 16) >>> 0) | 0);
};*/
if (!Math.clz32) Math.clz32 = (function(log, LN2){
return function(x) {
// Let n be ToUint32(x).
// Let p be the number of leading zero bits in
// the 32-bit binary representation of n.
// Return p.
var asUint = x >>> 0;
if (asUint === 0) {
return 32;
}
return 31 - (log(asUint) / LN2 | 0) |0; // the "| 0" acts like math.floor
};
})(Math.log, Math.LN2);
</script>
<script src="https://cdn.jsdelivr.net/npm/promise-polyfill@8/dist/polyfill.min.js"></script>
<script type="text/javascript" src="../lib/olm/olm_legacy.js"></script>
<script type="text/javascript">
function doit(log) {
var alice = new Olm.Account();
alice.create();
log("alice", alice.identity_keys());
var bob = new Olm.Account();
bob.unpickle("secret", "EWfA87or4GgQ+wqVkyuFiW9gUk3FI6QSXgp8E2dS5RFLvXgy4oFvxwQ1gVnbMkdJz2Hy9ex9UmJ/ZyuRU0aRt0IwXpw/SUNq4IQeVJ7J/miXW7rV4Ep+4RSEf945KbDrokDCS2CoL5PIfv/NYyey32gA0hMi8wWIfIlOxFBV4SBJYSC+Qd54VjprwCg0Sn9vjQouKVrM/+5jzsv9+JK5OpWW0Vrb3qrXwyAOEAQ4WlOQcqZHAyPQIw");
log("bob", bob.identity_keys());
// generate OTK on receiver side
bob.generate_one_time_keys(1);
var bobOneTimeKeys = JSON.parse(bob.one_time_keys());
var otkName = Object.getOwnPropertyNames(bobOneTimeKeys.curve25519)[0];
var bobOneTimeKey = bobOneTimeKeys.curve25519[otkName];
// encrypt
var aliceSession = new Olm.Session();
aliceSession.create_outbound(
alice,
JSON.parse(bob.identity_keys()).curve25519,
bobOneTimeKey
);
log("alice outbound session created");
var aliceSessionPickled = aliceSession.pickle("secret");
log("aliceSession pickled", aliceSessionPickled);
try {
var tmp = new Olm.Session();
tmp.unpickle("secret", aliceSessionPickled);
log("aliceSession unpickled");
} finally {
tmp.free();
}
var message = aliceSession.encrypt("hello secret world");
log("message", message);
// decrypt
var bobSession = new Olm.Session();
bobSession.create_inbound(bob, message.body);
var plaintext = bobSession.decrypt(message.type, message.body);
log("plaintext", plaintext);
// remove Bob's OTK as it was used to start an olm session
log("bob OTK before removing", bob.one_time_keys());
bob.remove_one_time_keys(bobSession);
log("bob OTK after removing", bob.one_time_keys());
}
if (window.msCrypto && !window.crypto) {
window.crypto = window.msCrypto;
}
function doRun(e) {
e.target.setAttribute("disabled", "disabled");
var logEl = document.getElementById("log");
logEl.innerText = "";
var startTime = performance.now();
function log() {
var timeDiff = Math.round(performance.now() - startTime).toString();
while (timeDiff.length < 5) {
timeDiff = "0" + timeDiff;
}
logEl.appendChild(document.createTextNode(timeDiff + " "));
for (var i = 0; i < arguments.length; i += 1) {
var value = arguments[i];
if (typeof value !== "string") {
value = JSON.stringify(value);
}
logEl.appendChild(document.createTextNode(value + " "));
}
logEl.appendChild(document.createTextNode("\n"));
}
doit(log);
e.target.removeAttribute("disabled");
}
function main() {
Olm.init( ).then(function() {
var startButton = document.getElementById("start");
startButton.innerText = "Start";
startButton.addEventListener("click", doRun);
});
}
document.addEventListener("DOMContentLoaded", main);
</script>
<pre id="log"></pre>
<button id="start">Loading...</button>
</body>
</html>

View file

@ -12,13 +12,13 @@
</style> </style>
</head> </head>
<body> <body>
<script type="text/javascript" src="../lib/olm.js"></script> <script type="text/javascript" src="../lib/olm/olm.js"></script>
<script type="module"> <script type="module">
async function main() { async function main() {
const Olm = window.Olm; const Olm = window.Olm;
await Olm.init({ await Olm.init({
locateFile: () => "../lib/olm.wasm", locateFile: () => "../lib/olm/olm.wasm",
}); });
const alice = new Olm.Account(); const alice = new Olm.Account();
alice.create(); alice.create();

View file

@ -58,6 +58,12 @@ program.parse(process.argv);
const {debug, noOffline} = program; const {debug, noOffline} = program;
const offline = !noOffline; const offline = !noOffline;
const olmFiles = {
wasm: "olm-4289088762.wasm",
legacyBundle: "olm_legacy-3232457086.js",
wasmBundle: "olm-1421970081.js",
};
async function build() { async function build() {
// only used for CSS for now, using legacy for all targets for now // only used for CSS for now, using legacy for all targets for now
const legacy = true; const legacy = true;
@ -73,13 +79,16 @@ async function build() {
// clear target dir // clear target dir
await removeDirIfExists(targetDir); await removeDirIfExists(targetDir);
await createDirs(targetDir, themes); await createDirs(targetDir, themes);
// copy assets
await copyFolder(path.join(projectDir, "lib/olm/"), targetDir, );
// also creates the directories where the theme css bundles are placed in, // also creates the directories where the theme css bundles are placed in,
// so do it first // so do it first
const themeAssets = await copyThemeAssets(themes, legacy); const themeAssets = await copyThemeAssets(themes, legacy);
const jsBundlePath = await buildJs(); const jsBundlePath = await buildJs("src/main.js", `${PROJECT_ID}.js`);
const jsLegacyBundlePath = await buildJsLegacy(); const jsLegacyBundlePath = await buildJsLegacy("src/main.js", `${PROJECT_ID}-legacy.js`);
const jsWorkerPath = await buildWorkerJsLegacy("src/worker.js", `worker.js`);
const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets); const cssBundlePaths = await buildCssBundles(legacy ? buildCssLegacy : buildCss, themes, themeAssets);
const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, themeAssets); const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets);
let manifestPath; let manifestPath;
if (offline) { if (offline) {
@ -90,7 +99,7 @@ async function build() {
console.log(`built ${PROJECT_ID} ${version} successfully`); console.log(`built ${PROJECT_ID} ${version} successfully`);
} }
function createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, themeAssets) { function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets) {
function trim(path) { function trim(path) {
if (!path.startsWith(targetDir)) { if (!path.startsWith(targetDir)) {
throw new Error("invalid target path: " + targetDir); throw new Error("invalid target path: " + targetDir);
@ -100,6 +109,7 @@ function createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, them
return { return {
jsBundle: () => trim(jsBundlePath), jsBundle: () => trim(jsBundlePath),
jsLegacyBundle: () => trim(jsLegacyBundlePath), jsLegacyBundle: () => trim(jsLegacyBundlePath),
jsWorker: () => trim(jsWorkerPath),
cssMainBundle: () => trim(cssBundlePaths.main), cssMainBundle: () => trim(cssBundlePaths.main),
cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]), cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)), cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)),
@ -153,10 +163,14 @@ async function buildHtml(doc, version, assetPaths, manifestPath) {
findThemes(doc, (themeName, theme) => { findThemes(doc, (themeName, theme) => {
theme.attr("href", assetPaths.cssThemeBundle(themeName)); theme.attr("href", assetPaths.cssThemeBundle(themeName));
}); });
const pathsJSON = JSON.stringify({
worker: assetPaths.jsWorker(),
olm: olmFiles
});
doc("script#main").replaceWith( doc("script#main").replaceWith(
`<script type="module">import {main} from "./${assetPaths.jsBundle()}"; main(document.body);</script>` + `<script type="module">import {main} from "./${assetPaths.jsBundle()}"; main(document.body, ${pathsJSON});</script>` +
`<script type="text/javascript" nomodule src="${assetPaths.jsLegacyBundle()}"></script>` + `<script type="text/javascript" nomodule src="${assetPaths.jsLegacyBundle()}"></script>` +
`<script type="text/javascript" nomodule>${PROJECT_ID}Bundle.main(document.body);</script>`); `<script type="text/javascript" nomodule>${PROJECT_ID}Bundle.main(document.body, ${pathsJSON});</script>`);
removeOrEnableScript(doc("script#service-worker"), offline); removeOrEnableScript(doc("script#service-worker"), offline);
const versionScript = doc("script#version"); 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"); await fs.writeFile(path.join(targetDir, "index.html"), doc.html(), "utf8");
} }
async function buildJs() { async function buildJs(inputFile, outputName) {
// create js bundle // create js bundle
const bundle = await rollup({ const bundle = await rollup({
input: 'src/main.js', input: inputFile,
plugins: [removeJsComments({comments: "none"})] plugins: [removeJsComments({comments: "none"})]
}); });
const {output} = await bundle.generate({ const {output} = await bundle.generate({
format: 'es', format: 'es',
// TODO: can remove this?
name: `${PROJECT_ID}Bundle` name: `${PROJECT_ID}Bundle`
}); });
const code = output[0].code; const code = output[0].code;
const bundlePath = resource(`${PROJECT_ID}.js`, code); const bundlePath = resource(outputName, code);
await fs.writeFile(bundlePath, code, "utf8"); await fs.writeFile(bundlePath, code, "utf8");
return bundlePath; return bundlePath;
} }
async function buildJsLegacy() { async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
// compile down to whatever IE 11 needs // compile down to whatever IE 11 needs
const babelPlugin = babel.babel({ const babelPlugin = babel.babel({
babelHelpers: 'bundled', babelHelpers: 'bundled',
@ -204,9 +219,12 @@ async function buildJsLegacy() {
] ]
] ]
}); });
if (!polyfillFile) {
polyfillFile = 'src/legacy-polyfill.js';
}
// create js bundle // create js bundle
const rollupConfig = { const rollupConfig = {
input: ['src/legacy-polyfill.js', 'src/main.js'], input: [polyfillFile, inputFile],
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})] plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})]
}; };
const bundle = await rollup(rollupConfig); const bundle = await rollup(rollupConfig);
@ -215,11 +233,16 @@ async function buildJsLegacy() {
name: `${PROJECT_ID}Bundle` name: `${PROJECT_ID}Bundle`
}); });
const code = output[0].code; const code = output[0].code;
const bundlePath = resource(`${PROJECT_ID}-legacy.js`, code); const bundlePath = resource(outputName, code);
await fs.writeFile(bundlePath, code, "utf8"); await fs.writeFile(bundlePath, code, "utf8");
return bundlePath; return bundlePath;
} }
function buildWorkerJsLegacy(inputFile, outputName) {
const polyfillFile = 'src/worker-polyfill.js';
return buildJsLegacy(inputFile, outputName, polyfillFile);
}
async function buildOffline(version, assetPaths) { async function buildOffline(version, assetPaths) {
// write offline availability // write offline availability
const offlineFiles = [ const offlineFiles = [
@ -338,7 +361,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
if (dirEnt.isDirectory()) { if (dirEnt.isDirectory()) {
await fs.mkdir(dstPath); await fs.mkdir(dstPath);
Object.assign(assetPaths, await copyFolder(srcPath, dstPath, filter)); 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 content = await fs.readFile(srcPath);
const hashedDstPath = resource(dstPath, content); const hashedDstPath = resource(dstPath, content);
await fs.writeFile(hashedDstPath, content); await fs.writeFile(hashedDstPath, content);
@ -350,7 +373,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
function resource(relPath, content) { function resource(relPath, content) {
let fullPath = relPath; let fullPath = relPath;
if (!relPath.startsWith("/")) { if (!path.isAbsolute(relPath)) {
fullPath = path.join(targetDir, relPath); fullPath = path.join(targetDir, relPath);
} }
const hash = contentHash(Buffer.from(content)); const hash = contentHash(Buffer.from(content));

12
scripts/common.mjs Normal file
View file

@ -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;
}
}
}

64
scripts/post-install.mjs Normal file
View file

@ -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();

View file

@ -82,7 +82,7 @@ export class SessionLoadViewModel extends ViewModel {
if (this._sessionContainer) { if (this._sessionContainer) {
this._sessionContainer.stop(); this._sessionContainer.stop();
if (this._deleteSessionOnCancel) { if (this._deleteSessionOnCancel) {
await this._sessionContainer.deletSession(); await this._sessionContainer.deleteSession();
} }
this._sessionContainer = null; this._sessionContainer = null;
} }
@ -127,6 +127,8 @@ export class SessionLoadViewModel extends ViewModel {
return `Something went wrong while checking your login and password.`; return `Something went wrong while checking your login and password.`;
} }
break; break;
case LoadStatus.SessionSetup:
return `Setting up your encryption keys…`;
case LoadStatus.Loading: case LoadStatus.Loading:
return `Loading your conversations…`; return `Loading your conversations…`;
case LoadStatus.FirstSync: case LoadStatus.FirstSync:

View file

@ -36,8 +36,7 @@ export class ViewModel extends EventEmitter {
if (!this.disposables) { if (!this.disposables) {
this.disposables = new Disposables(); this.disposables = new Disposables();
} }
this.disposables.track(disposable); return this.disposables.track(disposable);
return disposable;
} }
dispose() { dispose() {

View file

@ -38,7 +38,8 @@ export class RoomViewModel extends ViewModel {
async load() { async load() {
this._room.on("change", this._onRoomChange); this._room.on("change", this._onRoomChange);
try { try {
this._timeline = await this._room.openTimeline(); this._timeline = this.track(this._room.openTimeline());
await this._timeline.load();
this._timelineVM = new TimelineViewModel(this.childOptions({ this._timelineVM = new TimelineViewModel(this.childOptions({
room: this._room, room: this._room,
timeline: this._timeline, timeline: this._timeline,
@ -62,17 +63,15 @@ export class RoomViewModel extends ViewModel {
} }
dispose() { dispose() {
// this races with enable, on the await openTimeline() super.dispose();
if (this._timeline) {
// will stop the timeline from delivering updates on entries
this._timeline.close();
}
if (this._clearUnreadTimout) { if (this._clearUnreadTimout) {
this._clearUnreadTimout.abort(); this._clearUnreadTimout.abort();
this._clearUnreadTimout = null; this._clearUnreadTimout = null;
} }
} }
// called from view to close room
// parent vm will dispose this vm
close() { close() {
this._closeCallback(); this._closeCallback();
} }
@ -91,6 +90,10 @@ export class RoomViewModel extends ViewModel {
return this._timelineVM; return this._timelineVM;
} }
get isEncrypted() {
return this._room.isEncrypted;
}
get error() { get error() {
if (this._timelineError) { if (this._timelineError) {
return `Something went wrong loading the timeline: ${this._timelineError.message}`; return `Something went wrong loading the timeline: ${this._timelineError.message}`;
@ -148,6 +151,10 @@ class ComposerViewModel extends ViewModel {
this._isEmpty = true; this._isEmpty = true;
} }
get isEncrypted() {
return this._roomVM.isEncrypted;
}
sendMessage(message) { sendMessage(message) {
const success = this._roomVM._sendMessage(message); const success = this._roomVM._sendMessage(message);
if (success) { if (success) {

View file

@ -54,7 +54,7 @@ export class TimelineViewModel extends ViewModel {
if (firstTile.shape === "gap") { if (firstTile.shape === "gap") {
return firstTile.fill(); return firstTile.fill();
} else { } else {
await this._timeline.loadAtTop(50); await this._timeline.loadAtTop(10);
return false; return false;
} }
} }

View file

@ -31,8 +31,12 @@ export class MessageTile extends SimpleTile {
return "message"; return "message";
} }
get displayName() {
return this._entry.displayName || this.sender;
}
get sender() { get sender() {
return this._entry.displayName || this._entry.sender; return this._entry.sender;
} }
// Avatar view model contract // Avatar view model contract
@ -52,7 +56,7 @@ export class MessageTile extends SimpleTile {
} }
get avatarTitle() { get avatarTitle() {
return this.sender; return this.displayName;
} }
get date() { get date() {
@ -71,6 +75,10 @@ export class MessageTile extends SimpleTile {
return this._isContinuation; return this._isContinuation;
} }
get isUnverified() {
return this._entry.isUnverified;
}
_getContent() { _getContent() {
return this._entry.content; return this._entry.content;
} }

View file

@ -23,4 +23,4 @@ if (!Element.prototype.remove) {
Element.prototype.remove = function remove() { Element.prototype.remove = function remove() {
this.parentNode.removeChild(this); this.parentNode.removeChild(this);
}; };
} }

View file

@ -25,12 +25,67 @@ import {BrawlViewModel} from "./domain/BrawlViewModel.js";
import {BrawlView} from "./ui/web/BrawlView.js"; import {BrawlView} from "./ui/web/BrawlView.js";
import {Clock} from "./ui/web/dom/Clock.js"; import {Clock} from "./ui/web/dom/Clock.js";
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.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, // Don't use a default export here, as we use multiple entries during legacy build,
// which does not support default exports, // which does not support default exports,
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry // see https://github.com/rollup/plugins/tree/master/packages/multi-entry
export async function main(container) { export async function main(container, paths) {
try { try {
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
if (isIE11) {
document.body.className += " ie11";
} else {
document.body.className += " not-ie11";
}
// to replay: // to replay:
// const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json(); // const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json();
// const replay = new ReplayRequester(fetchLog, {delay: false}); // const replay = new ReplayRequester(fetchLog, {delay: false});
@ -50,6 +105,13 @@ export async function main(container) {
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1"); const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
const storageFactory = new StorageFactory(); 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({ const vm = new BrawlViewModel({
createSessionContainer: () => { createSessionContainer: () => {
return new SessionContainer({ return new SessionContainer({
@ -59,6 +121,8 @@ export async function main(container) {
sessionInfoStorage, sessionInfoStorage,
request, request,
clock, clock,
olmPromise: loadOlm(paths.olm),
workerPromise,
}); });
}, },
sessionInfoStorage, sessionInfoStorage,

View file

@ -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<DecryptionResult>} 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)) || [];
}
}

View file

@ -121,7 +121,7 @@ export class SendScheduler {
} }
this._sendRequests = []; this._sendRequests = [];
} }
console.error("error for request", request); console.error("error for request", err);
request.reject(err); request.reject(err);
break; break;
} }

View file

@ -18,10 +18,24 @@ import {Room} from "./room/Room.js";
import { ObservableMap } from "../observable/index.js"; import { ObservableMap } from "../observable/index.js";
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js"; import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
import {User} from "./User.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 { export class Session {
// sessionInfo contains deviceId, userId and homeServer // sessionInfo contains deviceId, userId and homeServer
constructor({storage, hsApi, sessionInfo}) { constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker}) {
this._clock = clock;
this._storage = storage; this._storage = storage;
this._hsApi = hsApi; this._hsApi = hsApi;
this._syncInfo = null; this._syncInfo = null;
@ -30,6 +44,118 @@ export class Session {
this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()}); this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()});
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
this._user = new User(sessionInfo.userId); 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() { async load() {
@ -43,6 +169,21 @@ export class Session {
]); ]);
// restore session object // restore session object
this._syncInfo = await txn.session.get("sync"); 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); const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
// load rooms // load rooms
const rooms = await txn.roomSummary.getAll(); const rooms = await txn.roomSummary.getAll();
@ -57,6 +198,7 @@ export class Session {
} }
stop() { stop() {
this._olmWorker?.dispose();
this._sendScheduler.stop(); this._sendScheduler.stop();
} }
@ -71,9 +213,20 @@ export class Session {
await txn.complete(); 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(); this._sendScheduler.start();
for (const [, room] of this._rooms) { 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) { createRoom(roomId, pendingEvents) {
const room = new Room({ const room = new Room({
roomId, roomId,
getSyncToken: this._getSyncToken,
storage: this._storage, storage: this._storage,
emitCollectionChange: this._roomUpdateCallback, emitCollectionChange: this._roomUpdateCallback,
hsApi: this._hsApi, hsApi: this._hsApi,
sendScheduler: this._sendScheduler, sendScheduler: this._sendScheduler,
pendingEvents, pendingEvents,
user: this._user, user: this._user,
createRoomEncryption: this._createRoomEncryption,
clock: this._clock
}); });
this._rooms.add(roomId, room); this._rooms.add(roomId, room);
return 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) { if (syncToken !== this.syncToken) {
const syncInfo = {token: syncToken, filterId: syncFilterId}; const syncInfo = {token: syncToken, filterId: syncFilterId};
// don't modify `this` because transaction might still fail // don't modify `this` because transaction might still fail
txn.session.set("sync", syncInfo); 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) { if (syncInfo) {
// sync transaction succeeded, modify object state now // sync transaction succeeded, modify object state now
this._syncInfo = syncInfo; 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() { 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(syncSet);
assert.equal(session.syncToken, "a"); assert.equal(session.syncToken, "a");
assert.equal(session.syncFilterId, 5); assert.equal(session.syncFilterId, 5);

View file

@ -28,6 +28,7 @@ export const LoadStatus = createEnum(
"Login", "Login",
"LoginFailed", "LoginFailed",
"Loading", "Loading",
"SessionSetup", // upload e2ee keys, ...
"Migrating", //not used atm, but would fit here "Migrating", //not used atm, but would fit here
"FirstSync", "FirstSync",
"Error", "Error",
@ -41,7 +42,7 @@ export const LoginFailure = createEnum(
); );
export class SessionContainer { export class SessionContainer {
constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage}) { constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise}) {
this._random = random; this._random = random;
this._clock = clock; this._clock = clock;
this._onlineStatus = onlineStatus; this._onlineStatus = onlineStatus;
@ -57,6 +58,8 @@ export class SessionContainer {
this._sync = null; this._sync = null;
this._sessionId = null; this._sessionId = null;
this._storage = null; this._storage = null;
this._olmPromise = olmPromise;
this._workerPromise = workerPromise;
} }
createNewSessionId() { createNewSessionId() {
@ -73,7 +76,7 @@ export class SessionContainer {
if (!sessionInfo) { if (!sessionInfo) {
throw new Error("Invalid session id: " + sessionId); throw new Error("Invalid session id: " + sessionId);
} }
await this._loadSessionInfo(sessionInfo); await this._loadSessionInfo(sessionInfo, false);
} catch (err) { } catch (err) {
this._error = err; this._error = err;
this._status.set(LoadStatus.Error); this._status.set(LoadStatus.Error);
@ -88,7 +91,7 @@ export class SessionContainer {
let sessionInfo; let sessionInfo;
try { try {
const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout}); 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(); const sessionId = this.createNewSessionId();
sessionInfo = { sessionInfo = {
id: sessionId, id: sessionId,
@ -120,14 +123,14 @@ export class SessionContainer {
// LoadStatus.Error in case of an error, // LoadStatus.Error in case of an error,
// so separate try/catch // so separate try/catch
try { try {
await this._loadSessionInfo(sessionInfo); await this._loadSessionInfo(sessionInfo, true);
} catch (err) { } catch (err) {
this._error = err; this._error = err;
this._status.set(LoadStatus.Error); this._status.set(LoadStatus.Error);
} }
} }
async _loadSessionInfo(sessionInfo) { async _loadSessionInfo(sessionInfo, isNewLogin) {
this._status.set(LoadStatus.Loading); this._status.set(LoadStatus.Loading);
this._reconnector = new Reconnector({ this._reconnector = new Reconnector({
onlineStatus: this._onlineStatus, onlineStatus: this._onlineStatus,
@ -149,8 +152,17 @@ export class SessionContainer {
userId: sessionInfo.userId, userId: sessionInfo.userId,
homeServer: sessionInfo.homeServer, 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(); 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}); this._sync = new Sync({hsApi, storage: this._storage, session: this._session});
// notify sync and session when back online // notify sync and session when back online
@ -234,10 +246,16 @@ export class SessionContainer {
} }
stop() { stop() {
this._reconnectSubscription(); if (this._reconnectSubscription) {
this._reconnectSubscription = null; this._reconnectSubscription();
this._sync.stop(); this._reconnectSubscription = null;
this._session.stop(); }
if (this._sync) {
this._sync.stop();
}
if (this._session) {
this._session.stop();
}
if (this._waitForFirstSyncHandle) { if (this._waitForFirstSyncHandle) {
this._waitForFirstSyncHandle.dispose(); this._waitForFirstSyncHandle.dispose();
this._waitForFirstSyncHandle = null; this._waitForFirstSyncHandle = null;

View file

@ -29,21 +29,6 @@ export const SyncStatus = createEnum(
"Stopped" "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) { function timelineIsEmpty(roomResponse) {
try { try {
const events = roomResponse?.timeline?.events; 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 { export class Sync {
constructor({hsApi, session, storage}) { constructor({hsApi, session, storage}) {
this._hsApi = hsApi; this._hsApi = hsApi;
@ -87,12 +92,16 @@ export class Sync {
} }
async _syncLoop(syncToken) { async _syncLoop(syncToken) {
let afterSyncCompletedPromise = Promise.resolve();
// if syncToken is falsy, it will first do an initial sync ... // if syncToken is falsy, it will first do an initial sync ...
while(this._status.get() !== SyncStatus.Stopped) { while(this._status.get() !== SyncStatus.Stopped) {
let roomStates;
try { try {
console.log(`starting sync request with since ${syncToken} ...`); console.log(`starting sync request with since ${syncToken} ...`);
const timeout = syncToken ? INCREMENTAL_TIMEOUT : undefined; 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); this._status.set(SyncStatus.Syncing);
} catch (err) { } catch (err) {
if (!(err instanceof AbortError)) { if (!(err instanceof AbortError)) {
@ -100,10 +109,39 @@ export class Sync {
this._status.set(SyncStatus.Stopped); 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; let {syncFilterId} = this._session;
if (typeof syncFilterId !== "string") { if (typeof syncFilterId !== "string") {
this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}}); 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 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}); this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout, {timeout: totalRequestTimeout});
const response = await this._currentRequest.response(); 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; const isInitialSync = !syncToken;
syncToken = response.next_batch; syncToken = response.next_batch;
const storeNames = this._storage.storeNames; const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync);
const syncTxn = await this._storage.readWriteTxn([ await this._prepareRooms(roomStates);
storeNames.session,
storeNames.roomSummary,
storeNames.roomState,
storeNames.roomMembers,
storeNames.timelineEvents,
storeNames.timelineFragments,
storeNames.pendingEvents,
]);
const roomChanges = [];
let sessionChanges; let sessionChanges;
const syncTxn = await this._openSyncTxn();
try { try {
sessionChanges = this._session.writeSync(syncToken, syncFilterId, response.account_data, syncTxn); await Promise.all(roomStates.map(async rs => {
// to_device console.log(` * applying sync response to room ${rs.room.id} ...`);
// presence rs.changes = await rs.room.writeSync(
if (response.rooms) { rs.roomResponse, rs.membership, isInitialSync, rs.preparation, syncTxn);
const promises = parseRooms(response.rooms, async (roomId, roomResponse, membership) => { }));
// ignore rooms with empty timelines during initial sync, sessionChanges = await this._session.writeSync(response, syncFilterId, syncTxn);
// 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);
}
} catch(err) { } catch(err) {
console.warn("aborting syncTxn because of error"); console.warn("aborting syncTxn because of error");
console.error(err); console.error(err);
@ -165,13 +185,80 @@ export class Sync {
} }
this._session.afterSync(sessionChanges); this._session.afterSync(sessionChanges);
// emit room related events after txn has been closed // emit room related events after txn has been closed
for(let {room, changes} of roomChanges) { for(let rs of roomStates) {
room.afterSync(changes); 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() { stop() {
if (this._status.get() === SyncStatus.Stopped) { if (this._status.get() === SyncStatus.Stopped) {
return; 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;
}
}

22
src/matrix/common.js Normal file
View file

@ -0,0 +1,22 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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;
}

242
src/matrix/e2ee/Account.js Normal file
View file

@ -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;
}
}
}

View file

@ -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;
}
}

View file

@ -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<string>} 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<DeviceIdentity>}
*/
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);
}
}

View file

@ -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();
}
}

44
src/matrix/e2ee/README.md Normal file
View file

@ -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

View file

@ -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);
}
}
}
}
}

55
src/matrix/e2ee/common.js Normal file
View file

@ -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;
}
}

View file

@ -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<OlmDecryptionResult>} decryptionResults an array of m.room_key decryption results.
* @param {[type]} txn a storage transaction with read/write on inboundGroupSessions
* @return {Promise<Array<MegolmInboundSessionDescription>>} 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;
}
}

View file

@ -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<EncryptionResult>}
*/
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;
}
}

View file

@ -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<string, DecryptionResult>} results a map of event id to decryption result
* @property {Map<string, Error>} 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
});
}
}
}

View file

@ -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();
}
}
}

View file

@ -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

View file

@ -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;
}
}

View file

@ -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();
}
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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<DecryptionChanges>} [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<DecryptionResult>} results
* @property {Array<DecryptionError>} 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();
}
}
}
}

View file

@ -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<userId, Map<deviceId, deviceIdentity>>
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;
}
}

View file

@ -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;
}
}

View file

@ -141,14 +141,15 @@ export class HomeServerApi {
{}, {}, options); {}, {}, options);
} }
passwordLogin(username, password, options = null) { passwordLogin(username, password, initialDeviceDisplayName, options = null) {
return this._post("/login", null, { return this._post("/login", null, {
"type": "m.login.password", "type": "m.login.password",
"identifier": { "identifier": {
"type": "m.id.user", "type": "m.id.user",
"user": username "user": username
}, },
"password": password "password": password,
"initial_device_display_name": initialDeviceDisplayName
}, options); }, options);
} }
@ -160,6 +161,22 @@ export class HomeServerApi {
return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options); 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() { get mediaRepository() {
return this._mediaRepository; return this._mediaRepository;
} }

View file

@ -25,9 +25,13 @@ import {WrappedError} from "../error.js"
import {fetchOrLoadMembers} from "./members/load.js"; import {fetchOrLoadMembers} from "./members/load.js";
import {MemberList} from "./members/MemberList.js"; import {MemberList} from "./members/MemberList.js";
import {Heroes} from "./members/Heroes.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 { 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(); super();
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
@ -40,17 +44,133 @@ export class Room extends EventEmitter {
this._timeline = null; this._timeline = null;
this._user = user; this._user = user;
this._changedMembersDuringSync = null; 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 */ /** @package */
async writeSync(roomResponse, membership, isInitialSync, txn) { async writeSync(roomResponse, membership, isInitialSync, decryptChanges, txn) {
const isTimelineOpen = !!this._timeline; 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( const summaryChanges = this._summary.writeSync(
roomResponse, roomResponse,
membership, membership,
isInitialSync, isTimelineOpen, isInitialSync, this._isTimelineOpen,
txn); txn);
const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
// fetch new members while we have txn open, // fetch new members while we have txn open,
// but don't make any in-memory changes yet // but don't make any in-memory changes yet
let heroChanges; let heroChanges;
@ -59,7 +179,7 @@ export class Room extends EventEmitter {
if (!this._heroes) { if (!this._heroes) {
this._heroes = new Heroes(this._roomId); 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; let removedPendingEvents;
if (roomResponse.timeline && roomResponse.timeline.events) { if (roomResponse.timeline && roomResponse.timeline.events) {
@ -70,22 +190,29 @@ export class Room extends EventEmitter {
newTimelineEntries: entries, newTimelineEntries: entries,
newLiveKey, newLiveKey,
removedPendingEvents, removedPendingEvents,
changedMembers, memberChanges,
heroChanges 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); 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) { if (this._changedMembersDuringSync) {
for (const member of changedMembers) { for (const [userId, memberChange] of memberChanges.entries()) {
this._changedMembersDuringSync.set(member.userId, member); this._changedMembersDuringSync.set(userId, memberChange.member);
} }
} }
if (this._memberList) { if (this._memberList) {
this._memberList.afterSync(changedMembers); this._memberList.afterSync(memberChanges);
} }
} }
let emitChange = false; 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 */ /** @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(); this._sendQueue.resumeSending();
} }
@ -124,6 +278,9 @@ export class Room extends EventEmitter {
async load(summary, txn) { async load(summary, txn) {
try { try {
this._summary.load(summary); this._summary.load(summary);
if (this._summary.encryption) {
this._enableEncryption(this._summary.encryption);
}
// need to load members for name? // need to load members for name?
if (this._summary.needsHeroes) { if (this._summary.needsHeroes) {
this._heroes = new Heroes(this._roomId); this._heroes = new Heroes(this._roomId);
@ -144,6 +301,7 @@ export class Room extends EventEmitter {
/** @public */ /** @public */
async loadMemberList() { async loadMemberList() {
if (this._memberList) { if (this._memberList) {
// TODO: also await fetchOrLoadMembers promise here
this._memberList.retain(); this._memberList.retain();
return this._memberList; return this._memberList;
} else { } else {
@ -152,6 +310,7 @@ export class Room extends EventEmitter {
roomId: this._roomId, roomId: this._roomId,
hsApi: this._hsApi, hsApi: this._hsApi,
storage: this._storage, storage: this._storage,
syncToken: this._getSyncToken(),
// to handle race between /members and /sync // to handle race between /members and /sync
setChangedMembersMap: map => this._changedMembersDuringSync = map, setChangedMembersMap: map => this._changedMembersDuringSync = map,
}); });
@ -193,7 +352,7 @@ export class Room extends EventEmitter {
const gapWriter = new GapWriter({ const gapWriter = new GapWriter({
roomId: this._roomId, roomId: this._roomId,
storage: this._storage, storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer,
}); });
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn); gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn);
} catch (err) { } catch (err) {
@ -201,6 +360,10 @@ export class Room extends EventEmitter {
throw err; throw err;
} }
await txn.complete(); 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 // once txn is committed, update in-memory state & emit events
for (const fragment of gapResult.fragments) { for (const fragment of gapResult.fragments) {
this._fragmentIdComparer.add(fragment); this._fragmentIdComparer.add(fragment);
@ -256,6 +419,14 @@ export class Room extends EventEmitter {
return !!(tags && tags['m.lowpriority']); return !!(tags && tags['m.lowpriority']);
} }
get isEncrypted() {
return !!this._summary.encryption;
}
get isTrackingMembers() {
return this._summary.isTrackingMembers;
}
async _getLastEventId() { async _getLastEventId() {
const lastKey = this._syncWriter.lastMessageKey; const lastKey = this._syncWriter.lastMessageKey;
if (lastKey) { if (lastKey) {
@ -267,6 +438,10 @@ export class Room extends EventEmitter {
} }
} }
get _isTimelineOpen() {
return !!this._timeline;
}
async clearUnread() { async clearUnread() {
if (this.isUnread || this.notificationCount) { if (this.isUnread || this.notificationCount) {
const txn = await this._storage.readWriteTxn([ const txn = await this._storage.readWriteTxn([
@ -299,7 +474,7 @@ export class Room extends EventEmitter {
} }
/** @public */ /** @public */
async openTimeline() { openTimeline() {
if (this._timeline) { if (this._timeline) {
throw new Error("not dealing with load race here for now"); throw new Error("not dealing with load race here for now");
} }
@ -312,15 +487,53 @@ export class Room extends EventEmitter {
closeCallback: () => { closeCallback: () => {
console.log(`closing the timeline for ${this._roomId}`); console.log(`closing the timeline for ${this._roomId}`);
this._timeline = null; this._timeline = null;
if (this._roomEncryption) {
this._roomEncryption.notifyTimelineClosed();
}
}, },
user: this._user, 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; return this._timeline;
} }
get mediaRepository() { get mediaRepository() {
return this._hsApi.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();
}
}
}

View file

@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) { function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
if (roomResponse.summary) { if (roomResponse.summary) {
data = updateSummary(data, roomResponse.summary); data = updateSummary(data, roomResponse.summary);
@ -29,12 +31,8 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime
if (roomResponse.state) { if (roomResponse.state) {
data = roomResponse.state.events.reduce(processStateEvent, data); data = roomResponse.state.events.reduce(processStateEvent, data);
} }
if (roomResponse.timeline) { const {timeline} = roomResponse;
const {timeline} = roomResponse; if (timeline && Array.isArray(timeline.events)) {
if (timeline.prev_batch) {
data = data.cloneIfNeeded();
data.lastPaginationToken = timeline.prev_batch;
}
data = timeline.events.reduce((data, event) => { data = timeline.events.reduce((data, event) => {
if (typeof event.state_key === "string") { if (typeof event.state_key === "string") {
return processStateEvent(data, event); return processStateEvent(data, event);
@ -68,9 +66,10 @@ function processRoomAccountData(data, event) {
function processStateEvent(data, event) { function processStateEvent(data, event) {
if (event.type === "m.room.encryption") { if (event.type === "m.room.encryption") {
if (!data.isEncrypted) { const algorithm = event.content?.algorithm;
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.isEncrypted = true; data.encryption = event.content;
} }
} else if (event.type === "m.room.name") { } else if (event.type === "m.room.name") {
const newName = event.content?.name; const newName = event.content?.name;
@ -113,7 +112,9 @@ function updateSummary(data, summary) {
const heroes = summary["m.heroes"]; const heroes = summary["m.heroes"];
const joinCount = summary["m.joined_member_count"]; const joinCount = summary["m.joined_member_count"];
const inviteCount = summary["m.invited_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)) { if (heroes && Array.isArray(heroes)) {
data = data.cloneIfNeeded(); data = data.cloneIfNeeded();
data.heroes = heroes; data.heroes = heroes;
@ -136,7 +137,7 @@ class SummaryData {
this.lastMessageBody = copy ? copy.lastMessageBody : null; this.lastMessageBody = copy ? copy.lastMessageBody : null;
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null; this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
this.isUnread = copy ? copy.isUnread : false; 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.isDirectMessage = copy ? copy.isDirectMessage : false;
this.membership = copy ? copy.membership : null; this.membership = copy ? copy.membership : null;
this.inviteCount = copy ? copy.inviteCount : 0; this.inviteCount = copy ? copy.inviteCount : 0;
@ -144,7 +145,7 @@ class SummaryData {
this.heroes = copy ? copy.heroes : null; this.heroes = copy ? copy.heroes : null;
this.canonicalAlias = copy ? copy.canonicalAlias : null; this.canonicalAlias = copy ? copy.canonicalAlias : null;
this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false; 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.avatarUrl = copy ? copy.avatarUrl : null;
this.notificationCount = copy ? copy.notificationCount : 0; this.notificationCount = copy ? copy.notificationCount : 0;
this.highlightCount = copy ? copy.highlightCount : 0; this.highlightCount = copy ? copy.highlightCount : 0;
@ -190,6 +191,11 @@ export class RoomSummary {
return this._data.heroes; return this._data.heroes;
} }
get encryption() {
return this._data.encryption;
}
// whether the room name should be determined with Heroes
get needsHeroes() { get needsHeroes() {
return needsHeroes(this._data); return needsHeroes(this._data);
} }
@ -230,10 +236,10 @@ export class RoomSummary {
return this._data.hasFetchedMembers; return this._data.hasFetchedMembers;
} }
get lastPaginationToken() { get isTrackingMembers() {
return this._data.lastPaginationToken; return this._data.isTrackingMembers;
} }
get tags() { get tags() {
return this._data.tags; return this._data.tags;
} }
@ -254,6 +260,13 @@ export class RoomSummary {
return data; 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) { writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
// clear cloned flag, so cloneIfNeeded makes a copy and // clear cloned flag, so cloneIfNeeded makes a copy and
// this._data is not modified if any field is changed. // this._data is not modified if any field is changed.

21
src/matrix/room/common.js Normal file
View file

@ -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;
}

View file

@ -42,11 +42,11 @@ export class Heroes {
/** /**
* @param {string[]} newHeroes array of user ids * @param {string[]} newHeroes array of user ids
* @param {RoomMember[]} changedMembers array of changed members in this sync * @param {Map<string, MemberChange>} memberChanges map of changed memberships
* @param {Transaction} txn * @param {Transaction} txn
* @return {Promise} * @return {Promise}
*/ */
async calculateChanges(newHeroes, changedMembers, txn) { async calculateChanges(newHeroes, memberChanges, txn) {
const updatedHeroMembers = new Map(); const updatedHeroMembers = new Map();
const removedUserIds = []; const removedUserIds = [];
// remove non-present members // remove non-present members
@ -56,9 +56,9 @@ export class Heroes {
} }
} }
// update heroes with synced member changes // update heroes with synced member changes
for (const member of changedMembers) { for (const [userId, memberChange] of memberChanges.entries()) {
if (this._members.has(member.userId) || newHeroes.indexOf(member.userId) !== -1) { if (this._members.has(userId) || newHeroes.indexOf(userId) !== -1) {
updatedHeroMembers.set(member.userId, member); updatedHeroMembers.set(userId, memberChange.member);
} }
} }
// load member for new heroes from storage // load member for new heroes from storage

View file

@ -26,9 +26,9 @@ export class MemberList {
this._retentionCount = 1; this._retentionCount = 1;
} }
afterSync(updatedMembers) { afterSync(memberChanges) {
for (const member of updatedMembers) { for (const [userId, memberChange] of memberChanges.entries()) {
this._members.add(member.userId, member); this._members.add(userId, memberChange.member);
} }
} }

View file

@ -1,5 +1,4 @@
/* /*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License"); 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. limitations under the License.
*/ */
import {getPrevContentFromStateEvent} from "../common.js";
export const EVENT_TYPE = "m.room.member"; export const EVENT_TYPE = "m.room.member";
export class RoomMember { export class RoomMember {
@ -28,7 +29,7 @@ export class RoomMember {
return; return;
} }
const content = memberEvent.content; const content = memberEvent.content;
const prevContent = memberEvent.unsigned?.prev_content; const prevContent = getPrevContentFromStateEvent(memberEvent);
const membership = content?.membership; const membership = content?.membership;
// fall back to prev_content for these as synapse doesn't (always?) // fall back to prev_content for these as synapse doesn't (always?)
// put them on content for "leave" memberships // put them on content for "leave" memberships
@ -45,7 +46,7 @@ export class RoomMember {
if (typeof userId !== "string") { if (typeof userId !== "string") {
return; return;
} }
const content = memberEvent.unsigned?.prev_content const content = getPrevContentFromStateEvent(memberEvent);
return this._validateAndCreateMember(roomId, userId, return this._validateAndCreateMember(roomId, userId,
content?.membership, content?.membership,
content?.displayname, content?.displayname,
@ -66,6 +67,10 @@ export class RoomMember {
}); });
} }
get membership() {
return this._data.membership;
}
/** /**
* @return {String?} the display name, if any * @return {String?} the display name, if any
*/ */
@ -99,3 +104,42 @@ export class RoomMember {
return this._data; 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";
}
}

View file

@ -25,13 +25,13 @@ async function loadMembers({roomId, storage}) {
return memberDatas.map(d => new RoomMember(d)); 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, // if any members are changed by sync while we're fetching members,
// they will end up here, so we check not to override them // they will end up here, so we check not to override them
const changedMembersDuringSync = new Map(); const changedMembersDuringSync = new Map();
setChangedMembersMap(changedMembersDuringSync); 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([ const txn = await storage.readWriteTxn([
storage.storeNames.roomSummary, storage.storeNames.roomSummary,

View file

@ -26,5 +26,12 @@ export class PendingEvent {
get remoteId() { return this._data.remoteId; } get remoteId() { return this._data.remoteId; }
set remoteId(value) { this._data.remoteId = value; } set remoteId(value) { this._data.remoteId = value; }
get content() { return this._data.content; } get content() { return this._data.content; }
get needsEncryption() { return this._data.needsEncryption; }
get data() { return this._data; } get data() { return this._data; }
setEncrypted(type, content) {
this._data.eventType = type;
this._data.content = content;
this._data.needsEncryption = false;
}
} }

View file

@ -17,12 +17,7 @@ limitations under the License.
import {SortedArray} from "../../../observable/list/SortedArray.js"; import {SortedArray} from "../../../observable/list/SortedArray.js";
import {ConnectionError} from "../../error.js"; import {ConnectionError} from "../../error.js";
import {PendingEvent} from "./PendingEvent.js"; import {PendingEvent} from "./PendingEvent.js";
import {makeTxnId} from "../../common.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;
}
export class SendQueue { export class SendQueue {
constructor({roomId, storage, sendScheduler, pendingEvents}) { constructor({roomId, storage, sendScheduler, pendingEvents}) {
@ -38,6 +33,11 @@ export class SendQueue {
this._isSending = false; this._isSending = false;
this._offline = false; this._offline = false;
this._amountSent = 0; this._amountSent = 0;
this._roomEncryption = null;
}
enableEncryption(roomEncryption) {
this._roomEncryption = roomEncryption;
} }
async _sendLoop() { async _sendLoop() {
@ -50,6 +50,13 @@ export class SendQueue {
if (pendingEvent.remoteId) { if (pendingEvent.remoteId) {
continue; 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"); console.log("really sending now");
const response = await this._sendScheduler.request(hsApi => { const response = await this._sendScheduler.request(hsApi => {
console.log("got sendScheduler slot"); console.log("got sendScheduler slot");
@ -161,7 +168,8 @@ export class SendQueue {
queueIndex, queueIndex,
eventType, eventType,
content, content,
txnId: makeTxnId() txnId: makeTxnId(),
needsEncryption: !!this._roomEncryption
}); });
console.log("_createAndStoreEvent: adding to pendingEventsStore"); console.log("_createAndStoreEvent: adding to pendingEventsStore");
pendingEventsStore.add(pendingEvent.data); pendingEventsStore.add(pendingEvent.data);

View file

@ -15,24 +15,27 @@ limitations under the License.
*/ */
import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js"; import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js";
import {Disposables} from "../../../utils/Disposables.js";
import {Direction} from "./Direction.js"; import {Direction} from "./Direction.js";
import {TimelineReader} from "./persistence/TimelineReader.js"; import {TimelineReader} from "./persistence/TimelineReader.js";
import {PendingEventEntry} from "./entries/PendingEventEntry.js"; import {PendingEventEntry} from "./entries/PendingEventEntry.js";
export class Timeline { export class Timeline {
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) { constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user, clock}) {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._closeCallback = closeCallback; this._closeCallback = closeCallback;
this._fragmentIdComparer = fragmentIdComparer; this._fragmentIdComparer = fragmentIdComparer;
this._disposables = new Disposables();
this._remoteEntries = new SortedArray((a, b) => a.compare(b)); this._remoteEntries = new SortedArray((a, b) => a.compare(b));
this._timelineReader = new TimelineReader({ this._timelineReader = new TimelineReader({
roomId: this._roomId, roomId: this._roomId,
storage: this._storage, storage: this._storage,
fragmentIdComparer: this._fragmentIdComparer fragmentIdComparer: this._fragmentIdComparer
}); });
this._readerRequest = null;
const localEntries = new MappedList(pendingEvents, pe => { const localEntries = new MappedList(pendingEvents, pe => {
return new PendingEventEntry({pendingEvent: pe, user}); return new PendingEventEntry({pendingEvent: pe, user, clock});
}, (pee, params) => { }, (pee, params) => {
pee.notifyUpdate(params); pee.notifyUpdate(params);
}); });
@ -41,8 +44,20 @@ export class Timeline {
/** @package */ /** @package */
async load() { async load() {
const entries = await this._timelineReader.readFromEnd(50); // 30 seems to be a good amount to fill the entire screen
this._remoteEntries.setManySorted(entries); 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 // TODO: should we rather have generic methods for
@ -64,12 +79,17 @@ export class Timeline {
if (!firstEventEntry) { if (!firstEventEntry) {
return; return;
} }
const entries = await this._timelineReader.readFrom( const readerRequest = this._disposables.track(this._timelineReader.readFrom(
firstEventEntry.asEventKey(), firstEventEntry.asEventKey(),
Direction.Backward, Direction.Backward,
amount amount
); ));
this._remoteEntries.setManySorted(entries); try {
const entries = await readerRequest.complete();
this._remoteEntries.setManySorted(entries);
} finally {
this._disposables.disposeTracked(readerRequest);
}
} }
/** @public */ /** @public */
@ -78,10 +98,15 @@ export class Timeline {
} }
/** @public */ /** @public */
close() { dispose() {
if (this._closeCallback) { if (this._closeCallback) {
this._disposables.dispose();
this._closeCallback(); this._closeCallback();
this._closeCallback = null; this._closeCallback = null;
} }
} }
enableEncryption(decryptEntries) {
this._timelineReader.enableEncryption(decryptEntries);
}
} }

View file

@ -15,11 +15,18 @@ limitations under the License.
*/ */
import {BaseEntry} from "./BaseEntry.js"; import {BaseEntry} from "./BaseEntry.js";
import {getPrevContentFromStateEvent} from "../../common.js";
export class EventEntry extends BaseEntry { export class EventEntry extends BaseEntry {
constructor(eventEntry, fragmentIdComparer) { constructor(eventEntry, fragmentIdComparer) {
super(fragmentIdComparer); super(fragmentIdComparer);
this._eventEntry = eventEntry; this._eventEntry = eventEntry;
this._decryptionError = null;
this._decryptionResult = null;
}
get event() {
return this._eventEntry.event;
} }
get fragmentId() { get fragmentId() {
@ -31,15 +38,16 @@ export class EventEntry extends BaseEntry {
} }
get content() { get content() {
return this._eventEntry.event.content; return this._decryptionResult?.event?.content || this._eventEntry.event.content;
} }
get prevContent() { 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() { get eventType() {
return this._eventEntry.event.type; return this._decryptionResult?.event?.type || this._eventEntry.event.type;
} }
get stateKey() { get stateKey() {
@ -65,4 +73,24 @@ export class EventEntry extends BaseEntry {
get id() { get id() {
return this._eventEntry.event.event_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;
}
} }

View file

@ -17,10 +17,11 @@ limitations under the License.
import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js"; import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js";
export class PendingEventEntry extends BaseEntry { export class PendingEventEntry extends BaseEntry {
constructor({pendingEvent, user}) { constructor({pendingEvent, user, clock}) {
super(null); super(null);
this._pendingEvent = pendingEvent; this._pendingEvent = pendingEvent;
this._user = user; this._user = user;
this._clock = clock;
} }
get fragmentId() { get fragmentId() {
@ -52,7 +53,7 @@ export class PendingEventEntry extends BaseEntry {
} }
get timestamp() { get timestamp() {
return null; return this._clock.now();
} }
get isPending() { get isPending() {

View file

@ -18,7 +18,7 @@ import {EventKey} from "../EventKey.js";
import {EventEntry} from "../entries/EventEntry.js"; import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js"; import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
import {createEventEntry} from "./common.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 // Synapse bug? where the m.room.create event appears twice in sync response
// when first syncing the room // when first syncing the room
@ -98,41 +98,41 @@ export class SyncWriter {
return {oldFragment, newFragment}; 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) { _writeStateEvent(event, txn) {
if (event.type === MEMBER_EVENT_TYPE) { if (event.type === MEMBER_EVENT_TYPE) {
const userId = event.state_key; return this._writeMember(event, txn);
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;
}
} else { } else {
txn.roomState.set(this._roomId, event); txn.roomState.set(this._roomId, event);
} }
} }
_writeStateEvents(roomResponse, txn) { _writeStateEvents(roomResponse, memberChanges, txn) {
const changedMembers = [];
// persist state // persist state
const {state} = roomResponse; const {state} = roomResponse;
if (Array.isArray(state?.events)) { if (Array.isArray(state?.events)) {
for (const event of state.events) { for (const event of state.events) {
const member = this._writeStateEvent(event, txn); const memberChange = this._writeStateEvent(event, txn);
if (member) { if (memberChange) {
changedMembers.push(member); memberChanges.set(memberChange.userId, memberChange);
} }
} }
} }
return changedMembers;
} }
async _writeTimeline(entries, timeline, currentKey, txn) { async _writeTimeline(entries, timeline, currentKey, memberChanges, txn) {
const changedMembers = []; if (Array.isArray(timeline.events)) {
if (timeline.events) {
const events = deduplicateEvents(timeline.events); const events = deduplicateEvents(timeline.events);
for(const event of events) { for(const event of events) {
// store event in timeline // store event in timeline
@ -145,17 +145,17 @@ export class SyncWriter {
} }
txn.timelineEvents.insert(entry); txn.timelineEvents.insert(entry);
entries.push(new EventEntry(entry, this._fragmentIdComparer)); entries.push(new EventEntry(entry, this._fragmentIdComparer));
// process live state events first, so new member info is available // process live state events first, so new member info is available
if (typeof event.state_key === "string") { if (typeof event.state_key === "string") {
const member = this._writeStateEvent(event, txn); const memberChange = this._writeStateEvent(event, txn);
if (member) { if (memberChange) {
changedMembers.push(member); memberChanges.set(memberChange.userId, memberChange);
} }
} }
} }
} }
return {currentKey, changedMembers}; return currentKey;
} }
async _findMemberData(userId, events, txn) { async _findMemberData(userId, events, txn) {
@ -176,6 +176,16 @@ export class SyncWriter {
} }
} }
/**
* @type {SyncWriterResult}
* @property {Array<BaseEntry>} entries new timeline entries written
* @property {EventKey} newLiveKey the advanced key to write events at
* @property {Map<string, MemberChange>} memberChanges member changes in the processed sync ny user id
*
* @param {Object} roomResponse [description]
* @param {Transaction} txn
* @return {SyncWriterResult}
*/
async writeSync(roomResponse, txn) { async writeSync(roomResponse, txn) {
const entries = []; const entries = [];
const {timeline} = roomResponse; const {timeline} = roomResponse;
@ -196,14 +206,12 @@ export class SyncWriter {
entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer)); entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer));
entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer)); entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer));
} }
const memberChanges = new Map();
// important this happens before _writeTimeline so // important this happens before _writeTimeline so
// members are available in the transaction // members are available in the transaction
const changedMembers = this._writeStateEvents(roomResponse, txn); this._writeStateEvents(roomResponse, memberChanges, txn);
const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn); currentKey = await this._writeTimeline(entries, timeline, currentKey, memberChanges, txn);
currentKey = timelineResult.currentKey; return {entries, newLiveKey: currentKey, memberChanges};
changedMembers.push(...timelineResult.changedMembers);
return {entries, newLiveKey: currentKey, changedMembers};
} }
afterSync(newLiveKey) { afterSync(newLiveKey) {

View file

@ -19,26 +19,74 @@ import {Direction} from "../Direction.js";
import {EventEntry} from "../entries/EventEntry.js"; import {EventEntry} from "../entries/EventEntry.js";
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.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 { export class TimelineReader {
constructor({roomId, storage, fragmentIdComparer}) { constructor({roomId, storage, fragmentIdComparer}) {
this._roomId = roomId; this._roomId = roomId;
this._storage = storage; this._storage = storage;
this._fragmentIdComparer = fragmentIdComparer; this._fragmentIdComparer = fragmentIdComparer;
this._decryptEntries = null;
}
enableEncryption(decryptEntries) {
this._decryptEntries = decryptEntries;
} }
_openTxn() { _openTxn() {
return this._storage.readTxn([ const stores = [
this._storage.storeNames.timelineEvents, this._storage.storeNames.timelineEvents,
this._storage.storeNames.timelineFragments, this._storage.storeNames.timelineFragments,
]); ];
if (this._decryptEntries) {
stores.push(this._storage.storeNames.inboundGroupSessions);
}
return this._storage.readTxn(stores);
} }
async readFrom(eventKey, direction, amount) { readFrom(eventKey, direction, amount) {
const txn = await this._openTxn(); return new ReaderRequest(async r => {
return this._readFrom(eventKey, direction, amount, txn); 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 = []; let entries = [];
const timelineStore = txn.timelineEvents; const timelineStore = txn.timelineEvents;
const fragmentStore = txn.timelineFragments; const fragmentStore = txn.timelineFragments;
@ -50,7 +98,7 @@ export class TimelineReader {
} else { } else {
eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount); 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); entries = directionalConcat(entries, eventEntries, direction);
// prepend or append eventsWithinFragment to entries, and wrap them in EventEntry // prepend or append eventsWithinFragment to entries, and wrap them in EventEntry
@ -73,27 +121,14 @@ export class TimelineReader {
} }
} }
return entries; if (this._decryptEntries) {
} r.decryptRequest = this._decryptEntries(entries, txn);
try {
async readFromEnd(amount) { await r.decryptRequest.complete();
const txn = await this._openTxn(); } finally {
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId); r.decryptRequest = null;
// room hasn't been synced yet }
if (!liveFragment) {
return [];
} }
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; return entries;
} }
// reads distance up and down from eventId
// or just expose eventIdToKey?
readAtEventId(eventId, distance) {
return null;
}
} }

View file

@ -22,6 +22,13 @@ export const STORE_NAMES = Object.freeze([
"timelineEvents", "timelineEvents",
"timelineFragments", "timelineFragments",
"pendingEvents", "pendingEvents",
"userIdentities",
"deviceIdentities",
"olmSessions",
"inboundGroupSessions",
"outboundGroupSessions",
"groupSessionDecryptions",
"operations"
]); ]);
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => { export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {

View file

@ -42,7 +42,15 @@ export class QueryTarget {
} }
getKey(key) { 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) { reduce(range, reducer, initialValue) {
@ -105,6 +113,13 @@ export class QueryTarget {
return maxKey; 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. * 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). * 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; 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) { async _find(range, predicate, direction) {
const cursor = this._openCursor(range, direction); const cursor = this._openCursor(range, direction);
let result; let result;

View file

@ -23,6 +23,14 @@ class QueryTargetWrapper {
this._qt = qt; this._qt = qt;
} }
get keyPath() {
if (this._qt.objectStore) {
return this._qt.objectStore.keyPath;
} else {
return this._qt.keyPath;
}
}
supports(methodName) { supports(methodName) {
return !!this._qt[methodName]; return !!this._qt[methodName];
} }

View file

@ -24,6 +24,13 @@ import {RoomStateStore} from "./stores/RoomStateStore.js";
import {RoomMemberStore} from "./stores/RoomMemberStore.js"; import {RoomMemberStore} from "./stores/RoomMemberStore.js";
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js"; import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
import {PendingEventStore} from "./stores/PendingEventStore.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 { export class Transaction {
constructor(txn, allowedStoreNames) { constructor(txn, allowedStoreNames) {
@ -81,6 +88,34 @@ export class Transaction {
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore)); 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() { complete() {
return txnAsPromise(this._txn); return txnAsPromise(this._txn);
} }

View file

@ -9,6 +9,7 @@ export const schema = [
createInitialStores, createInitialStores,
createMemberStore, createMemberStore,
migrateSession, migrateSession,
createE2EEStores
]; ];
// TODO: how to deal with git merge conflicts of this array? // 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) { async function migrateSession(db, txn) {
const session = txn.objectStore("session"); const session = txn.objectStore("session");
try { try {
@ -64,3 +65,15 @@ async function migrateSession(db, txn) {
console.error("could not migrate session", err.stack); 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});
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -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));
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

View file

@ -52,13 +52,7 @@ export class PendingEventStore {
async exists(roomId, queueIndex) { async exists(roomId, queueIndex) {
const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex)); const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex));
let key; const key = await this._eventStore.getKey(keyRange);
if (this._eventStore.supports("getKey")) {
key = await this._eventStore.getKey(keyRange);
} else {
const value = await this._eventStore.get(keyRange);
key = value && value.key;
}
return !!key; return !!key;
} }

View file

@ -19,6 +19,11 @@ function encodeKey(roomId, userId) {
return `${roomId}|${userId}`; return `${roomId}|${userId}`;
} }
function decodeKey(key) {
const [roomId, userId] = key.split("|");
return {roomId, userId};
}
// no historical members // no historical members
export class RoomMemberStore { export class RoomMemberStore {
constructor(roomMembersStore) { constructor(roomMembersStore) {
@ -40,4 +45,19 @@ export class RoomMemberStore {
return member.roomId === roomId; 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;
}
} }

View file

@ -14,22 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
/**
store contains:
loginData {
device_id
home_server
access_token
user_id
}
// flags {
// lazyLoading?
// }
syncToken
displayName
avatarUrl
lastSynced
*/
export class SessionStore { export class SessionStore {
constructor(sessionStore) { constructor(sessionStore) {
this._sessionStore = sessionStore; this._sessionStore = sessionStore;
@ -45,4 +29,12 @@ export class SessionStore {
set(key, value) { set(key, value) {
return this._sessionStore.put({key, value}); return this._sessionStore.put({key, value});
} }
add(key, value) {
return this._sessionStore.add({key, value});
}
remove(key) {
this._sessionStore.delete(key);
}
} }

View file

@ -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);
}
}

View file

@ -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) { set(item, updateParams = null) {
const idx = sortedIndex(this._items, item, this._comparator); const idx = sortedIndex(this._items, item, this._comparator);
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) { if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {

View file

@ -70,6 +70,10 @@ export class ObservableMap extends BaseObservableMap {
[Symbol.iterator]() { [Symbol.iterator]() {
return this._values.entries(); return this._values.entries();
} }
values() {
return this._values.values();
}
} }
export function tests() { export function tests() {

View file

@ -15,9 +15,18 @@ limitations under the License.
*/ */
export function spinner(t, extraClasses = undefined) { export function spinner(t, extraClasses = undefined) {
return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"}, if (document.body.classList.contains("ie11")) {
t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"}) 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"})
);
}
} }
/** /**

View file

@ -81,5 +81,5 @@ limitations under the License.
} }
.TimelineLoadingView div { .TimelineLoadingView div {
margin-left: 10px; margin-right: 10px;
} }

View file

@ -32,24 +32,57 @@ limitations under the License.
} }
} }
.spinner circle { .not-ie11 .spinner circle {
transform-origin: 50% 50%; transform-origin: 50% 50%;
animation-name: spinner; animation-name: spinner;
animation-duration: 2s; animation-duration: 2s;
animation-iteration-count: infinite; animation-iteration-count: infinite;
animation-timing-function: linear; 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; stroke-dasharray: 0 0 85 85;
fill: none; fill: none;
stroke: currentcolor; stroke: currentcolor;
stroke-width: 12; stroke-width: 12;
stroke-linecap: butt; 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 { .spinner {
--size: 20px; --size: 20px;
width: var(--size); width: var(--size);

View file

@ -373,6 +373,10 @@ ul.Timeline > li.continuation time {
color: #ccc; color: #ccc;
} }
.TextMessageView.unverified .message-container {
color: #ff4b55;
}
.message-container p { .message-container p {
margin: 3px 0; margin: 3px 0;
line-height: 2.2rem; line-height: 2.2rem;

View file

@ -77,6 +77,6 @@ limitations under the License.
} }
.GapView > div { .GapView > div {
flex: 1; flex: 1 1 0;
margin-left: 10px; margin-right: 10px;
} }

View file

@ -24,7 +24,7 @@ export class MessageComposer extends TemplateView {
render(t, vm) { render(t, vm) {
this._input = t.input({ this._input = t.input({
placeholder: "Send a message ...", placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
onKeydown: e => this._onKeyDown(e), onKeydown: e => this._onKeyDown(e),
onInput: () => vm.setInput(this._input.value), onInput: () => vm.setInput(this._input.value),
}); });

View file

@ -21,7 +21,7 @@ export class TimelineLoadingView extends TemplateView {
render(t, vm) { render(t, vm) {
return t.div({className: "TimelineLoadingView"}, [ return t.div({className: "TimelineLoadingView"}, [
spinner(t), spinner(t),
t.div(vm.i18n`Loading messages…`) t.div(vm.isEncrypted ? vm.i18n`Loading encrypted messages…` : vm.i18n`Loading messages…`)
]); ]);
} }
} }

View file

@ -22,12 +22,13 @@ export function renderMessage(t, vm, children) {
"TextMessageView": true, "TextMessageView": true,
own: vm.isOwn, own: vm.isOwn,
pending: vm.isPending, pending: vm.isPending,
unverified: vm.isUnverified,
continuation: vm => vm.isContinuation, continuation: vm => vm.isContinuation,
}; };
const profile = t.div({className: "profile"}, [ const profile = t.div({className: "profile"}, [
renderAvatar(t, vm, 30), 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); children = [profile].concat(children);
return t.li( return t.li(

View file

@ -5,7 +5,7 @@
<link rel="stylesheet" type="text/css" href="css/main.css"> <link rel="stylesheet" type="text/css" href="css/main.css">
<link rel="stylesheet" type="text/css" href="css/themes/element/theme.css"> <link rel="stylesheet" type="text/css" href="css/themes/element/theme.css">
</head> </head>
<body> <body class="not-ie11">
<script type="text/javascript"> <script type="text/javascript">
function vm(o) { function vm(o) {
// fake EventEmitter // fake EventEmitter

View file

@ -28,7 +28,11 @@ export class Disposables {
} }
track(disposable) { track(disposable) {
if (this.isDisposed) {
throw new Error("Already disposed, check isDisposed after await if needed");
}
this._disposables.push(disposable); this._disposables.push(disposable);
return disposable;
} }
dispose() { dispose() {
@ -40,8 +44,12 @@ export class Disposables {
} }
} }
get isDisposed() {
return this._disposables === null;
}
disposeTracked(value) { disposeTracked(value) {
if (value === undefined || value === null) { if (value === undefined || value === null || this.isDisposed) {
return null; return null;
} }
const idx = this._disposables.indexOf(value); const idx = this._disposables.indexOf(value);

86
src/utils/Lock.js Normal file
View file

@ -0,0 +1,86 @@
/*
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 Lock {
constructor() {
this._promise = null;
this._resolve = null;
}
take() {
if (!this._promise) {
this._promise = new Promise(resolve => {
this._resolve = resolve;
});
return true;
}
return false;
}
get isTaken() {
return !!this._promise;
}
release() {
if (this._resolve) {
this._promise = null;
const resolve = this._resolve;
this._resolve = null;
resolve();
}
}
released() {
return this._promise;
}
}
export function tests() {
return {
"taking a lock twice returns false": assert => {
const lock = new Lock();
assert.equal(lock.take(), true);
assert.equal(lock.isTaken, true);
assert.equal(lock.take(), false);
},
"can take a released lock again": assert => {
const lock = new Lock();
lock.take();
lock.release();
assert.equal(lock.isTaken, false);
assert.equal(lock.take(), true);
},
"2 waiting for lock, only first one gets it": async assert => {
const lock = new Lock();
lock.take();
let first;
lock.released().then(() => first = lock.take());
let second;
lock.released().then(() => second = lock.take());
const promise = lock.released();
lock.release();
await promise;
assert.strictEqual(first, true);
assert.strictEqual(second, false);
},
"await non-taken lock": async assert => {
const lock = new Lock();
await lock.released();
assert(true);
}
}
}

93
src/utils/LockMap.js Normal file
View file

@ -0,0 +1,93 @@
/*
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 {Lock} from "./Lock.js";
export class LockMap {
constructor() {
this._map = new Map();
}
async takeLock(key) {
let lock = this._map.get(key);
if (lock) {
while (!lock.take()) {
await lock.released();
}
} else {
lock = new Lock();
lock.take();
this._map.set(key, lock);
}
// don't leave old locks lying around
lock.released().then(() => {
// give others a chance to take the lock first
Promise.resolve().then(() => {
if (!lock.isTaken) {
this._map.delete(key);
}
});
});
return lock;
}
}
export function tests() {
return {
"taking a lock on the same key blocks": async assert => {
const lockMap = new LockMap();
const lock = await lockMap.takeLock("foo");
let second = false;
const prom = lockMap.takeLock("foo").then(() => {
second = true;
});
assert.equal(second, false);
// do a delay to make sure prom does not resolve on its own
await Promise.resolve();
lock.release();
await prom;
assert.equal(second, true);
},
"lock is not cleaned up with second request": async assert => {
const lockMap = new LockMap();
const lock = await lockMap.takeLock("foo");
let ranSecond = false;
const prom = lockMap.takeLock("foo").then(returnedLock => {
ranSecond = true;
assert.equal(returnedLock.isTaken, true);
// peek into internals, naughty
assert.equal(lockMap._map.get("foo"), returnedLock);
});
lock.release();
await prom;
// double delay to make sure cleanup logic ran
await Promise.resolve();
await Promise.resolve();
assert.equal(ranSecond, true);
},
"lock is cleaned up without other request": async assert => {
const lockMap = new LockMap();
const lock = await lockMap.takeLock("foo");
await Promise.resolve();
lock.release();
// double delay to make sure cleanup logic ran
await Promise.resolve();
await Promise.resolve();
assert.equal(lockMap._map.has("foo"), false);
},
};
}

211
src/utils/WorkerPool.js Normal file
View file

@ -0,0 +1,211 @@
/*
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 {AbortError} from "./error.js";
class WorkerState {
constructor(worker) {
this.worker = worker;
this.busy = false;
}
attach(pool) {
this.worker.addEventListener("message", pool);
this.worker.addEventListener("error", pool);
}
detach(pool) {
this.worker.removeEventListener("message", pool);
this.worker.removeEventListener("error", pool);
}
}
class Request {
constructor(message, pool) {
this._promise = new Promise((_resolve, _reject) => {
this._resolve = _resolve;
this._reject = _reject;
});
this._message = message;
this._pool = pool;
this._worker = null;
}
abort() {
if (this._isNotDisposed) {
this._pool._abortRequest(this);
this._dispose();
}
}
response() {
return this._promise;
}
_dispose() {
this._reject = null;
this._resolve = null;
}
get _isNotDisposed() {
return this._resolve && this._reject;
}
}
export class WorkerPool {
// TODO: extract DOM specific bits and write unit tests
constructor(path, amount) {
this._workers = [];
for (let i = 0; i < amount ; ++i) {
const worker = new WorkerState(new Worker(path));
worker.attach(this);
this._workers[i] = worker;
}
this._requests = new Map();
this._counter = 0;
this._pendingFlag = false;
this._init = null;
}
init() {
const promise = new Promise((resolve, reject) => {
this._init = {resolve, reject};
});
this.sendAll({type: "ping"})
.then(this._init.resolve, this._init.reject)
.finally(() => {
this._init = null;
});
return promise;
}
handleEvent(e) {
if (e.type === "message") {
const message = e.data;
const request = this._requests.get(message.replyToId);
if (request) {
request._worker.busy = false;
if (request._isNotDisposed) {
if (message.type === "success") {
request._resolve(message.payload);
} else if (message.type === "error") {
request._reject(new Error(message.stack));
}
request._dispose();
}
this._requests.delete(message.replyToId);
}
this._sendPending();
} else if (e.type === "error") {
if (this._init) {
this._init.reject(new Error("worker error during init"));
}
console.error("worker error", e);
}
}
_getPendingRequest() {
for (const r of this._requests.values()) {
if (!r._worker) {
return r;
}
}
}
_getFreeWorker() {
for (const w of this._workers) {
if (!w.busy) {
return w;
}
}
}
_sendPending() {
this._pendingFlag = false;
let success;
do {
success = false;
const request = this._getPendingRequest();
if (request) {
const worker = this._getFreeWorker();
if (worker) {
this._sendWith(request, worker);
success = true;
}
}
} while (success);
}
_sendWith(request, worker) {
request._worker = worker;
worker.busy = true;
worker.worker.postMessage(request._message);
}
_enqueueRequest(message) {
this._counter += 1;
message.id = this._counter;
const request = new Request(message, this);
this._requests.set(message.id, request);
return request;
}
send(message) {
const request = this._enqueueRequest(message);
const worker = this._getFreeWorker();
if (worker) {
this._sendWith(request, worker);
}
return request;
}
// assumes all workers are free atm
sendAll(message) {
const promises = this._workers.map(worker => {
const request = this._enqueueRequest(Object.assign({}, message));
this._sendWith(request, worker);
return request.response();
});
return Promise.all(promises);
}
dispose() {
for (const w of this._workers) {
w.detach(this);
w.worker.terminate();
}
}
_trySendPendingInNextTick() {
if (!this._pendingFlag) {
this._pendingFlag = true;
Promise.resolve().then(() => {
this._sendPending();
});
}
}
_abortRequest(request) {
request._reject(new AbortError());
if (request._worker) {
request._worker.busy = false;
}
this._requests.delete(request._message.id);
// allow more requests to be aborted before trying to send other pending
this._trySendPendingInNextTick();
}
}

35
src/utils/groupBy.js Normal file
View file

@ -0,0 +1,35 @@
/*
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 groupBy(array, groupFn) {
return groupByWithCreator(array, groupFn,
() => {return [];},
(array, value) => array.push(value)
);
}
export function groupByWithCreator(array, groupFn, createCollectionFn, addCollectionFn) {
return array.reduce((map, value) => {
const key = groupFn(value);
let collection = map.get(key);
if (!collection) {
collection = createCollectionFn();
map.set(key, collection);
}
addCollectionFn(collection, value);
return map;
}, new Map());
}

41
src/utils/mergeMap.js Normal file
View file

@ -0,0 +1,41 @@
/*
Copyright 2020 Bruno Windels <bruno@windels.cloud>
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 mergeMap(src, dst) {
if (src) {
for (const [key, value] of src.entries()) {
dst.set(key, value);
}
}
}
export function tests() {
return {
"mergeMap with src": assert => {
const src = new Map();
src.set(1, "a");
const dst = new Map();
dst.set(2, "b");
mergeMap(src, dst);
assert.equal(dst.get(1), "a");
assert.equal(dst.get(2), "b");
assert.equal(src.get(2), null);
},
"mergeMap without src doesn't fail": () => {
mergeMap(undefined, new Map());
}
}
}

23
src/worker-polyfill.js Normal file
View file

@ -0,0 +1,23 @@
/*
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.
*/
// polyfills needed for IE11
// just enough to run olm, have promises and async/await
import "regenerator-runtime/runtime";
import "core-js/modules/es.promise";
import "core-js/modules/es.math.imul";
import "core-js/modules/es.math.clz32";

156
src/worker.js Normal file
View file

@ -0,0 +1,156 @@
/*
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 asErrorMessage(err) {
return {
type: "error",
message: err.message,
stack: err.stack
};
}
function asSuccessMessage(payload) {
return {
type: "success",
payload
};
}
class MessageHandler {
constructor() {
this._olm = null;
this._randomValues = self.crypto ? null : [];
}
_feedRandomValues(randomValues) {
if (this._randomValues) {
this._randomValues.push(...randomValues);
}
}
_checkRandomValuesUsed() {
if (this._randomValues && this._randomValues.length !== 0) {
throw new Error(`${this._randomValues.length} random values left`);
}
}
_getRandomValues(typedArray) {
if (!(typedArray instanceof Uint8Array)) {
throw new Error("only Uint8Array is supported: " + JSON.stringify({
Int8Array: typedArray instanceof Int8Array,
Uint8Array: typedArray instanceof Uint8Array,
Int16Array: typedArray instanceof Int16Array,
Uint16Array: typedArray instanceof Uint16Array,
Int32Array: typedArray instanceof Int32Array,
Uint32Array: typedArray instanceof Uint32Array,
}));
}
if (this._randomValues.length === 0) {
throw new Error("no more random values, needed one of length " + typedArray.length);
}
const precalculated = this._randomValues.shift();
if (precalculated.length !== typedArray.length) {
throw new Error(`typedArray length (${typedArray.length}) does not match precalculated length (${precalculated.length})`);
}
// copy values
for (let i = 0; i < typedArray.length; ++i) {
typedArray[i] = precalculated[i];
}
return typedArray;
}
handleEvent(e) {
if (e.type === "message") {
this._handleMessage(e.data);
}
}
_sendReply(refMessage, reply) {
reply.replyToId = refMessage.id;
self.postMessage(reply);
}
_toMessage(fn) {
try {
const payload = fn();
if (payload instanceof Promise) {
return payload.then(
payload => asSuccessMessage(payload),
err => asErrorMessage(err)
);
} else {
return asSuccessMessage(payload);
}
} catch (err) {
return asErrorMessage(err);
}
}
_loadOlm(path) {
return this._toMessage(async () => {
if (!self.crypto) {
self.crypto = {getRandomValues: this._getRandomValues.bind(this)};
}
// mangle the globals enough to make olm believe it is running in a browser
self.window = self;
self.document = {};
self.importScripts(path);
const olm = self.olm_exports;
await olm.init();
this._olm = olm;
});
}
_megolmDecrypt(sessionKey, ciphertext) {
return this._toMessage(() => {
let session;
try {
session = new this._olm.InboundGroupSession();
session.import_session(sessionKey);
// returns object with plaintext and message_index
return session.decrypt(ciphertext);
} finally {
session?.free();
}
});
}
_olmCreateAccountAndOTKs(randomValues, otkAmount) {
return this._toMessage(() => {
this._feedRandomValues(randomValues);
const account = new this._olm.Account();
account.create();
account.generate_one_time_keys(otkAmount);
this._checkRandomValuesUsed();
return account.pickle("");
});
}
async _handleMessage(message) {
const {type} = message;
if (type === "ping") {
this._sendReply(message, {type: "success"});
} else if (type === "load_olm") {
this._sendReply(message, await this._loadOlm(message.path));
} else if (type === "megolm_decrypt") {
this._sendReply(message, this._megolmDecrypt(message.sessionKey, message.ciphertext));
} else if (type === "olm_create_account_otks") {
this._sendReply(message, this._olmCreateAccountAndOTKs(message.randomValues, message.otkAmount));
}
}
}
self.addEventListener("message", new MessageHandler());

View file

@ -907,6 +907,11 @@
dependencies: dependencies:
"@types/node" "*" "@types/node" "*"
another-json@^0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/another-json/-/another-json-0.2.0.tgz#b5f4019c973b6dd5c6506a2d93469cb6d32aeedc"
integrity sha1-tfQBnJc7bdXGUGotk0acttMq7tw=
ansi-styles@^3.2.1: ansi-styles@^3.2.1:
version "3.2.1" version "3.2.1"
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d"
@ -1491,6 +1496,10 @@ object.assign@^4.1.0:
has-symbols "^1.0.0" has-symbols "^1.0.0"
object-keys "^1.0.11" object-keys "^1.0.11"
"olm@https://packages.matrix.org/npm/olm/olm-3.1.4.tgz":
version "3.1.4"
resolved "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz#0f03128b7d3b2f614d2216409a1dfccca765fdb3"
on-finished@~2.3.0: on-finished@~2.3.0:
version "2.3.0" version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947" resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"