mirror of
https://github.com/vector-im/hydrogen-web.git
synced 2024-12-23 03:25:12 +01:00
Merge pull request #81 from vector-im/bwindels/e2ee
Implement end-to-end encryption
This commit is contained in:
commit
47a238f498
1
.gitignore
vendored
1
.gitignore
vendored
@ -5,3 +5,4 @@ fetchlogs
|
||||
sessionexports
|
||||
bundle.js
|
||||
target
|
||||
lib
|
||||
|
@ -18,7 +18,14 @@
|
||||
</script>
|
||||
<script id="main" type="module">
|
||||
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 id="service-worker" type="disabled">
|
||||
if('serviceWorker' in navigator) {
|
||||
|
@ -1 +0,0 @@
|
||||
../node_modules/olm/olm.js
|
@ -1 +0,0 @@
|
||||
../node_modules/olm/olm.wasm
|
@ -9,7 +9,8 @@
|
||||
"scripts": {
|
||||
"test": "node_modules/.bin/impunity --entry-point src/main.js --force-esm-dirs lib/ src/",
|
||||
"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": {
|
||||
"type": "git",
|
||||
@ -44,5 +45,9 @@
|
||||
"rollup-plugin-cleanup": "^3.1.1",
|
||||
"serve-static": "^1.13.2",
|
||||
"xxhashjs": "^0.2.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"another-json": "^0.2.0",
|
||||
"olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz"
|
||||
}
|
||||
}
|
||||
|
128
prototypes/olmtest-ie11.html
Normal file
128
prototypes/olmtest-ie11.html
Normal 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>
|
||||
|
@ -12,13 +12,13 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<script type="text/javascript" src="../lib/olm.js"></script>
|
||||
<script type="text/javascript" src="../lib/olm/olm.js"></script>
|
||||
<script type="module">
|
||||
|
||||
async function main() {
|
||||
const Olm = window.Olm;
|
||||
await Olm.init({
|
||||
locateFile: () => "../lib/olm.wasm",
|
||||
locateFile: () => "../lib/olm/olm.wasm",
|
||||
});
|
||||
const alice = new Olm.Account();
|
||||
alice.create();
|
||||
|
@ -58,6 +58,12 @@ program.parse(process.argv);
|
||||
const {debug, noOffline} = program;
|
||||
const offline = !noOffline;
|
||||
|
||||
const olmFiles = {
|
||||
wasm: "olm-4289088762.wasm",
|
||||
legacyBundle: "olm_legacy-3232457086.js",
|
||||
wasmBundle: "olm-1421970081.js",
|
||||
};
|
||||
|
||||
async function build() {
|
||||
// only used for CSS for now, using legacy for all targets for now
|
||||
const legacy = true;
|
||||
@ -73,13 +79,16 @@ async function build() {
|
||||
// clear target dir
|
||||
await removeDirIfExists(targetDir);
|
||||
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,
|
||||
// so do it first
|
||||
const themeAssets = await copyThemeAssets(themes, legacy);
|
||||
const jsBundlePath = await buildJs();
|
||||
const jsLegacyBundlePath = await buildJsLegacy();
|
||||
const jsBundlePath = await buildJs("src/main.js", `${PROJECT_ID}.js`);
|
||||
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 assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, themeAssets);
|
||||
const assetPaths = createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets);
|
||||
|
||||
let manifestPath;
|
||||
if (offline) {
|
||||
@ -90,7 +99,7 @@ async function build() {
|
||||
console.log(`built ${PROJECT_ID} ${version} successfully`);
|
||||
}
|
||||
|
||||
function createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, themeAssets) {
|
||||
function createAssetPaths(jsBundlePath, jsLegacyBundlePath, jsWorkerPath, cssBundlePaths, themeAssets) {
|
||||
function trim(path) {
|
||||
if (!path.startsWith(targetDir)) {
|
||||
throw new Error("invalid target path: " + targetDir);
|
||||
@ -100,6 +109,7 @@ function createAssetPaths(jsBundlePath, jsLegacyBundlePath, cssBundlePaths, them
|
||||
return {
|
||||
jsBundle: () => trim(jsBundlePath),
|
||||
jsLegacyBundle: () => trim(jsLegacyBundlePath),
|
||||
jsWorker: () => trim(jsWorkerPath),
|
||||
cssMainBundle: () => trim(cssBundlePaths.main),
|
||||
cssThemeBundle: themeName => trim(cssBundlePaths.themes[themeName]),
|
||||
cssThemeBundles: () => Object.values(cssBundlePaths.themes).map(a => trim(a)),
|
||||
@ -153,10 +163,14 @@ async function buildHtml(doc, version, assetPaths, manifestPath) {
|
||||
findThemes(doc, (themeName, theme) => {
|
||||
theme.attr("href", assetPaths.cssThemeBundle(themeName));
|
||||
});
|
||||
const pathsJSON = JSON.stringify({
|
||||
worker: assetPaths.jsWorker(),
|
||||
olm: olmFiles
|
||||
});
|
||||
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>${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);
|
||||
|
||||
const versionScript = doc("script#version");
|
||||
@ -172,23 +186,24 @@ async function buildHtml(doc, version, assetPaths, manifestPath) {
|
||||
await fs.writeFile(path.join(targetDir, "index.html"), doc.html(), "utf8");
|
||||
}
|
||||
|
||||
async function buildJs() {
|
||||
async function buildJs(inputFile, outputName) {
|
||||
// create js bundle
|
||||
const bundle = await rollup({
|
||||
input: 'src/main.js',
|
||||
input: inputFile,
|
||||
plugins: [removeJsComments({comments: "none"})]
|
||||
});
|
||||
const {output} = await bundle.generate({
|
||||
format: 'es',
|
||||
// TODO: can remove this?
|
||||
name: `${PROJECT_ID}Bundle`
|
||||
});
|
||||
const code = output[0].code;
|
||||
const bundlePath = resource(`${PROJECT_ID}.js`, code);
|
||||
const bundlePath = resource(outputName, code);
|
||||
await fs.writeFile(bundlePath, code, "utf8");
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
async function buildJsLegacy() {
|
||||
async function buildJsLegacy(inputFile, outputName, polyfillFile = null) {
|
||||
// compile down to whatever IE 11 needs
|
||||
const babelPlugin = babel.babel({
|
||||
babelHelpers: 'bundled',
|
||||
@ -204,9 +219,12 @@ async function buildJsLegacy() {
|
||||
]
|
||||
]
|
||||
});
|
||||
if (!polyfillFile) {
|
||||
polyfillFile = 'src/legacy-polyfill.js';
|
||||
}
|
||||
// create js bundle
|
||||
const rollupConfig = {
|
||||
input: ['src/legacy-polyfill.js', 'src/main.js'],
|
||||
input: [polyfillFile, inputFile],
|
||||
plugins: [multi(), commonjs(), nodeResolve(), babelPlugin, removeJsComments({comments: "none"})]
|
||||
};
|
||||
const bundle = await rollup(rollupConfig);
|
||||
@ -215,11 +233,16 @@ async function buildJsLegacy() {
|
||||
name: `${PROJECT_ID}Bundle`
|
||||
});
|
||||
const code = output[0].code;
|
||||
const bundlePath = resource(`${PROJECT_ID}-legacy.js`, code);
|
||||
const bundlePath = resource(outputName, code);
|
||||
await fs.writeFile(bundlePath, code, "utf8");
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
function buildWorkerJsLegacy(inputFile, outputName) {
|
||||
const polyfillFile = 'src/worker-polyfill.js';
|
||||
return buildJsLegacy(inputFile, outputName, polyfillFile);
|
||||
}
|
||||
|
||||
async function buildOffline(version, assetPaths) {
|
||||
// write offline availability
|
||||
const offlineFiles = [
|
||||
@ -338,7 +361,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
|
||||
if (dirEnt.isDirectory()) {
|
||||
await fs.mkdir(dstPath);
|
||||
Object.assign(assetPaths, await copyFolder(srcPath, dstPath, filter));
|
||||
} else if (dirEnt.isFile() && filter(srcPath)) {
|
||||
} else if ((dirEnt.isFile() || dirEnt.isSymbolicLink()) && (!filter || filter(srcPath))) {
|
||||
const content = await fs.readFile(srcPath);
|
||||
const hashedDstPath = resource(dstPath, content);
|
||||
await fs.writeFile(hashedDstPath, content);
|
||||
@ -350,7 +373,7 @@ async function copyFolder(srcRoot, dstRoot, filter) {
|
||||
|
||||
function resource(relPath, content) {
|
||||
let fullPath = relPath;
|
||||
if (!relPath.startsWith("/")) {
|
||||
if (!path.isAbsolute(relPath)) {
|
||||
fullPath = path.join(targetDir, relPath);
|
||||
}
|
||||
const hash = contentHash(Buffer.from(content));
|
||||
|
12
scripts/common.mjs
Normal file
12
scripts/common.mjs
Normal 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
64
scripts/post-install.mjs
Normal 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();
|
@ -82,7 +82,7 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
if (this._sessionContainer) {
|
||||
this._sessionContainer.stop();
|
||||
if (this._deleteSessionOnCancel) {
|
||||
await this._sessionContainer.deletSession();
|
||||
await this._sessionContainer.deleteSession();
|
||||
}
|
||||
this._sessionContainer = null;
|
||||
}
|
||||
@ -127,6 +127,8 @@ export class SessionLoadViewModel extends ViewModel {
|
||||
return `Something went wrong while checking your login and password.`;
|
||||
}
|
||||
break;
|
||||
case LoadStatus.SessionSetup:
|
||||
return `Setting up your encryption keys…`;
|
||||
case LoadStatus.Loading:
|
||||
return `Loading your conversations…`;
|
||||
case LoadStatus.FirstSync:
|
||||
|
@ -36,8 +36,7 @@ export class ViewModel extends EventEmitter {
|
||||
if (!this.disposables) {
|
||||
this.disposables = new Disposables();
|
||||
}
|
||||
this.disposables.track(disposable);
|
||||
return disposable;
|
||||
return this.disposables.track(disposable);
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
@ -38,7 +38,8 @@ export class RoomViewModel extends ViewModel {
|
||||
async load() {
|
||||
this._room.on("change", this._onRoomChange);
|
||||
try {
|
||||
this._timeline = await this._room.openTimeline();
|
||||
this._timeline = this.track(this._room.openTimeline());
|
||||
await this._timeline.load();
|
||||
this._timelineVM = new TimelineViewModel(this.childOptions({
|
||||
room: this._room,
|
||||
timeline: this._timeline,
|
||||
@ -62,17 +63,15 @@ export class RoomViewModel extends ViewModel {
|
||||
}
|
||||
|
||||
dispose() {
|
||||
// this races with enable, on the await openTimeline()
|
||||
if (this._timeline) {
|
||||
// will stop the timeline from delivering updates on entries
|
||||
this._timeline.close();
|
||||
}
|
||||
super.dispose();
|
||||
if (this._clearUnreadTimout) {
|
||||
this._clearUnreadTimout.abort();
|
||||
this._clearUnreadTimout = null;
|
||||
}
|
||||
}
|
||||
|
||||
// called from view to close room
|
||||
// parent vm will dispose this vm
|
||||
close() {
|
||||
this._closeCallback();
|
||||
}
|
||||
@ -91,6 +90,10 @@ export class RoomViewModel extends ViewModel {
|
||||
return this._timelineVM;
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return this._room.isEncrypted;
|
||||
}
|
||||
|
||||
get error() {
|
||||
if (this._timelineError) {
|
||||
return `Something went wrong loading the timeline: ${this._timelineError.message}`;
|
||||
@ -148,6 +151,10 @@ class ComposerViewModel extends ViewModel {
|
||||
this._isEmpty = true;
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return this._roomVM.isEncrypted;
|
||||
}
|
||||
|
||||
sendMessage(message) {
|
||||
const success = this._roomVM._sendMessage(message);
|
||||
if (success) {
|
||||
|
@ -54,7 +54,7 @@ export class TimelineViewModel extends ViewModel {
|
||||
if (firstTile.shape === "gap") {
|
||||
return firstTile.fill();
|
||||
} else {
|
||||
await this._timeline.loadAtTop(50);
|
||||
await this._timeline.loadAtTop(10);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
@ -31,8 +31,12 @@ export class MessageTile extends SimpleTile {
|
||||
return "message";
|
||||
}
|
||||
|
||||
get displayName() {
|
||||
return this._entry.displayName || this.sender;
|
||||
}
|
||||
|
||||
get sender() {
|
||||
return this._entry.displayName || this._entry.sender;
|
||||
return this._entry.sender;
|
||||
}
|
||||
|
||||
// Avatar view model contract
|
||||
@ -52,7 +56,7 @@ export class MessageTile extends SimpleTile {
|
||||
}
|
||||
|
||||
get avatarTitle() {
|
||||
return this.sender;
|
||||
return this.displayName;
|
||||
}
|
||||
|
||||
get date() {
|
||||
@ -71,6 +75,10 @@ export class MessageTile extends SimpleTile {
|
||||
return this._isContinuation;
|
||||
}
|
||||
|
||||
get isUnverified() {
|
||||
return this._entry.isUnverified;
|
||||
}
|
||||
|
||||
_getContent() {
|
||||
return this._entry.content;
|
||||
}
|
||||
|
66
src/main.js
66
src/main.js
@ -25,12 +25,67 @@ import {BrawlViewModel} from "./domain/BrawlViewModel.js";
|
||||
import {BrawlView} from "./ui/web/BrawlView.js";
|
||||
import {Clock} from "./ui/web/dom/Clock.js";
|
||||
import {OnlineStatus} from "./ui/web/dom/OnlineStatus.js";
|
||||
import {WorkerPool} from "./utils/WorkerPool.js";
|
||||
import {OlmWorker} from "./matrix/e2ee/OlmWorker.js";
|
||||
|
||||
function addScript(src) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var s = document.createElement("script");
|
||||
s.setAttribute("src", src );
|
||||
s.onload=resolve;
|
||||
s.onerror=reject;
|
||||
document.body.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadOlm(olmPaths) {
|
||||
// make crypto.getRandomValues available without
|
||||
// a prefix on IE11, needed by olm to work
|
||||
if (window.msCrypto && !window.crypto) {
|
||||
window.crypto = window.msCrypto;
|
||||
}
|
||||
if (olmPaths) {
|
||||
if (window.WebAssembly) {
|
||||
await addScript(olmPaths.wasmBundle);
|
||||
await window.Olm.init({locateFile: () => olmPaths.wasm});
|
||||
} else {
|
||||
await addScript(olmPaths.legacyBundle);
|
||||
await window.Olm.init();
|
||||
}
|
||||
return window.Olm;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// make path relative to basePath,
|
||||
// assuming it and basePath are relative to document
|
||||
function relPath(path, basePath) {
|
||||
const idx = basePath.lastIndexOf("/");
|
||||
const dir = idx === -1 ? "" : basePath.slice(0, idx);
|
||||
const dirCount = dir.length ? dir.split("/").length : 0;
|
||||
return "../".repeat(dirCount) + path;
|
||||
}
|
||||
|
||||
async function loadOlmWorker(paths) {
|
||||
const workerPool = new WorkerPool(paths.worker, 4);
|
||||
await workerPool.init();
|
||||
const path = relPath(paths.olm.legacyBundle, paths.worker);
|
||||
await workerPool.sendAll({type: "load_olm", path});
|
||||
const olmWorker = new OlmWorker(workerPool);
|
||||
return olmWorker;
|
||||
}
|
||||
|
||||
// Don't use a default export here, as we use multiple entries during legacy build,
|
||||
// which does not support default exports,
|
||||
// see https://github.com/rollup/plugins/tree/master/packages/multi-entry
|
||||
export async function main(container) {
|
||||
export async function main(container, paths) {
|
||||
try {
|
||||
const isIE11 = !!window.MSInputMethodContext && !!document.documentMode;
|
||||
if (isIE11) {
|
||||
document.body.className += " ie11";
|
||||
} else {
|
||||
document.body.className += " not-ie11";
|
||||
}
|
||||
// to replay:
|
||||
// const fetchLog = await (await fetch("/fetchlogs/constrainterror.json")).json();
|
||||
// const replay = new ReplayRequester(fetchLog, {delay: false});
|
||||
@ -50,6 +105,13 @@ export async function main(container) {
|
||||
const sessionInfoStorage = new SessionInfoStorage("brawl_sessions_v1");
|
||||
const storageFactory = new StorageFactory();
|
||||
|
||||
// if wasm is not supported, we'll want
|
||||
// to run some olm operations in a worker (mainly for IE11)
|
||||
let workerPromise;
|
||||
if (!window.WebAssembly) {
|
||||
workerPromise = loadOlmWorker(paths);
|
||||
}
|
||||
|
||||
const vm = new BrawlViewModel({
|
||||
createSessionContainer: () => {
|
||||
return new SessionContainer({
|
||||
@ -59,6 +121,8 @@ export async function main(container) {
|
||||
sessionInfoStorage,
|
||||
request,
|
||||
clock,
|
||||
olmPromise: loadOlm(paths.olm),
|
||||
workerPromise,
|
||||
});
|
||||
},
|
||||
sessionInfoStorage,
|
||||
|
110
src/matrix/DeviceMessageHandler.js
Normal file
110
src/matrix/DeviceMessageHandler.js
Normal 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)) || [];
|
||||
}
|
||||
}
|
@ -121,7 +121,7 @@ export class SendScheduler {
|
||||
}
|
||||
this._sendRequests = [];
|
||||
}
|
||||
console.error("error for request", request);
|
||||
console.error("error for request", err);
|
||||
request.reject(err);
|
||||
break;
|
||||
}
|
||||
|
@ -18,10 +18,24 @@ import {Room} from "./room/Room.js";
|
||||
import { ObservableMap } from "../observable/index.js";
|
||||
import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js";
|
||||
import {User} from "./User.js";
|
||||
import {DeviceMessageHandler} from "./DeviceMessageHandler.js";
|
||||
import {Account as E2EEAccount} from "./e2ee/Account.js";
|
||||
import {Decryption as OlmDecryption} from "./e2ee/olm/Decryption.js";
|
||||
import {Encryption as OlmEncryption} from "./e2ee/olm/Encryption.js";
|
||||
import {Decryption as MegOlmDecryption} from "./e2ee/megolm/Decryption.js";
|
||||
import {Encryption as MegOlmEncryption} from "./e2ee/megolm/Encryption.js";
|
||||
import {MEGOLM_ALGORITHM} from "./e2ee/common.js";
|
||||
import {RoomEncryption} from "./e2ee/RoomEncryption.js";
|
||||
import {DeviceTracker} from "./e2ee/DeviceTracker.js";
|
||||
import {LockMap} from "../utils/LockMap.js";
|
||||
import {groupBy} from "../utils/groupBy.js";
|
||||
|
||||
const PICKLE_KEY = "DEFAULT_KEY";
|
||||
|
||||
export class Session {
|
||||
// sessionInfo contains deviceId, userId and homeServer
|
||||
constructor({storage, hsApi, sessionInfo}) {
|
||||
constructor({clock, storage, hsApi, sessionInfo, olm, olmWorker}) {
|
||||
this._clock = clock;
|
||||
this._storage = storage;
|
||||
this._hsApi = hsApi;
|
||||
this._syncInfo = null;
|
||||
@ -30,6 +44,118 @@ export class Session {
|
||||
this._sendScheduler = new SendScheduler({hsApi, backoff: new RateLimitingBackoff()});
|
||||
this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params);
|
||||
this._user = new User(sessionInfo.userId);
|
||||
this._deviceMessageHandler = new DeviceMessageHandler({storage});
|
||||
this._olm = olm;
|
||||
this._olmUtil = null;
|
||||
this._e2eeAccount = null;
|
||||
this._deviceTracker = null;
|
||||
this._olmEncryption = null;
|
||||
this._megolmEncryption = null;
|
||||
this._megolmDecryption = null;
|
||||
this._getSyncToken = () => this.syncToken;
|
||||
this._olmWorker = olmWorker;
|
||||
|
||||
if (olm) {
|
||||
this._olmUtil = new olm.Utility();
|
||||
this._deviceTracker = new DeviceTracker({
|
||||
storage,
|
||||
getSyncToken: this._getSyncToken,
|
||||
olmUtil: this._olmUtil,
|
||||
ownUserId: sessionInfo.userId,
|
||||
ownDeviceId: sessionInfo.deviceId,
|
||||
});
|
||||
}
|
||||
this._createRoomEncryption = this._createRoomEncryption.bind(this);
|
||||
}
|
||||
|
||||
// called once this._e2eeAccount is assigned
|
||||
_setupEncryption() {
|
||||
console.log("loaded e2ee account with keys", this._e2eeAccount.identityKeys);
|
||||
const senderKeyLock = new LockMap();
|
||||
const olmDecryption = new OlmDecryption({
|
||||
account: this._e2eeAccount,
|
||||
pickleKey: PICKLE_KEY,
|
||||
olm: this._olm,
|
||||
storage: this._storage,
|
||||
now: this._clock.now,
|
||||
ownUserId: this._user.id,
|
||||
senderKeyLock
|
||||
});
|
||||
this._olmEncryption = new OlmEncryption({
|
||||
account: this._e2eeAccount,
|
||||
pickleKey: PICKLE_KEY,
|
||||
olm: this._olm,
|
||||
storage: this._storage,
|
||||
now: this._clock.now,
|
||||
ownUserId: this._user.id,
|
||||
olmUtil: this._olmUtil,
|
||||
senderKeyLock
|
||||
});
|
||||
this._megolmEncryption = new MegOlmEncryption({
|
||||
account: this._e2eeAccount,
|
||||
pickleKey: PICKLE_KEY,
|
||||
olm: this._olm,
|
||||
storage: this._storage,
|
||||
now: this._clock.now,
|
||||
ownDeviceId: this._sessionInfo.deviceId,
|
||||
});
|
||||
this._megolmDecryption = new MegOlmDecryption({
|
||||
pickleKey: PICKLE_KEY,
|
||||
olm: this._olm,
|
||||
olmWorker: this._olmWorker,
|
||||
});
|
||||
this._deviceMessageHandler.enableEncryption({olmDecryption, megolmDecryption: this._megolmDecryption});
|
||||
}
|
||||
|
||||
_createRoomEncryption(room, encryptionParams) {
|
||||
// TODO: this will actually happen when users start using the e2ee version for the first time
|
||||
|
||||
// this should never happen because either a session was already synced once
|
||||
// and thus an e2ee account was created as well and _setupEncryption is called from load
|
||||
// OR
|
||||
// this is a new session and loading it will load zero rooms, thus not calling this method.
|
||||
// in this case _setupEncryption is called from beforeFirstSync, right after load,
|
||||
// so any incoming synced rooms won't be there yet
|
||||
if (!this._olmEncryption) {
|
||||
throw new Error("creating room encryption before encryption got globally enabled");
|
||||
}
|
||||
// only support megolm
|
||||
if (encryptionParams.algorithm !== MEGOLM_ALGORITHM) {
|
||||
return null;
|
||||
}
|
||||
return new RoomEncryption({
|
||||
room,
|
||||
deviceTracker: this._deviceTracker,
|
||||
olmEncryption: this._olmEncryption,
|
||||
megolmEncryption: this._megolmEncryption,
|
||||
megolmDecryption: this._megolmDecryption,
|
||||
storage: this._storage,
|
||||
encryptionParams
|
||||
});
|
||||
}
|
||||
|
||||
// called after load
|
||||
async beforeFirstSync(isNewLogin) {
|
||||
if (this._olm) {
|
||||
if (isNewLogin && this._e2eeAccount) {
|
||||
throw new Error("there should not be an e2ee account already on a fresh login");
|
||||
}
|
||||
if (!this._e2eeAccount) {
|
||||
this._e2eeAccount = await E2EEAccount.create({
|
||||
hsApi: this._hsApi,
|
||||
olm: this._olm,
|
||||
pickleKey: PICKLE_KEY,
|
||||
userId: this._sessionInfo.userId,
|
||||
deviceId: this._sessionInfo.deviceId,
|
||||
olmWorker: this._olmWorker,
|
||||
storage: this._storage,
|
||||
});
|
||||
this._setupEncryption();
|
||||
}
|
||||
await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
|
||||
await this._e2eeAccount.uploadKeys(this._storage);
|
||||
await this._deviceMessageHandler.decryptPending(this.rooms);
|
||||
}
|
||||
}
|
||||
|
||||
async load() {
|
||||
@ -43,6 +169,21 @@ export class Session {
|
||||
]);
|
||||
// restore session object
|
||||
this._syncInfo = await txn.session.get("sync");
|
||||
// restore e2ee account, if any
|
||||
if (this._olm) {
|
||||
this._e2eeAccount = await E2EEAccount.load({
|
||||
hsApi: this._hsApi,
|
||||
olm: this._olm,
|
||||
pickleKey: PICKLE_KEY,
|
||||
userId: this._sessionInfo.userId,
|
||||
deviceId: this._sessionInfo.deviceId,
|
||||
olmWorker: this._olmWorker,
|
||||
txn
|
||||
});
|
||||
if (this._e2eeAccount) {
|
||||
this._setupEncryption();
|
||||
}
|
||||
}
|
||||
const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn);
|
||||
// load rooms
|
||||
const rooms = await txn.roomSummary.getAll();
|
||||
@ -57,6 +198,7 @@ export class Session {
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._olmWorker?.dispose();
|
||||
this._sendScheduler.stop();
|
||||
}
|
||||
|
||||
@ -71,9 +213,20 @@ export class Session {
|
||||
await txn.complete();
|
||||
}
|
||||
|
||||
const opsTxn = await this._storage.readWriteTxn([
|
||||
this._storage.storeNames.operations
|
||||
]);
|
||||
const operations = await opsTxn.operations.getAll();
|
||||
const operationsByScope = groupBy(operations, o => o.scope);
|
||||
|
||||
this._sendScheduler.start();
|
||||
for (const [, room] of this._rooms) {
|
||||
room.resumeSending();
|
||||
let roomOperationsByType;
|
||||
const roomOperations = operationsByScope.get(room.id);
|
||||
if (roomOperations) {
|
||||
roomOperationsByType = groupBy(roomOperations, r => r.type);
|
||||
}
|
||||
room.start(roomOperationsByType);
|
||||
}
|
||||
}
|
||||
|
||||
@ -97,31 +250,68 @@ export class Session {
|
||||
createRoom(roomId, pendingEvents) {
|
||||
const room = new Room({
|
||||
roomId,
|
||||
getSyncToken: this._getSyncToken,
|
||||
storage: this._storage,
|
||||
emitCollectionChange: this._roomUpdateCallback,
|
||||
hsApi: this._hsApi,
|
||||
sendScheduler: this._sendScheduler,
|
||||
pendingEvents,
|
||||
user: this._user,
|
||||
createRoomEncryption: this._createRoomEncryption,
|
||||
clock: this._clock
|
||||
});
|
||||
this._rooms.add(roomId, room);
|
||||
return room;
|
||||
}
|
||||
|
||||
writeSync(syncToken, syncFilterId, accountData, txn) {
|
||||
async writeSync(syncResponse, syncFilterId, txn) {
|
||||
const changes = {};
|
||||
const syncToken = syncResponse.next_batch;
|
||||
const deviceOneTimeKeysCount = syncResponse.device_one_time_keys_count;
|
||||
|
||||
if (this._e2eeAccount && deviceOneTimeKeysCount) {
|
||||
changes.e2eeAccountChanges = this._e2eeAccount.writeSync(deviceOneTimeKeysCount, txn);
|
||||
}
|
||||
if (syncToken !== this.syncToken) {
|
||||
const syncInfo = {token: syncToken, filterId: syncFilterId};
|
||||
// don't modify `this` because transaction might still fail
|
||||
txn.session.set("sync", syncInfo);
|
||||
return syncInfo;
|
||||
changes.syncInfo = syncInfo;
|
||||
}
|
||||
if (this._deviceTracker) {
|
||||
const deviceLists = syncResponse.device_lists;
|
||||
if (deviceLists) {
|
||||
await this._deviceTracker.writeDeviceChanges(deviceLists, txn);
|
||||
}
|
||||
}
|
||||
|
||||
afterSync(syncInfo) {
|
||||
const toDeviceEvents = syncResponse.to_device?.events;
|
||||
if (Array.isArray(toDeviceEvents)) {
|
||||
this._deviceMessageHandler.writeSync(toDeviceEvents, txn);
|
||||
}
|
||||
return changes;
|
||||
}
|
||||
|
||||
afterSync({syncInfo, e2eeAccountChanges}) {
|
||||
if (syncInfo) {
|
||||
// sync transaction succeeded, modify object state now
|
||||
this._syncInfo = syncInfo;
|
||||
}
|
||||
if (this._e2eeAccount && e2eeAccountChanges) {
|
||||
this._e2eeAccount.afterSync(e2eeAccountChanges);
|
||||
}
|
||||
}
|
||||
|
||||
async afterSyncCompleted() {
|
||||
const needsToUploadOTKs = await this._e2eeAccount.generateOTKsIfNeeded(this._storage);
|
||||
const promises = [this._deviceMessageHandler.decryptPending(this.rooms)];
|
||||
if (needsToUploadOTKs) {
|
||||
// TODO: we could do this in parallel with sync if it proves to be too slow
|
||||
// but I'm not sure how to not swallow errors in that case
|
||||
promises.push(this._e2eeAccount.uploadKeys(this._storage));
|
||||
}
|
||||
// run key upload and decryption in parallel
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
get syncToken() {
|
||||
@ -181,7 +371,7 @@ export function tests() {
|
||||
}
|
||||
}
|
||||
};
|
||||
const newSessionData = session.writeSync("b", 6, {}, syncTxn);
|
||||
const newSessionData = await session.writeSync({next_batch: "b"}, 6, syncTxn);
|
||||
assert(syncSet);
|
||||
assert.equal(session.syncToken, "a");
|
||||
assert.equal(session.syncFilterId, 5);
|
||||
|
@ -28,6 +28,7 @@ export const LoadStatus = createEnum(
|
||||
"Login",
|
||||
"LoginFailed",
|
||||
"Loading",
|
||||
"SessionSetup", // upload e2ee keys, ...
|
||||
"Migrating", //not used atm, but would fit here
|
||||
"FirstSync",
|
||||
"Error",
|
||||
@ -41,7 +42,7 @@ export const LoginFailure = createEnum(
|
||||
);
|
||||
|
||||
export class SessionContainer {
|
||||
constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage}) {
|
||||
constructor({clock, random, onlineStatus, request, storageFactory, sessionInfoStorage, olmPromise, workerPromise}) {
|
||||
this._random = random;
|
||||
this._clock = clock;
|
||||
this._onlineStatus = onlineStatus;
|
||||
@ -57,6 +58,8 @@ export class SessionContainer {
|
||||
this._sync = null;
|
||||
this._sessionId = null;
|
||||
this._storage = null;
|
||||
this._olmPromise = olmPromise;
|
||||
this._workerPromise = workerPromise;
|
||||
}
|
||||
|
||||
createNewSessionId() {
|
||||
@ -73,7 +76,7 @@ export class SessionContainer {
|
||||
if (!sessionInfo) {
|
||||
throw new Error("Invalid session id: " + sessionId);
|
||||
}
|
||||
await this._loadSessionInfo(sessionInfo);
|
||||
await this._loadSessionInfo(sessionInfo, false);
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
this._status.set(LoadStatus.Error);
|
||||
@ -88,7 +91,7 @@ export class SessionContainer {
|
||||
let sessionInfo;
|
||||
try {
|
||||
const hsApi = new HomeServerApi({homeServer, request: this._request, createTimeout: this._clock.createTimeout});
|
||||
const loginData = await hsApi.passwordLogin(username, password).response();
|
||||
const loginData = await hsApi.passwordLogin(username, password, "Hydrogen").response();
|
||||
const sessionId = this.createNewSessionId();
|
||||
sessionInfo = {
|
||||
id: sessionId,
|
||||
@ -120,14 +123,14 @@ export class SessionContainer {
|
||||
// LoadStatus.Error in case of an error,
|
||||
// so separate try/catch
|
||||
try {
|
||||
await this._loadSessionInfo(sessionInfo);
|
||||
await this._loadSessionInfo(sessionInfo, true);
|
||||
} catch (err) {
|
||||
this._error = err;
|
||||
this._status.set(LoadStatus.Error);
|
||||
}
|
||||
}
|
||||
|
||||
async _loadSessionInfo(sessionInfo) {
|
||||
async _loadSessionInfo(sessionInfo, isNewLogin) {
|
||||
this._status.set(LoadStatus.Loading);
|
||||
this._reconnector = new Reconnector({
|
||||
onlineStatus: this._onlineStatus,
|
||||
@ -149,8 +152,17 @@ export class SessionContainer {
|
||||
userId: sessionInfo.userId,
|
||||
homeServer: sessionInfo.homeServer,
|
||||
};
|
||||
this._session = new Session({storage: this._storage, sessionInfo: filteredSessionInfo, hsApi});
|
||||
const olm = await this._olmPromise;
|
||||
let olmWorker = null;
|
||||
if (this._workerPromise) {
|
||||
olmWorker = await this._workerPromise;
|
||||
}
|
||||
this._session = new Session({storage: this._storage,
|
||||
sessionInfo: filteredSessionInfo, hsApi, olm,
|
||||
clock: this._clock, olmWorker});
|
||||
await this._session.load();
|
||||
this._status.set(LoadStatus.SessionSetup);
|
||||
await this._session.beforeFirstSync(isNewLogin);
|
||||
|
||||
this._sync = new Sync({hsApi, storage: this._storage, session: this._session});
|
||||
// notify sync and session when back online
|
||||
@ -234,10 +246,16 @@ export class SessionContainer {
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._reconnectSubscription) {
|
||||
this._reconnectSubscription();
|
||||
this._reconnectSubscription = null;
|
||||
}
|
||||
if (this._sync) {
|
||||
this._sync.stop();
|
||||
}
|
||||
if (this._session) {
|
||||
this._session.stop();
|
||||
}
|
||||
if (this._waitForFirstSyncHandle) {
|
||||
this._waitForFirstSyncHandle.dispose();
|
||||
this._waitForFirstSyncHandle = null;
|
||||
|
@ -29,21 +29,6 @@ export const SyncStatus = createEnum(
|
||||
"Stopped"
|
||||
);
|
||||
|
||||
function parseRooms(roomsSection, roomCallback) {
|
||||
if (roomsSection) {
|
||||
const allMemberships = ["join", "invite", "leave"];
|
||||
for(const membership of allMemberships) {
|
||||
const membershipSection = roomsSection[membership];
|
||||
if (membershipSection) {
|
||||
return Object.entries(membershipSection).map(([roomId, roomResponse]) => {
|
||||
return roomCallback(roomId, roomResponse, membership);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function timelineIsEmpty(roomResponse) {
|
||||
try {
|
||||
const events = roomResponse?.timeline?.events;
|
||||
@ -53,6 +38,26 @@ function timelineIsEmpty(roomResponse) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync steps in js-pseudocode:
|
||||
* ```js
|
||||
* let preparation;
|
||||
* if (room.needsPrepareSync) {
|
||||
* // can only read some stores
|
||||
* preparation = await room.prepareSync(roomResponse, prepareTxn);
|
||||
* // can do async work that is not related to storage (such as decryption)
|
||||
* preparation = await room.afterPrepareSync(preparation);
|
||||
* }
|
||||
* // writes and calculates changes
|
||||
* const changes = await room.writeSync(roomResponse, membership, isInitialSync, preparation, syncTxn);
|
||||
* // applies and emits changes once syncTxn is committed
|
||||
* room.afterSync(changes);
|
||||
* if (room.needsAfterSyncCompleted(changes)) {
|
||||
* // can do network requests
|
||||
* await room.afterSyncCompleted(changes);
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export class Sync {
|
||||
constructor({hsApi, session, storage}) {
|
||||
this._hsApi = hsApi;
|
||||
@ -87,12 +92,16 @@ export class Sync {
|
||||
}
|
||||
|
||||
async _syncLoop(syncToken) {
|
||||
let afterSyncCompletedPromise = Promise.resolve();
|
||||
// if syncToken is falsy, it will first do an initial sync ...
|
||||
while(this._status.get() !== SyncStatus.Stopped) {
|
||||
let roomStates;
|
||||
try {
|
||||
console.log(`starting sync request with since ${syncToken} ...`);
|
||||
const timeout = syncToken ? INCREMENTAL_TIMEOUT : undefined;
|
||||
syncToken = await this._syncRequest(syncToken, timeout);
|
||||
const syncResult = await this._syncRequest(syncToken, timeout, afterSyncCompletedPromise);
|
||||
syncToken = syncResult.syncToken;
|
||||
roomStates = syncResult.roomStates;
|
||||
this._status.set(SyncStatus.Syncing);
|
||||
} catch (err) {
|
||||
if (!(err instanceof AbortError)) {
|
||||
@ -100,10 +109,39 @@ export class Sync {
|
||||
this._status.set(SyncStatus.Stopped);
|
||||
}
|
||||
}
|
||||
if (!this._error) {
|
||||
afterSyncCompletedPromise = this._runAfterSyncCompleted(roomStates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _syncRequest(syncToken, timeout) {
|
||||
async _runAfterSyncCompleted(roomStates) {
|
||||
const sessionPromise = (async () => {
|
||||
try {
|
||||
await this._session.afterSyncCompleted();
|
||||
} catch (err) {
|
||||
console.error("error during session afterSyncCompleted, continuing", err.stack);
|
||||
}
|
||||
})();
|
||||
|
||||
const roomsNeedingAfterSyncCompleted = roomStates.filter(rs => {
|
||||
return rs.room.needsAfterSyncCompleted(rs.changes);
|
||||
});
|
||||
const roomsPromises = roomsNeedingAfterSyncCompleted.map(async rs => {
|
||||
try {
|
||||
await rs.room.afterSyncCompleted(rs.changes);
|
||||
} catch (err) {
|
||||
console.error(`error during room ${rs.room.id} afterSyncCompleted, continuing`, err.stack);
|
||||
}
|
||||
});
|
||||
// run everything in parallel,
|
||||
// we don't want to delay the next sync too much
|
||||
// Also, since all promises won't reject (as they have a try/catch)
|
||||
// it's fine to use Promise.all
|
||||
await Promise.all(roomsPromises.concat(sessionPromise));
|
||||
}
|
||||
|
||||
async _syncRequest(syncToken, timeout, prevAfterSyncCompletedPromise) {
|
||||
let {syncFilterId} = this._session;
|
||||
if (typeof syncFilterId !== "string") {
|
||||
this._currentRequest = this._hsApi.createFilter(this._session.user.id, {room: {state: {lazy_load_members: true}}});
|
||||
@ -112,41 +150,23 @@ export class Sync {
|
||||
const totalRequestTimeout = timeout + (80 * 1000); // same as riot-web, don't get stuck on wedged long requests
|
||||
this._currentRequest = this._hsApi.sync(syncToken, syncFilterId, timeout, {timeout: totalRequestTimeout});
|
||||
const response = await this._currentRequest.response();
|
||||
// wait here for the afterSyncCompleted step of the previous sync to complete
|
||||
// before we continue processing this sync response
|
||||
await prevAfterSyncCompletedPromise;
|
||||
|
||||
const isInitialSync = !syncToken;
|
||||
syncToken = response.next_batch;
|
||||
const storeNames = this._storage.storeNames;
|
||||
const syncTxn = await this._storage.readWriteTxn([
|
||||
storeNames.session,
|
||||
storeNames.roomSummary,
|
||||
storeNames.roomState,
|
||||
storeNames.roomMembers,
|
||||
storeNames.timelineEvents,
|
||||
storeNames.timelineFragments,
|
||||
storeNames.pendingEvents,
|
||||
]);
|
||||
const roomChanges = [];
|
||||
const roomStates = this._parseRoomsResponse(response.rooms, isInitialSync);
|
||||
await this._prepareRooms(roomStates);
|
||||
let sessionChanges;
|
||||
const syncTxn = await this._openSyncTxn();
|
||||
try {
|
||||
sessionChanges = this._session.writeSync(syncToken, syncFilterId, response.account_data, syncTxn);
|
||||
// to_device
|
||||
// presence
|
||||
if (response.rooms) {
|
||||
const promises = parseRooms(response.rooms, async (roomId, roomResponse, membership) => {
|
||||
// ignore rooms with empty timelines during initial sync,
|
||||
// see https://github.com/vector-im/hydrogen-web/issues/15
|
||||
if (isInitialSync && timelineIsEmpty(roomResponse)) {
|
||||
return;
|
||||
}
|
||||
let room = this._session.rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = this._session.createRoom(roomId);
|
||||
}
|
||||
console.log(` * applying sync response to room ${roomId} ...`);
|
||||
const changes = await room.writeSync(roomResponse, membership, isInitialSync, syncTxn);
|
||||
roomChanges.push({room, changes});
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
await Promise.all(roomStates.map(async rs => {
|
||||
console.log(` * applying sync response to room ${rs.room.id} ...`);
|
||||
rs.changes = await rs.room.writeSync(
|
||||
rs.roomResponse, rs.membership, isInitialSync, rs.preparation, syncTxn);
|
||||
}));
|
||||
sessionChanges = await this._session.writeSync(response, syncFilterId, syncTxn);
|
||||
} catch(err) {
|
||||
console.warn("aborting syncTxn because of error");
|
||||
console.error(err);
|
||||
@ -165,13 +185,80 @@ export class Sync {
|
||||
}
|
||||
this._session.afterSync(sessionChanges);
|
||||
// emit room related events after txn has been closed
|
||||
for(let {room, changes} of roomChanges) {
|
||||
room.afterSync(changes);
|
||||
for(let rs of roomStates) {
|
||||
rs.room.afterSync(rs.changes);
|
||||
}
|
||||
|
||||
return syncToken;
|
||||
return {syncToken, roomStates};
|
||||
}
|
||||
|
||||
async _openPrepareSyncTxn() {
|
||||
const storeNames = this._storage.storeNames;
|
||||
return await this._storage.readTxn([
|
||||
storeNames.inboundGroupSessions,
|
||||
]);
|
||||
}
|
||||
|
||||
async _prepareRooms(roomStates) {
|
||||
const prepareRoomStates = roomStates.filter(rs => rs.room.needsPrepareSync);
|
||||
if (prepareRoomStates.length) {
|
||||
const prepareTxn = await this._openPrepareSyncTxn();
|
||||
await Promise.all(prepareRoomStates.map(async rs => {
|
||||
rs.preparation = await rs.room.prepareSync(rs.roomResponse, prepareTxn);
|
||||
}));
|
||||
await Promise.all(prepareRoomStates.map(async rs => {
|
||||
rs.preparation = await rs.room.afterPrepareSync(rs.preparation);
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async _openSyncTxn() {
|
||||
const storeNames = this._storage.storeNames;
|
||||
return await this._storage.readWriteTxn([
|
||||
storeNames.session,
|
||||
storeNames.roomSummary,
|
||||
storeNames.roomState,
|
||||
storeNames.roomMembers,
|
||||
storeNames.timelineEvents,
|
||||
storeNames.timelineFragments,
|
||||
storeNames.pendingEvents,
|
||||
storeNames.userIdentities,
|
||||
storeNames.groupSessionDecryptions,
|
||||
storeNames.deviceIdentities,
|
||||
// to discard outbound session when somebody leaves a room
|
||||
// and to create room key messages when somebody leaves
|
||||
storeNames.outboundGroupSessions,
|
||||
storeNames.operations
|
||||
]);
|
||||
}
|
||||
|
||||
_parseRoomsResponse(roomsSection, isInitialSync) {
|
||||
const roomStates = [];
|
||||
if (roomsSection) {
|
||||
// don't do "invite", "leave" for now
|
||||
const allMemberships = ["join"];
|
||||
for(const membership of allMemberships) {
|
||||
const membershipSection = roomsSection[membership];
|
||||
if (membershipSection) {
|
||||
for (const [roomId, roomResponse] of Object.entries(membershipSection)) {
|
||||
// ignore rooms with empty timelines during initial sync,
|
||||
// see https://github.com/vector-im/hydrogen-web/issues/15
|
||||
if (isInitialSync && timelineIsEmpty(roomResponse)) {
|
||||
return;
|
||||
}
|
||||
let room = this._session.rooms.get(roomId);
|
||||
if (!room) {
|
||||
room = this._session.createRoom(roomId);
|
||||
}
|
||||
roomStates.push(new RoomSyncProcessState(room, roomResponse, membership));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return roomStates;
|
||||
}
|
||||
|
||||
|
||||
stop() {
|
||||
if (this._status.get() === SyncStatus.Stopped) {
|
||||
return;
|
||||
@ -183,3 +270,13 @@ export class Sync {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RoomSyncProcessState {
|
||||
constructor(room, roomResponse, membership) {
|
||||
this.room = room;
|
||||
this.roomResponse = roomResponse;
|
||||
this.membership = membership;
|
||||
this.preparation = null;
|
||||
this.changes = null;
|
||||
}
|
||||
}
|
||||
|
22
src/matrix/common.js
Normal file
22
src/matrix/common.js
Normal 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
242
src/matrix/e2ee/Account.js
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
70
src/matrix/e2ee/DecryptionResult.js
Normal file
70
src/matrix/e2ee/DecryptionResult.js
Normal 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;
|
||||
}
|
||||
}
|
301
src/matrix/e2ee/DeviceTracker.js
Normal file
301
src/matrix/e2ee/DeviceTracker.js
Normal 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);
|
||||
}
|
||||
}
|
43
src/matrix/e2ee/OlmWorker.js
Normal file
43
src/matrix/e2ee/OlmWorker.js
Normal 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
44
src/matrix/e2ee/README.md
Normal 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
|
328
src/matrix/e2ee/RoomEncryption.js
Normal file
328
src/matrix/e2ee/RoomEncryption.js
Normal 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
55
src/matrix/e2ee/common.js
Normal 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;
|
||||
}
|
||||
}
|
166
src/matrix/e2ee/megolm/Decryption.js
Normal file
166
src/matrix/e2ee/megolm/Decryption.js
Normal 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;
|
||||
}
|
||||
}
|
||||
|
183
src/matrix/e2ee/megolm/Encryption.js
Normal file
183
src/matrix/e2ee/megolm/Encryption.js
Normal 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;
|
||||
}
|
||||
}
|
75
src/matrix/e2ee/megolm/decryption/DecryptionChanges.js
Normal file
75
src/matrix/e2ee/megolm/decryption/DecryptionChanges.js
Normal 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
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
52
src/matrix/e2ee/megolm/decryption/DecryptionPreparation.js
Normal file
52
src/matrix/e2ee/megolm/decryption/DecryptionPreparation.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
6
src/matrix/e2ee/megolm/decryption/README.md
Normal file
6
src/matrix/e2ee/megolm/decryption/README.md
Normal 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
|
24
src/matrix/e2ee/megolm/decryption/ReplayDetectionEntry.js
Normal file
24
src/matrix/e2ee/megolm/decryption/ReplayDetectionEntry.js
Normal 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;
|
||||
}
|
||||
}
|
68
src/matrix/e2ee/megolm/decryption/SessionCache.js
Normal file
68
src/matrix/e2ee/megolm/decryption/SessionCache.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
90
src/matrix/e2ee/megolm/decryption/SessionDecryption.js
Normal file
90
src/matrix/e2ee/megolm/decryption/SessionDecryption.js
Normal 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();
|
||||
}
|
||||
}
|
44
src/matrix/e2ee/megolm/decryption/SessionInfo.js
Normal file
44
src/matrix/e2ee/megolm/decryption/SessionInfo.js
Normal 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();
|
||||
}
|
||||
}
|
307
src/matrix/e2ee/olm/Decryption.js
Normal file
307
src/matrix/e2ee/olm/Decryption.js
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
290
src/matrix/e2ee/olm/Encryption.js
Normal file
290
src/matrix/e2ee/olm/Encryption.js
Normal 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;
|
||||
}
|
||||
}
|
58
src/matrix/e2ee/olm/Session.js
Normal file
58
src/matrix/e2ee/olm/Session.js
Normal 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;
|
||||
}
|
||||
}
|
@ -141,14 +141,15 @@ export class HomeServerApi {
|
||||
{}, {}, options);
|
||||
}
|
||||
|
||||
passwordLogin(username, password, options = null) {
|
||||
passwordLogin(username, password, initialDeviceDisplayName, options = null) {
|
||||
return this._post("/login", null, {
|
||||
"type": "m.login.password",
|
||||
"identifier": {
|
||||
"type": "m.id.user",
|
||||
"user": username
|
||||
},
|
||||
"password": password
|
||||
"password": password,
|
||||
"initial_device_display_name": initialDeviceDisplayName
|
||||
}, options);
|
||||
}
|
||||
|
||||
@ -160,6 +161,22 @@ export class HomeServerApi {
|
||||
return this._request("GET", `${this._homeserver}/_matrix/client/versions`, null, null, options);
|
||||
}
|
||||
|
||||
uploadKeys(payload, options = null) {
|
||||
return this._post("/keys/upload", null, payload, options);
|
||||
}
|
||||
|
||||
queryKeys(queryRequest, options = null) {
|
||||
return this._post("/keys/query", null, queryRequest, options);
|
||||
}
|
||||
|
||||
claimKeys(payload, options = null) {
|
||||
return this._post("/keys/claim", null, payload, options);
|
||||
}
|
||||
|
||||
sendToDevice(type, payload, txnId, options = null) {
|
||||
return this._put(`/sendToDevice/${encodeURIComponent(type)}/${encodeURIComponent(txnId)}`, null, payload, options);
|
||||
}
|
||||
|
||||
get mediaRepository() {
|
||||
return this._mediaRepository;
|
||||
}
|
||||
|
@ -25,9 +25,13 @@ import {WrappedError} from "../error.js"
|
||||
import {fetchOrLoadMembers} from "./members/load.js";
|
||||
import {MemberList} from "./members/MemberList.js";
|
||||
import {Heroes} from "./members/Heroes.js";
|
||||
import {EventEntry} from "./timeline/entries/EventEntry.js";
|
||||
import {DecryptionSource} from "../e2ee/common.js";
|
||||
|
||||
const EVENT_ENCRYPTED_TYPE = "m.room.encrypted";
|
||||
|
||||
export class Room extends EventEmitter {
|
||||
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user}) {
|
||||
constructor({roomId, storage, hsApi, emitCollectionChange, sendScheduler, pendingEvents, user, createRoomEncryption, getSyncToken, clock}) {
|
||||
super();
|
||||
this._roomId = roomId;
|
||||
this._storage = storage;
|
||||
@ -40,17 +44,133 @@ export class Room extends EventEmitter {
|
||||
this._timeline = null;
|
||||
this._user = user;
|
||||
this._changedMembersDuringSync = null;
|
||||
this._memberList = null;
|
||||
this._createRoomEncryption = createRoomEncryption;
|
||||
this._roomEncryption = null;
|
||||
this._getSyncToken = getSyncToken;
|
||||
this._clock = clock;
|
||||
}
|
||||
|
||||
async notifyRoomKeys(roomKeys) {
|
||||
if (this._roomEncryption) {
|
||||
let retryEventIds = this._roomEncryption.applyRoomKeys(roomKeys);
|
||||
if (retryEventIds.length) {
|
||||
const retryEntries = [];
|
||||
const txn = await this._storage.readTxn([
|
||||
this._storage.storeNames.timelineEvents,
|
||||
this._storage.storeNames.inboundGroupSessions,
|
||||
]);
|
||||
for (const eventId of retryEventIds) {
|
||||
const storageEntry = await txn.timelineEvents.getByEventId(this._roomId, eventId);
|
||||
if (storageEntry) {
|
||||
retryEntries.push(new EventEntry(storageEntry, this._fragmentIdComparer));
|
||||
}
|
||||
}
|
||||
const decryptRequest = this._decryptEntries(DecryptionSource.Retry, retryEntries, txn);
|
||||
await decryptRequest.complete();
|
||||
if (this._timeline) {
|
||||
// only adds if already present
|
||||
this._timeline.replaceEntries(retryEntries);
|
||||
}
|
||||
// pass decryptedEntries to roomSummary
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_enableEncryption(encryptionParams) {
|
||||
this._roomEncryption = this._createRoomEncryption(this, encryptionParams);
|
||||
if (this._roomEncryption) {
|
||||
this._sendQueue.enableEncryption(this._roomEncryption);
|
||||
if (this._timeline) {
|
||||
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Used for decrypting when loading/filling the timeline, and retrying decryption,
|
||||
* not during sync, where it is split up during the multiple phases.
|
||||
*/
|
||||
_decryptEntries(source, entries, inboundSessionTxn = null) {
|
||||
const request = new DecryptionRequest(async r => {
|
||||
if (!inboundSessionTxn) {
|
||||
inboundSessionTxn = await this._storage.readTxn([this._storage.storeNames.inboundGroupSessions]);
|
||||
}
|
||||
if (r.cancelled) return;
|
||||
const events = entries.filter(entry => {
|
||||
return entry.eventType === EVENT_ENCRYPTED_TYPE;
|
||||
}).map(entry => entry.event);
|
||||
const isTimelineOpen = this._isTimelineOpen;
|
||||
r.preparation = await this._roomEncryption.prepareDecryptAll(events, source, isTimelineOpen, inboundSessionTxn);
|
||||
if (r.cancelled) return;
|
||||
const changes = await r.preparation.decrypt();
|
||||
r.preparation = null;
|
||||
if (r.cancelled) return;
|
||||
const stores = [this._storage.storeNames.groupSessionDecryptions];
|
||||
if (isTimelineOpen) {
|
||||
// read to fetch devices if timeline is open
|
||||
stores.push(this._storage.storeNames.deviceIdentities);
|
||||
}
|
||||
const writeTxn = await this._storage.readWriteTxn(stores);
|
||||
let decryption;
|
||||
try {
|
||||
decryption = await changes.write(writeTxn);
|
||||
} catch (err) {
|
||||
writeTxn.abort();
|
||||
throw err;
|
||||
}
|
||||
await writeTxn.complete();
|
||||
decryption.applyToEntries(entries);
|
||||
});
|
||||
return request;
|
||||
}
|
||||
|
||||
get needsPrepareSync() {
|
||||
// only encrypted rooms need the prepare sync steps
|
||||
return !!this._roomEncryption;
|
||||
}
|
||||
|
||||
async prepareSync(roomResponse, txn) {
|
||||
if (this._roomEncryption) {
|
||||
const events = roomResponse?.timeline?.events;
|
||||
if (Array.isArray(events)) {
|
||||
const eventsToDecrypt = events.filter(event => {
|
||||
return event?.type === EVENT_ENCRYPTED_TYPE;
|
||||
});
|
||||
const preparation = await this._roomEncryption.prepareDecryptAll(
|
||||
eventsToDecrypt, DecryptionSource.Sync, this._isTimelineOpen, txn);
|
||||
return preparation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async afterPrepareSync(preparation) {
|
||||
if (preparation) {
|
||||
const decryptChanges = await preparation.decrypt();
|
||||
return decryptChanges;
|
||||
}
|
||||
}
|
||||
|
||||
/** @package */
|
||||
async writeSync(roomResponse, membership, isInitialSync, txn) {
|
||||
const isTimelineOpen = !!this._timeline;
|
||||
async writeSync(roomResponse, membership, isInitialSync, decryptChanges, txn) {
|
||||
let decryption;
|
||||
if (this._roomEncryption && decryptChanges) {
|
||||
decryption = await decryptChanges.write(txn);
|
||||
}
|
||||
const {entries, newLiveKey, memberChanges} =
|
||||
await this._syncWriter.writeSync(roomResponse, txn);
|
||||
if (decryption) {
|
||||
decryption.applyToEntries(entries);
|
||||
}
|
||||
// pass member changes to device tracker
|
||||
if (this._roomEncryption && this.isTrackingMembers && memberChanges?.size) {
|
||||
await this._roomEncryption.writeMemberChanges(memberChanges, txn);
|
||||
}
|
||||
const summaryChanges = this._summary.writeSync(
|
||||
roomResponse,
|
||||
membership,
|
||||
isInitialSync, isTimelineOpen,
|
||||
isInitialSync, this._isTimelineOpen,
|
||||
txn);
|
||||
const {entries, newLiveKey, changedMembers} = await this._syncWriter.writeSync(roomResponse, txn);
|
||||
// fetch new members while we have txn open,
|
||||
// but don't make any in-memory changes yet
|
||||
let heroChanges;
|
||||
@ -59,7 +179,7 @@ export class Room extends EventEmitter {
|
||||
if (!this._heroes) {
|
||||
this._heroes = new Heroes(this._roomId);
|
||||
}
|
||||
heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, changedMembers, txn);
|
||||
heroChanges = await this._heroes.calculateChanges(summaryChanges.heroes, memberChanges, txn);
|
||||
}
|
||||
let removedPendingEvents;
|
||||
if (roomResponse.timeline && roomResponse.timeline.events) {
|
||||
@ -70,22 +190,29 @@ export class Room extends EventEmitter {
|
||||
newTimelineEntries: entries,
|
||||
newLiveKey,
|
||||
removedPendingEvents,
|
||||
changedMembers,
|
||||
heroChanges
|
||||
memberChanges,
|
||||
heroChanges,
|
||||
};
|
||||
}
|
||||
|
||||
/** @package */
|
||||
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, changedMembers, heroChanges}) {
|
||||
/**
|
||||
* @package
|
||||
* Called with the changes returned from `writeSync` to apply them and emit changes.
|
||||
* No storage or network operations should be done here.
|
||||
*/
|
||||
afterSync({summaryChanges, newTimelineEntries, newLiveKey, removedPendingEvents, memberChanges, heroChanges}) {
|
||||
this._syncWriter.afterSync(newLiveKey);
|
||||
if (changedMembers.length) {
|
||||
if (!this._summary.encryption && summaryChanges.encryption && !this._roomEncryption) {
|
||||
this._enableEncryption(summaryChanges.encryption);
|
||||
}
|
||||
if (memberChanges.size) {
|
||||
if (this._changedMembersDuringSync) {
|
||||
for (const member of changedMembers) {
|
||||
this._changedMembersDuringSync.set(member.userId, member);
|
||||
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||
this._changedMembersDuringSync.set(userId, memberChange.member);
|
||||
}
|
||||
}
|
||||
if (this._memberList) {
|
||||
this._memberList.afterSync(changedMembers);
|
||||
this._memberList.afterSync(memberChanges);
|
||||
}
|
||||
}
|
||||
let emitChange = false;
|
||||
@ -115,8 +242,35 @@ export class Room extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
needsAfterSyncCompleted({memberChanges}) {
|
||||
return this._roomEncryption?.needsToShareKeys(memberChanges);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only called if the result of writeSync had `needsAfterSyncCompleted` set.
|
||||
* Can be used to do longer running operations that resulted from the last sync,
|
||||
* like network operations.
|
||||
*/
|
||||
async afterSyncCompleted() {
|
||||
if (this._roomEncryption) {
|
||||
await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi);
|
||||
}
|
||||
}
|
||||
|
||||
/** @package */
|
||||
resumeSending() {
|
||||
async start(pendingOperations) {
|
||||
if (this._roomEncryption) {
|
||||
try {
|
||||
const roomKeyShares = pendingOperations?.get("share_room_key");
|
||||
if (roomKeyShares) {
|
||||
// if we got interrupted last time sending keys to newly joined members
|
||||
await this._roomEncryption.flushPendingRoomKeyShares(this._hsApi, roomKeyShares);
|
||||
}
|
||||
} catch (err) {
|
||||
// we should not throw here
|
||||
console.error(`could not send out (all) pending room keys for room ${this.id}`, err.stack);
|
||||
}
|
||||
}
|
||||
this._sendQueue.resumeSending();
|
||||
}
|
||||
|
||||
@ -124,6 +278,9 @@ export class Room extends EventEmitter {
|
||||
async load(summary, txn) {
|
||||
try {
|
||||
this._summary.load(summary);
|
||||
if (this._summary.encryption) {
|
||||
this._enableEncryption(this._summary.encryption);
|
||||
}
|
||||
// need to load members for name?
|
||||
if (this._summary.needsHeroes) {
|
||||
this._heroes = new Heroes(this._roomId);
|
||||
@ -144,6 +301,7 @@ export class Room extends EventEmitter {
|
||||
/** @public */
|
||||
async loadMemberList() {
|
||||
if (this._memberList) {
|
||||
// TODO: also await fetchOrLoadMembers promise here
|
||||
this._memberList.retain();
|
||||
return this._memberList;
|
||||
} else {
|
||||
@ -152,6 +310,7 @@ export class Room extends EventEmitter {
|
||||
roomId: this._roomId,
|
||||
hsApi: this._hsApi,
|
||||
storage: this._storage,
|
||||
syncToken: this._getSyncToken(),
|
||||
// to handle race between /members and /sync
|
||||
setChangedMembersMap: map => this._changedMembersDuringSync = map,
|
||||
});
|
||||
@ -193,7 +352,7 @@ export class Room extends EventEmitter {
|
||||
const gapWriter = new GapWriter({
|
||||
roomId: this._roomId,
|
||||
storage: this._storage,
|
||||
fragmentIdComparer: this._fragmentIdComparer
|
||||
fragmentIdComparer: this._fragmentIdComparer,
|
||||
});
|
||||
gapResult = await gapWriter.writeFragmentFill(fragmentEntry, response, txn);
|
||||
} catch (err) {
|
||||
@ -201,6 +360,10 @@ export class Room extends EventEmitter {
|
||||
throw err;
|
||||
}
|
||||
await txn.complete();
|
||||
if (this._roomEncryption) {
|
||||
const decryptRequest = this._decryptEntries(DecryptionSource.Timeline, gapResult.entries);
|
||||
await decryptRequest.complete();
|
||||
}
|
||||
// once txn is committed, update in-memory state & emit events
|
||||
for (const fragment of gapResult.fragments) {
|
||||
this._fragmentIdComparer.add(fragment);
|
||||
@ -256,6 +419,14 @@ export class Room extends EventEmitter {
|
||||
return !!(tags && tags['m.lowpriority']);
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return !!this._summary.encryption;
|
||||
}
|
||||
|
||||
get isTrackingMembers() {
|
||||
return this._summary.isTrackingMembers;
|
||||
}
|
||||
|
||||
async _getLastEventId() {
|
||||
const lastKey = this._syncWriter.lastMessageKey;
|
||||
if (lastKey) {
|
||||
@ -267,6 +438,10 @@ export class Room extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
get _isTimelineOpen() {
|
||||
return !!this._timeline;
|
||||
}
|
||||
|
||||
async clearUnread() {
|
||||
if (this.isUnread || this.notificationCount) {
|
||||
const txn = await this._storage.readWriteTxn([
|
||||
@ -299,7 +474,7 @@ export class Room extends EventEmitter {
|
||||
}
|
||||
|
||||
/** @public */
|
||||
async openTimeline() {
|
||||
openTimeline() {
|
||||
if (this._timeline) {
|
||||
throw new Error("not dealing with load race here for now");
|
||||
}
|
||||
@ -312,15 +487,53 @@ export class Room extends EventEmitter {
|
||||
closeCallback: () => {
|
||||
console.log(`closing the timeline for ${this._roomId}`);
|
||||
this._timeline = null;
|
||||
if (this._roomEncryption) {
|
||||
this._roomEncryption.notifyTimelineClosed();
|
||||
}
|
||||
},
|
||||
user: this._user,
|
||||
clock: this._clock
|
||||
});
|
||||
await this._timeline.load();
|
||||
if (this._roomEncryption) {
|
||||
this._timeline.enableEncryption(this._decryptEntries.bind(this, DecryptionSource.Timeline));
|
||||
}
|
||||
return this._timeline;
|
||||
}
|
||||
|
||||
get mediaRepository() {
|
||||
return this._hsApi.mediaRepository;
|
||||
}
|
||||
|
||||
/** @package */
|
||||
writeIsTrackingMembers(value, txn) {
|
||||
return this._summary.writeIsTrackingMembers(value, txn);
|
||||
}
|
||||
|
||||
/** @package */
|
||||
applyIsTrackingMembersChanges(changes) {
|
||||
this._summary.applyChanges(changes);
|
||||
}
|
||||
}
|
||||
|
||||
class DecryptionRequest {
|
||||
constructor(decryptFn) {
|
||||
this._cancelled = false;
|
||||
this.preparation = null;
|
||||
this._promise = decryptFn(this);
|
||||
}
|
||||
|
||||
complete() {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
get cancelled() {
|
||||
return this._cancelled;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
this._cancelled = true;
|
||||
if (this.preparation) {
|
||||
this.preparation.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {MEGOLM_ALGORITHM} from "../e2ee/common.js";
|
||||
|
||||
function applySyncResponse(data, roomResponse, membership, isInitialSync, isTimelineOpen, ownUserId) {
|
||||
if (roomResponse.summary) {
|
||||
data = updateSummary(data, roomResponse.summary);
|
||||
@ -29,12 +31,8 @@ function applySyncResponse(data, roomResponse, membership, isInitialSync, isTime
|
||||
if (roomResponse.state) {
|
||||
data = roomResponse.state.events.reduce(processStateEvent, data);
|
||||
}
|
||||
if (roomResponse.timeline) {
|
||||
const {timeline} = roomResponse;
|
||||
if (timeline.prev_batch) {
|
||||
data = data.cloneIfNeeded();
|
||||
data.lastPaginationToken = timeline.prev_batch;
|
||||
}
|
||||
if (timeline && Array.isArray(timeline.events)) {
|
||||
data = timeline.events.reduce((data, event) => {
|
||||
if (typeof event.state_key === "string") {
|
||||
return processStateEvent(data, event);
|
||||
@ -68,9 +66,10 @@ function processRoomAccountData(data, event) {
|
||||
|
||||
function processStateEvent(data, event) {
|
||||
if (event.type === "m.room.encryption") {
|
||||
if (!data.isEncrypted) {
|
||||
const algorithm = event.content?.algorithm;
|
||||
if (!data.encryption && algorithm === MEGOLM_ALGORITHM) {
|
||||
data = data.cloneIfNeeded();
|
||||
data.isEncrypted = true;
|
||||
data.encryption = event.content;
|
||||
}
|
||||
} else if (event.type === "m.room.name") {
|
||||
const newName = event.content?.name;
|
||||
@ -113,7 +112,9 @@ function updateSummary(data, summary) {
|
||||
const heroes = summary["m.heroes"];
|
||||
const joinCount = summary["m.joined_member_count"];
|
||||
const inviteCount = summary["m.invited_member_count"];
|
||||
|
||||
// TODO: we could easily calculate if all members are available here and set hasFetchedMembers?
|
||||
// so we can avoid calling /members...
|
||||
// we'd need to do a count query in the roomMembers store though ...
|
||||
if (heroes && Array.isArray(heroes)) {
|
||||
data = data.cloneIfNeeded();
|
||||
data.heroes = heroes;
|
||||
@ -136,7 +137,7 @@ class SummaryData {
|
||||
this.lastMessageBody = copy ? copy.lastMessageBody : null;
|
||||
this.lastMessageTimestamp = copy ? copy.lastMessageTimestamp : null;
|
||||
this.isUnread = copy ? copy.isUnread : false;
|
||||
this.isEncrypted = copy ? copy.isEncrypted : false;
|
||||
this.encryption = copy ? copy.encryption : null;
|
||||
this.isDirectMessage = copy ? copy.isDirectMessage : false;
|
||||
this.membership = copy ? copy.membership : null;
|
||||
this.inviteCount = copy ? copy.inviteCount : 0;
|
||||
@ -144,7 +145,7 @@ class SummaryData {
|
||||
this.heroes = copy ? copy.heroes : null;
|
||||
this.canonicalAlias = copy ? copy.canonicalAlias : null;
|
||||
this.hasFetchedMembers = copy ? copy.hasFetchedMembers : false;
|
||||
this.lastPaginationToken = copy ? copy.lastPaginationToken : null;
|
||||
this.isTrackingMembers = copy ? copy.isTrackingMembers : false;
|
||||
this.avatarUrl = copy ? copy.avatarUrl : null;
|
||||
this.notificationCount = copy ? copy.notificationCount : 0;
|
||||
this.highlightCount = copy ? copy.highlightCount : 0;
|
||||
@ -190,6 +191,11 @@ export class RoomSummary {
|
||||
return this._data.heroes;
|
||||
}
|
||||
|
||||
get encryption() {
|
||||
return this._data.encryption;
|
||||
}
|
||||
|
||||
// whether the room name should be determined with Heroes
|
||||
get needsHeroes() {
|
||||
return needsHeroes(this._data);
|
||||
}
|
||||
@ -230,8 +236,8 @@ export class RoomSummary {
|
||||
return this._data.hasFetchedMembers;
|
||||
}
|
||||
|
||||
get lastPaginationToken() {
|
||||
return this._data.lastPaginationToken;
|
||||
get isTrackingMembers() {
|
||||
return this._data.isTrackingMembers;
|
||||
}
|
||||
|
||||
get tags() {
|
||||
@ -254,6 +260,13 @@ export class RoomSummary {
|
||||
return data;
|
||||
}
|
||||
|
||||
writeIsTrackingMembers(value, txn) {
|
||||
const data = new SummaryData(this._data);
|
||||
data.isTrackingMembers = value;
|
||||
txn.roomSummary.set(data.serialize());
|
||||
return data;
|
||||
}
|
||||
|
||||
writeSync(roomResponse, membership, isInitialSync, isTimelineOpen, txn) {
|
||||
// clear cloned flag, so cloneIfNeeded makes a copy and
|
||||
// this._data is not modified if any field is changed.
|
||||
|
21
src/matrix/room/common.js
Normal file
21
src/matrix/room/common.js
Normal 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;
|
||||
}
|
@ -42,11 +42,11 @@ export class Heroes {
|
||||
|
||||
/**
|
||||
* @param {string[]} newHeroes array of user ids
|
||||
* @param {RoomMember[]} changedMembers array of changed members in this sync
|
||||
* @param {Map<string, MemberChange>} memberChanges map of changed memberships
|
||||
* @param {Transaction} txn
|
||||
* @return {Promise}
|
||||
*/
|
||||
async calculateChanges(newHeroes, changedMembers, txn) {
|
||||
async calculateChanges(newHeroes, memberChanges, txn) {
|
||||
const updatedHeroMembers = new Map();
|
||||
const removedUserIds = [];
|
||||
// remove non-present members
|
||||
@ -56,9 +56,9 @@ export class Heroes {
|
||||
}
|
||||
}
|
||||
// update heroes with synced member changes
|
||||
for (const member of changedMembers) {
|
||||
if (this._members.has(member.userId) || newHeroes.indexOf(member.userId) !== -1) {
|
||||
updatedHeroMembers.set(member.userId, member);
|
||||
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||
if (this._members.has(userId) || newHeroes.indexOf(userId) !== -1) {
|
||||
updatedHeroMembers.set(userId, memberChange.member);
|
||||
}
|
||||
}
|
||||
// load member for new heroes from storage
|
||||
|
@ -26,9 +26,9 @@ export class MemberList {
|
||||
this._retentionCount = 1;
|
||||
}
|
||||
|
||||
afterSync(updatedMembers) {
|
||||
for (const member of updatedMembers) {
|
||||
this._members.add(member.userId, member);
|
||||
afterSync(memberChanges) {
|
||||
for (const [userId, memberChange] of memberChanges.entries()) {
|
||||
this._members.add(userId, memberChange.member);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,4 @@
|
||||
/*
|
||||
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");
|
||||
@ -15,6 +14,8 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import {getPrevContentFromStateEvent} from "../common.js";
|
||||
|
||||
export const EVENT_TYPE = "m.room.member";
|
||||
|
||||
export class RoomMember {
|
||||
@ -28,7 +29,7 @@ export class RoomMember {
|
||||
return;
|
||||
}
|
||||
const content = memberEvent.content;
|
||||
const prevContent = memberEvent.unsigned?.prev_content;
|
||||
const prevContent = getPrevContentFromStateEvent(memberEvent);
|
||||
const membership = content?.membership;
|
||||
// fall back to prev_content for these as synapse doesn't (always?)
|
||||
// put them on content for "leave" memberships
|
||||
@ -45,7 +46,7 @@ export class RoomMember {
|
||||
if (typeof userId !== "string") {
|
||||
return;
|
||||
}
|
||||
const content = memberEvent.unsigned?.prev_content
|
||||
const content = getPrevContentFromStateEvent(memberEvent);
|
||||
return this._validateAndCreateMember(roomId, userId,
|
||||
content?.membership,
|
||||
content?.displayname,
|
||||
@ -66,6 +67,10 @@ export class RoomMember {
|
||||
});
|
||||
}
|
||||
|
||||
get membership() {
|
||||
return this._data.membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {String?} the display name, if any
|
||||
*/
|
||||
@ -99,3 +104,42 @@ export class RoomMember {
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
|
||||
export class MemberChange {
|
||||
constructor(roomId, memberEvent) {
|
||||
this._roomId = roomId;
|
||||
this._memberEvent = memberEvent;
|
||||
this._member = null;
|
||||
}
|
||||
|
||||
get member() {
|
||||
if (!this._member) {
|
||||
this._member = RoomMember.fromMemberEvent(this._roomId, this._memberEvent);
|
||||
}
|
||||
return this._member;
|
||||
}
|
||||
|
||||
get roomId() {
|
||||
return this._roomId;
|
||||
}
|
||||
|
||||
get userId() {
|
||||
return this._memberEvent.state_key;
|
||||
}
|
||||
|
||||
get previousMembership() {
|
||||
return getPrevContentFromStateEvent(this._memberEvent)?.membership;
|
||||
}
|
||||
|
||||
get membership() {
|
||||
return this._memberEvent.content?.membership;
|
||||
}
|
||||
|
||||
get hasLeft() {
|
||||
return this.previousMembership === "join" && this.membership !== "join";
|
||||
}
|
||||
|
||||
get hasJoined() {
|
||||
return this.previousMembership !== "join" && this.membership === "join";
|
||||
}
|
||||
}
|
||||
|
@ -25,13 +25,13 @@ async function loadMembers({roomId, storage}) {
|
||||
return memberDatas.map(d => new RoomMember(d));
|
||||
}
|
||||
|
||||
async function fetchMembers({summary, roomId, hsApi, storage, setChangedMembersMap}) {
|
||||
async function fetchMembers({summary, syncToken, roomId, hsApi, storage, setChangedMembersMap}) {
|
||||
// if any members are changed by sync while we're fetching members,
|
||||
// they will end up here, so we check not to override them
|
||||
const changedMembersDuringSync = new Map();
|
||||
setChangedMembersMap(changedMembersDuringSync);
|
||||
|
||||
const memberResponse = await hsApi.members(roomId, {at: summary.lastPaginationToken}).response;
|
||||
const memberResponse = await hsApi.members(roomId, {at: syncToken}).response();
|
||||
|
||||
const txn = await storage.readWriteTxn([
|
||||
storage.storeNames.roomSummary,
|
||||
|
@ -26,5 +26,12 @@ export class PendingEvent {
|
||||
get remoteId() { return this._data.remoteId; }
|
||||
set remoteId(value) { this._data.remoteId = value; }
|
||||
get content() { return this._data.content; }
|
||||
get needsEncryption() { return this._data.needsEncryption; }
|
||||
get data() { return this._data; }
|
||||
|
||||
setEncrypted(type, content) {
|
||||
this._data.eventType = type;
|
||||
this._data.content = content;
|
||||
this._data.needsEncryption = false;
|
||||
}
|
||||
}
|
||||
|
@ -17,12 +17,7 @@ limitations under the License.
|
||||
import {SortedArray} from "../../../observable/list/SortedArray.js";
|
||||
import {ConnectionError} from "../../error.js";
|
||||
import {PendingEvent} from "./PendingEvent.js";
|
||||
|
||||
function makeTxnId() {
|
||||
const n = Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||
const str = n.toString(16);
|
||||
return "t" + "0".repeat(14 - str.length) + str;
|
||||
}
|
||||
import {makeTxnId} from "../../common.js";
|
||||
|
||||
export class SendQueue {
|
||||
constructor({roomId, storage, sendScheduler, pendingEvents}) {
|
||||
@ -38,6 +33,11 @@ export class SendQueue {
|
||||
this._isSending = false;
|
||||
this._offline = false;
|
||||
this._amountSent = 0;
|
||||
this._roomEncryption = null;
|
||||
}
|
||||
|
||||
enableEncryption(roomEncryption) {
|
||||
this._roomEncryption = roomEncryption;
|
||||
}
|
||||
|
||||
async _sendLoop() {
|
||||
@ -50,6 +50,13 @@ export class SendQueue {
|
||||
if (pendingEvent.remoteId) {
|
||||
continue;
|
||||
}
|
||||
if (pendingEvent.needsEncryption) {
|
||||
const {type, content} = await this._sendScheduler.request(async hsApi => {
|
||||
return await this._roomEncryption.encrypt(pendingEvent.eventType, pendingEvent.content, hsApi);
|
||||
});
|
||||
pendingEvent.setEncrypted(type, content);
|
||||
await this._tryUpdateEvent(pendingEvent);
|
||||
}
|
||||
console.log("really sending now");
|
||||
const response = await this._sendScheduler.request(hsApi => {
|
||||
console.log("got sendScheduler slot");
|
||||
@ -161,7 +168,8 @@ export class SendQueue {
|
||||
queueIndex,
|
||||
eventType,
|
||||
content,
|
||||
txnId: makeTxnId()
|
||||
txnId: makeTxnId(),
|
||||
needsEncryption: !!this._roomEncryption
|
||||
});
|
||||
console.log("_createAndStoreEvent: adding to pendingEventsStore");
|
||||
pendingEventsStore.add(pendingEvent.data);
|
||||
|
@ -15,24 +15,27 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {SortedArray, MappedList, ConcatList} from "../../../observable/index.js";
|
||||
import {Disposables} from "../../../utils/Disposables.js";
|
||||
import {Direction} from "./Direction.js";
|
||||
import {TimelineReader} from "./persistence/TimelineReader.js";
|
||||
import {PendingEventEntry} from "./entries/PendingEventEntry.js";
|
||||
|
||||
export class Timeline {
|
||||
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user}) {
|
||||
constructor({roomId, storage, closeCallback, fragmentIdComparer, pendingEvents, user, clock}) {
|
||||
this._roomId = roomId;
|
||||
this._storage = storage;
|
||||
this._closeCallback = closeCallback;
|
||||
this._fragmentIdComparer = fragmentIdComparer;
|
||||
this._disposables = new Disposables();
|
||||
this._remoteEntries = new SortedArray((a, b) => a.compare(b));
|
||||
this._timelineReader = new TimelineReader({
|
||||
roomId: this._roomId,
|
||||
storage: this._storage,
|
||||
fragmentIdComparer: this._fragmentIdComparer
|
||||
});
|
||||
this._readerRequest = null;
|
||||
const localEntries = new MappedList(pendingEvents, pe => {
|
||||
return new PendingEventEntry({pendingEvent: pe, user});
|
||||
return new PendingEventEntry({pendingEvent: pe, user, clock});
|
||||
}, (pee, params) => {
|
||||
pee.notifyUpdate(params);
|
||||
});
|
||||
@ -41,8 +44,20 @@ export class Timeline {
|
||||
|
||||
/** @package */
|
||||
async load() {
|
||||
const entries = await this._timelineReader.readFromEnd(50);
|
||||
// 30 seems to be a good amount to fill the entire screen
|
||||
const readerRequest = this._disposables.track(this._timelineReader.readFromEnd(30));
|
||||
try {
|
||||
const entries = await readerRequest.complete();
|
||||
this._remoteEntries.setManySorted(entries);
|
||||
} finally {
|
||||
this._disposables.disposeTracked(readerRequest);
|
||||
}
|
||||
}
|
||||
|
||||
replaceEntries(entries) {
|
||||
for (const entry of entries) {
|
||||
this._remoteEntries.replace(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: should we rather have generic methods for
|
||||
@ -64,12 +79,17 @@ export class Timeline {
|
||||
if (!firstEventEntry) {
|
||||
return;
|
||||
}
|
||||
const entries = await this._timelineReader.readFrom(
|
||||
const readerRequest = this._disposables.track(this._timelineReader.readFrom(
|
||||
firstEventEntry.asEventKey(),
|
||||
Direction.Backward,
|
||||
amount
|
||||
);
|
||||
));
|
||||
try {
|
||||
const entries = await readerRequest.complete();
|
||||
this._remoteEntries.setManySorted(entries);
|
||||
} finally {
|
||||
this._disposables.disposeTracked(readerRequest);
|
||||
}
|
||||
}
|
||||
|
||||
/** @public */
|
||||
@ -78,10 +98,15 @@ export class Timeline {
|
||||
}
|
||||
|
||||
/** @public */
|
||||
close() {
|
||||
dispose() {
|
||||
if (this._closeCallback) {
|
||||
this._disposables.dispose();
|
||||
this._closeCallback();
|
||||
this._closeCallback = null;
|
||||
}
|
||||
}
|
||||
|
||||
enableEncryption(decryptEntries) {
|
||||
this._timelineReader.enableEncryption(decryptEntries);
|
||||
}
|
||||
}
|
||||
|
@ -15,11 +15,18 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {BaseEntry} from "./BaseEntry.js";
|
||||
import {getPrevContentFromStateEvent} from "../../common.js";
|
||||
|
||||
export class EventEntry extends BaseEntry {
|
||||
constructor(eventEntry, fragmentIdComparer) {
|
||||
super(fragmentIdComparer);
|
||||
this._eventEntry = eventEntry;
|
||||
this._decryptionError = null;
|
||||
this._decryptionResult = null;
|
||||
}
|
||||
|
||||
get event() {
|
||||
return this._eventEntry.event;
|
||||
}
|
||||
|
||||
get fragmentId() {
|
||||
@ -31,15 +38,16 @@ export class EventEntry extends BaseEntry {
|
||||
}
|
||||
|
||||
get content() {
|
||||
return this._eventEntry.event.content;
|
||||
return this._decryptionResult?.event?.content || this._eventEntry.event.content;
|
||||
}
|
||||
|
||||
get prevContent() {
|
||||
return this._eventEntry.event.unsigned?.prev_content;
|
||||
// doesn't look at _decryptionResult because state events are not encrypted
|
||||
return getPrevContentFromStateEvent(this._eventEntry.event);
|
||||
}
|
||||
|
||||
get eventType() {
|
||||
return this._eventEntry.event.type;
|
||||
return this._decryptionResult?.event?.type || this._eventEntry.event.type;
|
||||
}
|
||||
|
||||
get stateKey() {
|
||||
@ -65,4 +73,24 @@ export class EventEntry extends BaseEntry {
|
||||
get id() {
|
||||
return this._eventEntry.event.event_id;
|
||||
}
|
||||
|
||||
setDecryptionResult(result) {
|
||||
this._decryptionResult = result;
|
||||
}
|
||||
|
||||
get isEncrypted() {
|
||||
return this._eventEntry.event.type === "m.room.encrypted";
|
||||
}
|
||||
|
||||
get isVerified() {
|
||||
return this.isEncrypted && this._decryptionResult?.isVerified;
|
||||
}
|
||||
|
||||
get isUnverified() {
|
||||
return this.isEncrypted && this._decryptionResult?.isUnverified;
|
||||
}
|
||||
|
||||
setDecryptionError(err) {
|
||||
this._decryptionError = err;
|
||||
}
|
||||
}
|
||||
|
@ -17,10 +17,11 @@ limitations under the License.
|
||||
import {BaseEntry, PENDING_FRAGMENT_ID} from "./BaseEntry.js";
|
||||
|
||||
export class PendingEventEntry extends BaseEntry {
|
||||
constructor({pendingEvent, user}) {
|
||||
constructor({pendingEvent, user, clock}) {
|
||||
super(null);
|
||||
this._pendingEvent = pendingEvent;
|
||||
this._user = user;
|
||||
this._clock = clock;
|
||||
}
|
||||
|
||||
get fragmentId() {
|
||||
@ -52,7 +53,7 @@ export class PendingEventEntry extends BaseEntry {
|
||||
}
|
||||
|
||||
get timestamp() {
|
||||
return null;
|
||||
return this._clock.now();
|
||||
}
|
||||
|
||||
get isPending() {
|
||||
|
@ -18,7 +18,7 @@ import {EventKey} from "../EventKey.js";
|
||||
import {EventEntry} from "../entries/EventEntry.js";
|
||||
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
|
||||
import {createEventEntry} from "./common.js";
|
||||
import {RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
|
||||
import {MemberChange, RoomMember, EVENT_TYPE as MEMBER_EVENT_TYPE} from "../../members/RoomMember.js";
|
||||
|
||||
// Synapse bug? where the m.room.create event appears twice in sync response
|
||||
// when first syncing the room
|
||||
@ -98,41 +98,41 @@ export class SyncWriter {
|
||||
return {oldFragment, newFragment};
|
||||
}
|
||||
|
||||
_writeStateEvent(event, txn) {
|
||||
if (event.type === MEMBER_EVENT_TYPE) {
|
||||
_writeMember(event, txn) {
|
||||
const userId = event.state_key;
|
||||
if (userId) {
|
||||
const member = RoomMember.fromMemberEvent(this._roomId, event);
|
||||
const memberChange = new MemberChange(this._roomId, event);
|
||||
const {member} = memberChange;
|
||||
if (member) {
|
||||
// as this is sync, we can just replace the member
|
||||
// if it is there already
|
||||
txn.roomMembers.set(member.serialize());
|
||||
return memberChange;
|
||||
}
|
||||
return member;
|
||||
}
|
||||
}
|
||||
|
||||
_writeStateEvent(event, txn) {
|
||||
if (event.type === MEMBER_EVENT_TYPE) {
|
||||
return this._writeMember(event, txn);
|
||||
} else {
|
||||
txn.roomState.set(this._roomId, event);
|
||||
}
|
||||
}
|
||||
|
||||
_writeStateEvents(roomResponse, txn) {
|
||||
const changedMembers = [];
|
||||
_writeStateEvents(roomResponse, memberChanges, txn) {
|
||||
// persist state
|
||||
const {state} = roomResponse;
|
||||
if (Array.isArray(state?.events)) {
|
||||
for (const event of state.events) {
|
||||
const member = this._writeStateEvent(event, txn);
|
||||
if (member) {
|
||||
changedMembers.push(member);
|
||||
const memberChange = this._writeStateEvent(event, txn);
|
||||
if (memberChange) {
|
||||
memberChanges.set(memberChange.userId, memberChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
return changedMembers;
|
||||
}
|
||||
|
||||
async _writeTimeline(entries, timeline, currentKey, txn) {
|
||||
const changedMembers = [];
|
||||
if (timeline.events) {
|
||||
async _writeTimeline(entries, timeline, currentKey, memberChanges, txn) {
|
||||
if (Array.isArray(timeline.events)) {
|
||||
const events = deduplicateEvents(timeline.events);
|
||||
for(const event of events) {
|
||||
// store event in timeline
|
||||
@ -148,14 +148,14 @@ export class SyncWriter {
|
||||
|
||||
// process live state events first, so new member info is available
|
||||
if (typeof event.state_key === "string") {
|
||||
const member = this._writeStateEvent(event, txn);
|
||||
if (member) {
|
||||
changedMembers.push(member);
|
||||
const memberChange = this._writeStateEvent(event, txn);
|
||||
if (memberChange) {
|
||||
memberChanges.set(memberChange.userId, memberChange);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {currentKey, changedMembers};
|
||||
return currentKey;
|
||||
}
|
||||
|
||||
async _findMemberData(userId, events, txn) {
|
||||
@ -176,6 +176,16 @@ export class SyncWriter {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @type {SyncWriterResult}
|
||||
* @property {Array<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) {
|
||||
const entries = [];
|
||||
const {timeline} = roomResponse;
|
||||
@ -196,14 +206,12 @@ export class SyncWriter {
|
||||
entries.push(FragmentBoundaryEntry.end(oldFragment, this._fragmentIdComparer));
|
||||
entries.push(FragmentBoundaryEntry.start(newFragment, this._fragmentIdComparer));
|
||||
}
|
||||
const memberChanges = new Map();
|
||||
// important this happens before _writeTimeline so
|
||||
// members are available in the transaction
|
||||
const changedMembers = this._writeStateEvents(roomResponse, txn);
|
||||
const timelineResult = await this._writeTimeline(entries, timeline, currentKey, txn);
|
||||
currentKey = timelineResult.currentKey;
|
||||
changedMembers.push(...timelineResult.changedMembers);
|
||||
|
||||
return {entries, newLiveKey: currentKey, changedMembers};
|
||||
this._writeStateEvents(roomResponse, memberChanges, txn);
|
||||
currentKey = await this._writeTimeline(entries, timeline, currentKey, memberChanges, txn);
|
||||
return {entries, newLiveKey: currentKey, memberChanges};
|
||||
}
|
||||
|
||||
afterSync(newLiveKey) {
|
||||
|
@ -19,26 +19,74 @@ import {Direction} from "../Direction.js";
|
||||
import {EventEntry} from "../entries/EventEntry.js";
|
||||
import {FragmentBoundaryEntry} from "../entries/FragmentBoundaryEntry.js";
|
||||
|
||||
class ReaderRequest {
|
||||
constructor(fn) {
|
||||
this.decryptRequest = null;
|
||||
this._promise = fn(this);
|
||||
}
|
||||
|
||||
complete() {
|
||||
return this._promise;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
if (this.decryptRequest) {
|
||||
this.decryptRequest.dispose();
|
||||
this.decryptRequest = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class TimelineReader {
|
||||
constructor({roomId, storage, fragmentIdComparer}) {
|
||||
this._roomId = roomId;
|
||||
this._storage = storage;
|
||||
this._fragmentIdComparer = fragmentIdComparer;
|
||||
this._decryptEntries = null;
|
||||
}
|
||||
|
||||
enableEncryption(decryptEntries) {
|
||||
this._decryptEntries = decryptEntries;
|
||||
}
|
||||
|
||||
_openTxn() {
|
||||
return this._storage.readTxn([
|
||||
const stores = [
|
||||
this._storage.storeNames.timelineEvents,
|
||||
this._storage.storeNames.timelineFragments,
|
||||
]);
|
||||
];
|
||||
if (this._decryptEntries) {
|
||||
stores.push(this._storage.storeNames.inboundGroupSessions);
|
||||
}
|
||||
return this._storage.readTxn(stores);
|
||||
}
|
||||
|
||||
async readFrom(eventKey, direction, amount) {
|
||||
readFrom(eventKey, direction, amount) {
|
||||
return new ReaderRequest(async r => {
|
||||
const txn = await this._openTxn();
|
||||
return this._readFrom(eventKey, direction, amount, txn);
|
||||
return await this._readFrom(eventKey, direction, amount, r, txn);
|
||||
});
|
||||
}
|
||||
|
||||
async _readFrom(eventKey, direction, amount, txn) {
|
||||
readFromEnd(amount) {
|
||||
return new ReaderRequest(async r => {
|
||||
const txn = await this._openTxn();
|
||||
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
|
||||
let entries;
|
||||
// room hasn't been synced yet
|
||||
if (!liveFragment) {
|
||||
entries = [];
|
||||
} else {
|
||||
this._fragmentIdComparer.add(liveFragment);
|
||||
const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
|
||||
const eventKey = liveFragmentEntry.asEventKey();
|
||||
entries = await this._readFrom(eventKey, Direction.Backward, amount, r, txn);
|
||||
entries.unshift(liveFragmentEntry);
|
||||
}
|
||||
return entries;
|
||||
});
|
||||
}
|
||||
|
||||
async _readFrom(eventKey, direction, amount, r, txn) {
|
||||
let entries = [];
|
||||
const timelineStore = txn.timelineEvents;
|
||||
const fragmentStore = txn.timelineFragments;
|
||||
@ -50,7 +98,7 @@ export class TimelineReader {
|
||||
} else {
|
||||
eventsWithinFragment = await timelineStore.eventsBefore(this._roomId, eventKey, amount);
|
||||
}
|
||||
const eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
|
||||
let eventEntries = eventsWithinFragment.map(e => new EventEntry(e, this._fragmentIdComparer));
|
||||
entries = directionalConcat(entries, eventEntries, direction);
|
||||
// prepend or append eventsWithinFragment to entries, and wrap them in EventEntry
|
||||
|
||||
@ -73,27 +121,14 @@ export class TimelineReader {
|
||||
}
|
||||
}
|
||||
|
||||
if (this._decryptEntries) {
|
||||
r.decryptRequest = this._decryptEntries(entries, txn);
|
||||
try {
|
||||
await r.decryptRequest.complete();
|
||||
} finally {
|
||||
r.decryptRequest = null;
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
async readFromEnd(amount) {
|
||||
const txn = await this._openTxn();
|
||||
const liveFragment = await txn.timelineFragments.liveFragment(this._roomId);
|
||||
// room hasn't been synced yet
|
||||
if (!liveFragment) {
|
||||
return [];
|
||||
}
|
||||
this._fragmentIdComparer.add(liveFragment);
|
||||
const liveFragmentEntry = FragmentBoundaryEntry.end(liveFragment, this._fragmentIdComparer);
|
||||
const eventKey = liveFragmentEntry.asEventKey();
|
||||
const entries = await this._readFrom(eventKey, Direction.Backward, amount, txn);
|
||||
entries.unshift(liveFragmentEntry);
|
||||
return entries;
|
||||
}
|
||||
|
||||
// reads distance up and down from eventId
|
||||
// or just expose eventIdToKey?
|
||||
readAtEventId(eventId, distance) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
@ -22,6 +22,13 @@ export const STORE_NAMES = Object.freeze([
|
||||
"timelineEvents",
|
||||
"timelineFragments",
|
||||
"pendingEvents",
|
||||
"userIdentities",
|
||||
"deviceIdentities",
|
||||
"olmSessions",
|
||||
"inboundGroupSessions",
|
||||
"outboundGroupSessions",
|
||||
"groupSessionDecryptions",
|
||||
"operations"
|
||||
]);
|
||||
|
||||
export const STORE_MAP = Object.freeze(STORE_NAMES.reduce((nameMap, name) => {
|
||||
|
@ -42,7 +42,15 @@ export class QueryTarget {
|
||||
}
|
||||
|
||||
getKey(key) {
|
||||
if (this._target.supports("getKey")) {
|
||||
return reqAsPromise(this._target.getKey(key));
|
||||
} else {
|
||||
return reqAsPromise(this._target.get(key)).then(value => {
|
||||
if (value) {
|
||||
return value[this._target.keyPath];
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reduce(range, reducer, initialValue) {
|
||||
@ -105,6 +113,13 @@ export class QueryTarget {
|
||||
return maxKey;
|
||||
}
|
||||
|
||||
async iterateKeys(range, callback) {
|
||||
const cursor = this._target.openKeyCursor(range, "next");
|
||||
await iterateCursor(cursor, (_, key) => {
|
||||
return {done: callback(key)};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given set of keys exist.
|
||||
* Calls `callback(key, found)` for each key in `keys`, in key sorting order (or reversed if backwards=true).
|
||||
@ -180,6 +195,14 @@ export class QueryTarget {
|
||||
return results;
|
||||
}
|
||||
|
||||
async iterateWhile(range, predicate) {
|
||||
const cursor = this._openCursor(range, "next");
|
||||
await iterateCursor(cursor, (value) => {
|
||||
const passesPredicate = predicate(value);
|
||||
return {done: !passesPredicate};
|
||||
});
|
||||
}
|
||||
|
||||
async _find(range, predicate, direction) {
|
||||
const cursor = this._openCursor(range, direction);
|
||||
let result;
|
||||
|
@ -23,6 +23,14 @@ class QueryTargetWrapper {
|
||||
this._qt = qt;
|
||||
}
|
||||
|
||||
get keyPath() {
|
||||
if (this._qt.objectStore) {
|
||||
return this._qt.objectStore.keyPath;
|
||||
} else {
|
||||
return this._qt.keyPath;
|
||||
}
|
||||
}
|
||||
|
||||
supports(methodName) {
|
||||
return !!this._qt[methodName];
|
||||
}
|
||||
|
@ -24,6 +24,13 @@ import {RoomStateStore} from "./stores/RoomStateStore.js";
|
||||
import {RoomMemberStore} from "./stores/RoomMemberStore.js";
|
||||
import {TimelineFragmentStore} from "./stores/TimelineFragmentStore.js";
|
||||
import {PendingEventStore} from "./stores/PendingEventStore.js";
|
||||
import {UserIdentityStore} from "./stores/UserIdentityStore.js";
|
||||
import {DeviceIdentityStore} from "./stores/DeviceIdentityStore.js";
|
||||
import {OlmSessionStore} from "./stores/OlmSessionStore.js";
|
||||
import {InboundGroupSessionStore} from "./stores/InboundGroupSessionStore.js";
|
||||
import {OutboundGroupSessionStore} from "./stores/OutboundGroupSessionStore.js";
|
||||
import {GroupSessionDecryptionStore} from "./stores/GroupSessionDecryptionStore.js";
|
||||
import {OperationStore} from "./stores/OperationStore.js";
|
||||
|
||||
export class Transaction {
|
||||
constructor(txn, allowedStoreNames) {
|
||||
@ -81,6 +88,34 @@ export class Transaction {
|
||||
return this._store("pendingEvents", idbStore => new PendingEventStore(idbStore));
|
||||
}
|
||||
|
||||
get userIdentities() {
|
||||
return this._store("userIdentities", idbStore => new UserIdentityStore(idbStore));
|
||||
}
|
||||
|
||||
get deviceIdentities() {
|
||||
return this._store("deviceIdentities", idbStore => new DeviceIdentityStore(idbStore));
|
||||
}
|
||||
|
||||
get olmSessions() {
|
||||
return this._store("olmSessions", idbStore => new OlmSessionStore(idbStore));
|
||||
}
|
||||
|
||||
get inboundGroupSessions() {
|
||||
return this._store("inboundGroupSessions", idbStore => new InboundGroupSessionStore(idbStore));
|
||||
}
|
||||
|
||||
get outboundGroupSessions() {
|
||||
return this._store("outboundGroupSessions", idbStore => new OutboundGroupSessionStore(idbStore));
|
||||
}
|
||||
|
||||
get groupSessionDecryptions() {
|
||||
return this._store("groupSessionDecryptions", idbStore => new GroupSessionDecryptionStore(idbStore));
|
||||
}
|
||||
|
||||
get operations() {
|
||||
return this._store("operations", idbStore => new OperationStore(idbStore));
|
||||
}
|
||||
|
||||
complete() {
|
||||
return txnAsPromise(this._txn);
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ export const schema = [
|
||||
createInitialStores,
|
||||
createMemberStore,
|
||||
migrateSession,
|
||||
createE2EEStores
|
||||
];
|
||||
// TODO: how to deal with git merge conflicts of this array?
|
||||
|
||||
@ -46,7 +47,7 @@ async function createMemberStore(db, txn) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//v3
|
||||
async function migrateSession(db, txn) {
|
||||
const session = txn.objectStore("session");
|
||||
try {
|
||||
@ -64,3 +65,15 @@ async function migrateSession(db, txn) {
|
||||
console.error("could not migrate session", err.stack);
|
||||
}
|
||||
}
|
||||
//v4
|
||||
function createE2EEStores(db) {
|
||||
db.createObjectStore("userIdentities", {keyPath: "userId"});
|
||||
const deviceIdentities = db.createObjectStore("deviceIdentities", {keyPath: "key"});
|
||||
deviceIdentities.createIndex("byCurve25519Key", "curve25519Key", {unique: true});
|
||||
db.createObjectStore("olmSessions", {keyPath: "key"});
|
||||
db.createObjectStore("inboundGroupSessions", {keyPath: "key"});
|
||||
db.createObjectStore("outboundGroupSessions", {keyPath: "roomId"});
|
||||
db.createObjectStore("groupSessionDecryptions", {keyPath: "key"});
|
||||
const operations = db.createObjectStore("operations", {keyPath: "id"});
|
||||
operations.createIndex("byTypeAndScope", "typeScopeKey", {unique: false});
|
||||
}
|
||||
|
45
src/matrix/storage/idb/stores/DeviceIdentityStore.js
Normal file
45
src/matrix/storage/idb/stores/DeviceIdentityStore.js
Normal 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);
|
||||
}
|
||||
}
|
34
src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js
Normal file
34
src/matrix/storage/idb/stores/GroupSessionDecryptionStore.js
Normal 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);
|
||||
}
|
||||
}
|
40
src/matrix/storage/idb/stores/InboundGroupSessionStore.js
Normal file
40
src/matrix/storage/idb/stores/InboundGroupSessionStore.js
Normal 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);
|
||||
}
|
||||
}
|
65
src/matrix/storage/idb/stores/OlmSessionStore.js
Normal file
65
src/matrix/storage/idb/stores/OlmSessionStore.js
Normal 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));
|
||||
}
|
||||
}
|
55
src/matrix/storage/idb/stores/OperationStore.js
Normal file
55
src/matrix/storage/idb/stores/OperationStore.js
Normal 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);
|
||||
}
|
||||
}
|
33
src/matrix/storage/idb/stores/OutboundGroupSessionStore.js
Normal file
33
src/matrix/storage/idb/stores/OutboundGroupSessionStore.js
Normal 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);
|
||||
}
|
||||
}
|
@ -52,13 +52,7 @@ export class PendingEventStore {
|
||||
|
||||
async exists(roomId, queueIndex) {
|
||||
const keyRange = IDBKeyRange.only(encodeKey(roomId, queueIndex));
|
||||
let key;
|
||||
if (this._eventStore.supports("getKey")) {
|
||||
key = await this._eventStore.getKey(keyRange);
|
||||
} else {
|
||||
const value = await this._eventStore.get(keyRange);
|
||||
key = value && value.key;
|
||||
}
|
||||
const key = await this._eventStore.getKey(keyRange);
|
||||
return !!key;
|
||||
}
|
||||
|
||||
|
@ -19,6 +19,11 @@ function encodeKey(roomId, userId) {
|
||||
return `${roomId}|${userId}`;
|
||||
}
|
||||
|
||||
function decodeKey(key) {
|
||||
const [roomId, userId] = key.split("|");
|
||||
return {roomId, userId};
|
||||
}
|
||||
|
||||
// no historical members
|
||||
export class RoomMemberStore {
|
||||
constructor(roomMembersStore) {
|
||||
@ -40,4 +45,19 @@ export class RoomMemberStore {
|
||||
return member.roomId === roomId;
|
||||
});
|
||||
}
|
||||
|
||||
async getAllUserIds(roomId) {
|
||||
const userIds = [];
|
||||
const range = IDBKeyRange.lowerBound(encodeKey(roomId, ""));
|
||||
await this._roomMembersStore.iterateKeys(range, key => {
|
||||
const decodedKey = decodeKey(key);
|
||||
// prevent running into the next room
|
||||
if (decodedKey.roomId === roomId) {
|
||||
userIds.push(decodedKey.userId);
|
||||
return false; // fetch more
|
||||
}
|
||||
return true; // done
|
||||
});
|
||||
return userIds;
|
||||
}
|
||||
}
|
||||
|
@ -14,22 +14,6 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
store contains:
|
||||
loginData {
|
||||
device_id
|
||||
home_server
|
||||
access_token
|
||||
user_id
|
||||
}
|
||||
// flags {
|
||||
// lazyLoading?
|
||||
// }
|
||||
syncToken
|
||||
displayName
|
||||
avatarUrl
|
||||
lastSynced
|
||||
*/
|
||||
export class SessionStore {
|
||||
constructor(sessionStore) {
|
||||
this._sessionStore = sessionStore;
|
||||
@ -45,4 +29,12 @@ export class SessionStore {
|
||||
set(key, value) {
|
||||
return this._sessionStore.put({key, value});
|
||||
}
|
||||
|
||||
add(key, value) {
|
||||
return this._sessionStore.add({key, value});
|
||||
}
|
||||
|
||||
remove(key) {
|
||||
this._sessionStore.delete(key);
|
||||
}
|
||||
}
|
||||
|
33
src/matrix/storage/idb/stores/UserIdentityStore.js
Normal file
33
src/matrix/storage/idb/stores/UserIdentityStore.js
Normal 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);
|
||||
}
|
||||
}
|
@ -41,6 +41,22 @@ export class SortedArray extends BaseObservableList {
|
||||
}
|
||||
}
|
||||
|
||||
replace(item) {
|
||||
const idx = this.indexOf(item);
|
||||
if (idx !== -1) {
|
||||
this._items[idx] = item;
|
||||
}
|
||||
}
|
||||
|
||||
indexOf(item) {
|
||||
const idx = sortedIndex(this._items, item, this._comparator);
|
||||
if (idx < this._items.length && this._comparator(this._items[idx], item) === 0) {
|
||||
return idx;
|
||||
} else {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
set(item, updateParams = null) {
|
||||
const idx = sortedIndex(this._items, item, this._comparator);
|
||||
if (idx >= this._items.length || this._comparator(this._items[idx], item) !== 0) {
|
||||
|
@ -70,6 +70,10 @@ export class ObservableMap extends BaseObservableMap {
|
||||
[Symbol.iterator]() {
|
||||
return this._values.entries();
|
||||
}
|
||||
|
||||
values() {
|
||||
return this._values.values();
|
||||
}
|
||||
}
|
||||
|
||||
export function tests() {
|
||||
|
@ -15,10 +15,19 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
export function spinner(t, extraClasses = undefined) {
|
||||
if (document.body.classList.contains("ie11")) {
|
||||
return t.div({className: "spinner"}, [
|
||||
t.div(),
|
||||
t.div(),
|
||||
t.div(),
|
||||
t.div(),
|
||||
]);
|
||||
} else {
|
||||
return t.svg({className: Object.assign({"spinner": true}, extraClasses), viewBox:"0 0 100 100"},
|
||||
t.circle({cx:"50%", cy:"50%", r:"45%", pathLength:"100"})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {TemplateBuilder} t
|
||||
|
@ -81,5 +81,5 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.TimelineLoadingView div {
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
@ -32,24 +32,57 @@ limitations under the License.
|
||||
}
|
||||
}
|
||||
|
||||
.spinner circle {
|
||||
.not-ie11 .spinner circle {
|
||||
transform-origin: 50% 50%;
|
||||
animation-name: spinner;
|
||||
animation-duration: 2s;
|
||||
animation-iteration-count: infinite;
|
||||
animation-timing-function: linear;
|
||||
/**
|
||||
* TODO
|
||||
* see if with IE11 we can just set a static stroke state and make it rotate?
|
||||
*/
|
||||
stroke-dasharray: 0 0 85 85;
|
||||
|
||||
fill: none;
|
||||
stroke: currentcolor;
|
||||
stroke-width: 12;
|
||||
stroke-linecap: butt;
|
||||
}
|
||||
|
||||
.ie11 .spinner {
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ie11 .spinner div {
|
||||
box-sizing: border-box;
|
||||
display: block;
|
||||
position: absolute;
|
||||
padding: 2px;
|
||||
border: 2px solid currentcolor;
|
||||
border-radius: 50%;
|
||||
animation: ie-spinner 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||
border-color: currentcolor transparent transparent transparent;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
|
||||
.ie11 .spinner div:nth-child(1) {
|
||||
animation-delay: -0.45s;
|
||||
}
|
||||
.ie11 .spinner div:nth-child(2) {
|
||||
animation-delay: -0.3s;
|
||||
}
|
||||
.ie11 .spinner div:nth-child(3) {
|
||||
animation-delay: -0.15s;
|
||||
}
|
||||
|
||||
@keyframes ie-spinner {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.spinner {
|
||||
--size: 20px;
|
||||
width: var(--size);
|
||||
|
@ -373,6 +373,10 @@ ul.Timeline > li.continuation time {
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.TextMessageView.unverified .message-container {
|
||||
color: #ff4b55;
|
||||
}
|
||||
|
||||
.message-container p {
|
||||
margin: 3px 0;
|
||||
line-height: 2.2rem;
|
||||
|
@ -77,6 +77,6 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.GapView > div {
|
||||
flex: 1;
|
||||
margin-left: 10px;
|
||||
flex: 1 1 0;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
@ -24,7 +24,7 @@ export class MessageComposer extends TemplateView {
|
||||
|
||||
render(t, vm) {
|
||||
this._input = t.input({
|
||||
placeholder: "Send a message ...",
|
||||
placeholder: vm.isEncrypted ? "Send an encrypted message…" : "Send a message…",
|
||||
onKeydown: e => this._onKeyDown(e),
|
||||
onInput: () => vm.setInput(this._input.value),
|
||||
});
|
||||
|
@ -21,7 +21,7 @@ export class TimelineLoadingView extends TemplateView {
|
||||
render(t, vm) {
|
||||
return t.div({className: "TimelineLoadingView"}, [
|
||||
spinner(t),
|
||||
t.div(vm.i18n`Loading messages…`)
|
||||
t.div(vm.isEncrypted ? vm.i18n`Loading encrypted messages…` : vm.i18n`Loading messages…`)
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
@ -22,12 +22,13 @@ export function renderMessage(t, vm, children) {
|
||||
"TextMessageView": true,
|
||||
own: vm.isOwn,
|
||||
pending: vm.isPending,
|
||||
unverified: vm.isUnverified,
|
||||
continuation: vm => vm.isContinuation,
|
||||
};
|
||||
|
||||
const profile = t.div({className: "profile"}, [
|
||||
renderAvatar(t, vm, 30),
|
||||
t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.sender)
|
||||
t.div({className: `sender usercolor${vm.avatarColorNumber}`}, vm.displayName)
|
||||
]);
|
||||
children = [profile].concat(children);
|
||||
return t.li(
|
||||
|
@ -5,7 +5,7 @@
|
||||
<link rel="stylesheet" type="text/css" href="css/main.css">
|
||||
<link rel="stylesheet" type="text/css" href="css/themes/element/theme.css">
|
||||
</head>
|
||||
<body>
|
||||
<body class="not-ie11">
|
||||
<script type="text/javascript">
|
||||
function vm(o) {
|
||||
// fake EventEmitter
|
||||
|
@ -28,7 +28,11 @@ export class Disposables {
|
||||
}
|
||||
|
||||
track(disposable) {
|
||||
if (this.isDisposed) {
|
||||
throw new Error("Already disposed, check isDisposed after await if needed");
|
||||
}
|
||||
this._disposables.push(disposable);
|
||||
return disposable;
|
||||
}
|
||||
|
||||
dispose() {
|
||||
@ -40,8 +44,12 @@ export class Disposables {
|
||||
}
|
||||
}
|
||||
|
||||
get isDisposed() {
|
||||
return this._disposables === null;
|
||||
}
|
||||
|
||||
disposeTracked(value) {
|
||||
if (value === undefined || value === null) {
|
||||
if (value === undefined || value === null || this.isDisposed) {
|
||||
return null;
|
||||
}
|
||||
const idx = this._disposables.indexOf(value);
|
||||
|
86
src/utils/Lock.js
Normal file
86
src/utils/Lock.js
Normal 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
93
src/utils/LockMap.js
Normal 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
211
src/utils/WorkerPool.js
Normal 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
35
src/utils/groupBy.js
Normal 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
41
src/utils/mergeMap.js
Normal 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
23
src/worker-polyfill.js
Normal 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
156
src/worker.js
Normal 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());
|
@ -907,6 +907,11 @@
|
||||
dependencies:
|
||||
"@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:
|
||||
version "3.2.1"
|
||||
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"
|
||||
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:
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
|
||||
|
Loading…
Reference in New Issue
Block a user