diff --git a/package.json b/package.json index 3935c174..a4f4c082 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "scripts": { "test": "node_modules/.bin/impunity --entry-point src/main.js --force-esm", "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", @@ -46,6 +47,7 @@ "xxhash": "^0.3.0" }, "dependencies": { + "another-json": "^0.2.0", "olm": "https://packages.matrix.org/npm/olm/olm-3.1.4.tgz" } } diff --git a/scripts/post-install.mjs b/scripts/post-install.mjs new file mode 100644 index 00000000..8c00d2a0 --- /dev/null +++ b/scripts/post-install.mjs @@ -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 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 + +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 transpile() { + await fs.mkdir(path.join(projectDir, "lib/another-json/")); + await commonjsToESM( + path.join(projectDir, 'node_modules/another-json/another-json.js'), + path.join(projectDir, "lib/another-json/index.js") + ); +} + +transpile(); diff --git a/src/matrix/Session.js b/src/matrix/Session.js index 90516c5f..f5d9021c 100644 --- a/src/matrix/Session.js +++ b/src/matrix/Session.js @@ -18,6 +18,8 @@ import {Room} from "./room/Room.js"; import { ObservableMap } from "../observable/index.js"; import { SendScheduler, RateLimitingBackoff } from "./SendScheduler.js"; import {User} from "./User.js"; +import {Account as E2EEAccount} from "./e2ee/Account.js"; +const PICKLE_KEY = "DEFAULT_KEY"; export class Session { // sessionInfo contains deviceId, userId and homeServer @@ -31,6 +33,35 @@ export class Session { this._roomUpdateCallback = (room, params) => this._rooms.update(room.id, params); this._user = new User(sessionInfo.userId); this._olm = olm; + this._e2eeAccount = null; + } + + 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) { + const txn = await this._storage.readWriteTxn([ + this._storage.storeNames.session + ]); + try { + this._e2eeAccount = await E2EEAccount.create({ + hsApi: this._hsApi, + olm: this._olm, + pickleKey: PICKLE_KEY, + userId: this._sessionInfo.userId, + deviceId: this._sessionInfo.deviceId, + txn + }); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } + await this._e2eeAccount.uploadKeys(this._storage); + } } async load() { @@ -44,6 +75,17 @@ 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, + txn + }); + } const pendingEventsByRoomId = await this._getPendingEventsByRoom(txn); // load rooms const rooms = await txn.roomSummary.getAll(); diff --git a/src/matrix/SessionContainer.js b/src/matrix/SessionContainer.js index eb025ec6..ae9572a9 100644 --- a/src/matrix/SessionContainer.js +++ b/src/matrix/SessionContainer.js @@ -74,7 +74,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); @@ -121,14 +121,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, @@ -153,6 +153,7 @@ export class SessionContainer { const olm = await this._olmPromise; this._session = new Session({storage: this._storage, sessionInfo: filteredSessionInfo, hsApi, olm}); await this._session.load(); + await this._session.beforeFirstSync(isNewLogin); this._sync = new Sync({hsApi, storage: this._storage, session: this._session}); // notify sync and session when back online diff --git a/src/matrix/e2ee/Account.js b/src/matrix/e2ee/Account.js new file mode 100644 index 00000000..ef342d49 --- /dev/null +++ b/src/matrix/e2ee/Account.js @@ -0,0 +1,140 @@ +/* +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"; + +const ACCOUNT_SESSION_KEY = "olmAccount"; +const DEVICE_KEY_FLAG_SESSION_KEY = "areDeviceKeysUploaded"; + +export class Account { + static async load({olm, pickleKey, hsApi, userId, deviceId, 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); + return new Account({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded}); + } + } + + static async create({olm, pickleKey, hsApi, userId, deviceId, txn}) { + const account = new olm.Account(); + account.create(); + account.generate_one_time_keys(account.max_number_of_one_time_keys()); + const pickledAccount = account.pickle(pickleKey); + // add will throw if the key already exists + // we would not want to overwrite olmAccount here + const areDeviceKeysUploaded = false; + await txn.session.add(ACCOUNT_SESSION_KEY, pickledAccount); + await txn.session.add(DEVICE_KEY_FLAG_SESSION_KEY, areDeviceKeysUploaded); + return new Account({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded}); + } + + constructor({pickleKey, hsApi, account, userId, deviceId, areDeviceKeysUploaded}) { + this._pickleKey = pickleKey; + this._hsApi = hsApi; + this._account = account; + this._userId = userId; + this._deviceId = deviceId; + this._areDeviceKeysUploaded = areDeviceKeysUploaded; + } + + 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); + } + await this._hsApi.uploadKeys(payload); + + await this._updateSessionStorage(storage, sessionStore => { + if (oneTimeKeysEntries.length) { + this._account.mark_keys_as_published(); + sessionStore.set(ACCOUNT_SESSION_KEY, this._account.pickle(this._pickleKey)); + } + if (!this._areDeviceKeysUploaded) { + this._areDeviceKeysUploaded = true; + sessionStore.set(DEVICE_KEY_FLAG_SESSION_KEY, this._areDeviceKeysUploaded); + } + }); + } + } + + _deviceKeysPayload(identityKeys) { + const obj = { + user_id: this._userId, + device_id: this._deviceId, + algorithms: [ + "m.olm.v1.curve25519-aes-sha2", + "m.megolm.v1.aes-sha2" + ], + 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 { + callback(txn.session); + } catch (err) { + txn.abort(); + throw err; + } + await txn.complete(); + } + + signObject(obj) { + const sigs = obj.signatures || {}; + const unsigned = obj.unsigned; + + delete obj.signatures; + delete obj.unsigned; + + sigs[this._userId] = sigs[this._userId] || {}; + sigs[this._userId]["ed25519:" + this._deviceId] = + this._account.sign(anotherjson.stringify(obj)); + obj.signatures = sigs; + if (unsigned !== undefined) { + obj.unsigned = unsigned; + } + } +} diff --git a/src/matrix/net/HomeServerApi.js b/src/matrix/net/HomeServerApi.js index 234c8bc3..ac55f0a5 100644 --- a/src/matrix/net/HomeServerApi.js +++ b/src/matrix/net/HomeServerApi.js @@ -160,6 +160,10 @@ 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); + } + get mediaRepository() { return this._mediaRepository; } diff --git a/src/matrix/storage/idb/stores/SessionStore.js b/src/matrix/storage/idb/stores/SessionStore.js index 2e74b9df..f64a8299 100644 --- a/src/matrix/storage/idb/stores/SessionStore.js +++ b/src/matrix/storage/idb/stores/SessionStore.js @@ -45,4 +45,8 @@ export class SessionStore { set(key, value) { return this._sessionStore.put({key, value}); } + + add(key, value) { + return this._sessionStore.put({key, value}); + } } diff --git a/yarn.lock b/yarn.lock index 9e556624..ea3c6ba5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"