diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..c9d11f02c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,3 @@ + + + diff --git a/CHANGELOG.md b/CHANGELOG.md index 349967cbd..a7baac3a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,81 @@ +Changes in [12.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1) (2021-07-05) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.1-rc.1...v12.0.1) + + * No changes from rc.1 + +Changes in [12.0.1-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.1-rc.1) (2021-06-29) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0...v12.0.1-rc.1) + + * Fix broken /messages filtering due to internal field changes in + FilterComponent + [\#1759](https://github.com/matrix-org/matrix-js-sdk/pull/1759) + * Convert crypto index to TS + [\#1749](https://github.com/matrix-org/matrix-js-sdk/pull/1749) + * Fix typescript return types for membership update events + [\#1739](https://github.com/matrix-org/matrix-js-sdk/pull/1739) + * Fix types of MatrixEvent sender & target + [\#1753](https://github.com/matrix-org/matrix-js-sdk/pull/1753) + * Add keysharing on invites to File Tree Spaces + [\#1744](https://github.com/matrix-org/matrix-js-sdk/pull/1744) + * Convert Room and RoomState to Typescript + [\#1746](https://github.com/matrix-org/matrix-js-sdk/pull/1746) + * Improve type of IContent msgtype + [\#1752](https://github.com/matrix-org/matrix-js-sdk/pull/1752) + * Add PR template + [\#1747](https://github.com/matrix-org/matrix-js-sdk/pull/1747) + * Add functions to assist in immutability of Event objects + [\#1738](https://github.com/matrix-org/matrix-js-sdk/pull/1738) + * Convert Event Context to TS + [\#1742](https://github.com/matrix-org/matrix-js-sdk/pull/1742) + * Bump lodash from 4.17.20 to 4.17.21 + [\#1743](https://github.com/matrix-org/matrix-js-sdk/pull/1743) + * Add invite retries to file trees + [\#1740](https://github.com/matrix-org/matrix-js-sdk/pull/1740) + * Convert IndexedDBStore to TS + [\#1741](https://github.com/matrix-org/matrix-js-sdk/pull/1741) + * Convert additional files to typescript + [\#1736](https://github.com/matrix-org/matrix-js-sdk/pull/1736) + +Changes in [12.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0) (2021-06-21) +================================================================================================== +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v12.0.0-rc.1...v12.0.0) + + * No changes since rc.1 + +Changes in [12.0.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v12.0.0-rc.1) (2021-06-15) +============================================================================================================ +[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0...v12.0.0-rc.1) + + * Rework how disambiguation is handled + [\#1730](https://github.com/matrix-org/matrix-js-sdk/pull/1730) + * Fix baseToString for n=0 edge case to match inverse stringToBase + [\#1735](https://github.com/matrix-org/matrix-js-sdk/pull/1735) + * Move various types from the react-sdk to the js-sdk + [\#1734](https://github.com/matrix-org/matrix-js-sdk/pull/1734) + * Unstable implementation of MSC3089: File Trees + [\#1732](https://github.com/matrix-org/matrix-js-sdk/pull/1732) + * Add MSC3230 event type to enum + [\#1729](https://github.com/matrix-org/matrix-js-sdk/pull/1729) + * Add separate reason code for transferred calls + [\#1731](https://github.com/matrix-org/matrix-js-sdk/pull/1731) + * Use sendonly for call hold + [\#1728](https://github.com/matrix-org/matrix-js-sdk/pull/1728) + * Stop breeding sync listeners + [\#1727](https://github.com/matrix-org/matrix-js-sdk/pull/1727) + * Fix semicolons in TS files + [\#1724](https://github.com/matrix-org/matrix-js-sdk/pull/1724) + * [BREAKING] Convert MatrixClient to TypeScript + [\#1718](https://github.com/matrix-org/matrix-js-sdk/pull/1718) + * Factor out backup management to a separate module + [\#1697](https://github.com/matrix-org/matrix-js-sdk/pull/1697) + * Ignore power_levels events with unknown state_key on room-state + initialization + [\#1723](https://github.com/matrix-org/matrix-js-sdk/pull/1723) + * Revert 1579 (Fix extra negotiate message in Firefox) + [\#1725](https://github.com/matrix-org/matrix-js-sdk/pull/1725) + Changes in [11.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v11.2.0) (2021-06-07) ================================================================================================== [Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.2.0-rc.1...v11.2.0) diff --git a/package.json b/package.json index f3c06fb6b..b1c92efe6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "matrix-js-sdk", - "version": "11.2.0", + "version": "12.0.1", "description": "Matrix Client-Server SDK for Javascript", "scripts": { "prepublishOnly": "yarn build", @@ -55,6 +55,7 @@ "bs58": "^4.0.1", "content-type": "^1.0.4", "loglevel": "^1.7.1", + "p-retry": "^4.5.0", "qs": "^6.9.6", "request": "^2.88.2", "unhomoglyph": "^1.0.6" @@ -73,6 +74,7 @@ "@babel/preset-typescript": "^7.12.7", "@babel/register": "^7.12.10", "@matrix-org/olm": "https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.3.tgz", + "@types/bs58": "^4.0.1", "@types/jest": "^26.0.20", "@types/node": "12", "@types/request": "^2.48.5", diff --git a/release.sh b/release.sh index 7ee2777da..e2ef78263 100755 --- a/release.sh +++ b/release.sh @@ -100,7 +100,7 @@ fi # global cache here to ensure we get the right thing. yarn cache clean # Ensure all dependencies are updated -yarn install --ignore-scripts +yarn install --ignore-scripts --pure-lockfile if [ -z "$skip_changelog" ]; then # update_changelog doesn't have a --version flag @@ -225,7 +225,7 @@ if [ $dodist -eq 0 ]; then pushd "$builddir" git clone "$projdir" . git checkout "$rel_branch" - yarn install + yarn install --pure-lockfile # We haven't tagged yet, so tell the dist script what version # it's building DIST_VERSION="$tag" yarn dist diff --git a/spec/MockBlob.ts b/spec/MockBlob.ts new file mode 100644 index 000000000..04d01c24e --- /dev/null +++ b/spec/MockBlob.ts @@ -0,0 +1,27 @@ +/* +Copyright 2021 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 MockBlob { + private contents: number[] = []; + + public constructor(private parts: ArrayLike[]) { + parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e))); + } + + public get size(): number { + return this.contents.length; + } +} diff --git a/spec/integ/devicelist-integ-spec.js b/spec/integ/devicelist-integ-spec.js index cdad8a905..2ca459119 100644 --- a/spec/integ/devicelist-integ-spec.js +++ b/spec/integ/devicelist-integ-spec.js @@ -165,7 +165,7 @@ describe("DeviceList management:", function() { aliceTestClient.httpBackend.flush('/keys/query', 1).then( () => aliceTestClient.httpBackend.flush('/send/', 1), ), - aliceTestClient.client.crypto._deviceList.saveIfDirty(), + aliceTestClient.client.crypto.deviceList.saveIfDirty(), ]); }).then(() => { aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { @@ -202,7 +202,7 @@ describe("DeviceList management:", function() { return aliceTestClient.httpBackend.flush('/keys/query', 1); }).then((flushed) => { expect(flushed).toEqual(0); - return aliceTestClient.client.crypto._deviceList.saveIfDirty(); + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -235,7 +235,7 @@ describe("DeviceList management:", function() { // wait for the client to stop processing the response return aliceTestClient.client.downloadKeys(['@bob:xyz']); }).then(() => { - return aliceTestClient.client.crypto._deviceList.saveIfDirty(); + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -256,7 +256,7 @@ describe("DeviceList management:", function() { // wait for the client to stop processing the response return aliceTestClient.client.downloadKeys(['@chris:abc']); }).then(() => { - return aliceTestClient.client.crypto._deviceList.saveIfDirty(); + return aliceTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -286,7 +286,7 @@ describe("DeviceList management:", function() { }, ); await aliceTestClient.httpBackend.flush('/keys/query', 1); - await aliceTestClient.client.crypto._deviceList.saveIfDirty(); + await aliceTestClient.client.crypto.deviceList.saveIfDirty(); aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -322,7 +322,7 @@ describe("DeviceList management:", function() { ); await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto._deviceList.saveIfDirty(); + await aliceTestClient.client.crypto.deviceList.saveIfDirty(); aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -358,7 +358,7 @@ describe("DeviceList management:", function() { ); await aliceTestClient.flushSync(); - await aliceTestClient.client.crypto._deviceList.saveIfDirty(); + await aliceTestClient.client.crypto.deviceList.saveIfDirty(); aliceTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; @@ -379,7 +379,7 @@ describe("DeviceList management:", function() { anotherTestClient.httpBackend.when('GET', '/sync').respond( 200, getSyncResponse([])); await anotherTestClient.flushSync(); - await anotherTestClient.client.crypto._deviceList.saveIfDirty(); + await anotherTestClient.client.crypto.deviceList.saveIfDirty(); anotherTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const bobStat = data.trackingStatus['@bob:xyz']; diff --git a/spec/integ/matrix-client-crypto.spec.js b/spec/integ/matrix-client-crypto.spec.js index 6bb1a494b..eb87c5193 100644 --- a/spec/integ/matrix-client-crypto.spec.js +++ b/spec/integ/matrix-client-crypto.spec.js @@ -159,7 +159,7 @@ function aliDownloadsKeys() { // check that the localStorage is updated as we expect (not sure this is // an integration test, but meh) return Promise.all([p1, p2]).then(() => { - return aliTestClient.client.crypto._deviceList.saveIfDirty(); + return aliTestClient.client.crypto.deviceList.saveIfDirty(); }).then(() => { aliTestClient.cryptoStore.getEndToEndDeviceData(null, (data) => { const devices = data.devices[bobUserId]; diff --git a/spec/integ/matrix-client-methods.spec.js b/spec/integ/matrix-client-methods.spec.js index b133b456e..acf353970 100644 --- a/spec/integ/matrix-client-methods.spec.js +++ b/spec/integ/matrix-client-methods.spec.js @@ -336,7 +336,7 @@ describe("MatrixClient", function() { var b = JSON.parse(JSON.stringify(o)); delete(b.signatures); delete(b.unsigned); - return client.crypto._olmDevice.sign(anotherjson.stringify(b)); + return client.crypto.olmDevice.sign(anotherjson.stringify(b)); }; logger.log("Ed25519: " + ed25519key); diff --git a/spec/integ/megolm-integ.spec.js b/spec/integ/megolm-integ.spec.js index 513043410..e2bc34c25 100644 --- a/spec/integ/megolm-integ.spec.js +++ b/spec/integ/megolm-integ.spec.js @@ -1012,8 +1012,8 @@ describe("megolm", function() { }, event: true, }); - event._senderCurve25519Key = testSenderKey; - return testClient.client.crypto._onRoomKeyEvent(event); + event.senderCurve25519Key = testSenderKey; + return testClient.client.crypto.onRoomKeyEvent(event); }).then(() => { const event = testUtils.mkEvent({ event: true, diff --git a/spec/test-utils.js b/spec/test-utils.js index dcde2d6fe..47b2624f6 100644 --- a/spec/test-utils.js +++ b/spec/test-utils.js @@ -51,7 +51,7 @@ export function mock(constr, name) { result.toString = function() { return "mock" + (name ? " of " + name : ""); }; - for (const key in constr.prototype) { // eslint-disable-line guard-for-in + for (const key of Object.getOwnPropertyNames(constr.prototype)) { // eslint-disable-line guard-for-in try { if (constr.prototype[key] instanceof Function) { result[key] = jest.fn(); diff --git a/spec/unit/NamespacedValue.spec.ts b/spec/unit/NamespacedValue.spec.ts new file mode 100644 index 000000000..834acd0c9 --- /dev/null +++ b/spec/unit/NamespacedValue.spec.ts @@ -0,0 +1,78 @@ +/* +Copyright 2021 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 { NamespacedValue, UnstableValue } from "../../src/NamespacedValue"; + +describe("NamespacedValue", () => { + it("should prefer stable over unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBe(ns.unstable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new NamespacedValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should have a falsey unstable if needed", () => { + const ns = new NamespacedValue("stable", null); + expect(ns.name).toBe(ns.stable); + expect(ns.altName).toBeFalsy(); + }); + + it("should match against either stable or unstable", () => { + const ns = new NamespacedValue("stable", "unstable"); + expect(ns.matches("no")).toBe(false); + expect(ns.matches(ns.stable)).toBe(true); + expect(ns.matches(ns.unstable)).toBe(true); + }); + + it("should not permit falsey values for both parts", () => { + try { + new UnstableValue(null, null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("One of stable or unstable values must be supplied"); + } + }); +}); + +describe("UnstableValue", () => { + it("should prefer unstable over stable", () => { + const ns = new UnstableValue("stable", "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBe(ns.stable); + }); + + it("should return unstable if there is no stable", () => { + const ns = new UnstableValue(null, "unstable"); + expect(ns.name).toBe(ns.unstable); + expect(ns.altName).toBeFalsy(); + }); + + it("should not permit falsey unstable values", () => { + try { + new UnstableValue("stable", null); + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toBe("Unstable value must be supplied"); + } + }); +}); diff --git a/spec/unit/crypto.spec.js b/spec/unit/crypto.spec.js index d1e707fd3..816b952b1 100644 --- a/spec/unit/crypto.spec.js +++ b/spec/unit/crypto.spec.js @@ -65,7 +65,7 @@ describe("Crypto", function() { 'YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI'; device.keys["ed25519:FLIBBLE"] = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'; - client.crypto._deviceList.getDeviceByIdentityKey = () => device; + client.crypto.deviceList.getDeviceByIdentityKey = () => device; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); @@ -213,7 +213,7 @@ describe("Crypto", function() { async function keyshareEventForEvent(event, index) { const eventContent = event.getWireContent(); - const key = await aliceClient.crypto._olmDevice + const key = await aliceClient.crypto.olmDevice .getInboundGroupSessionKey( roomId, eventContent.sender_key, eventContent.session_id, index, @@ -234,7 +234,7 @@ describe("Crypto", function() { }, }); // make onRoomKeyEvent think this was an encrypted event - ksEvent._senderCurve25519Key = "akey"; + ksEvent.senderCurve25519Key = "akey"; return ksEvent; } @@ -274,9 +274,9 @@ describe("Crypto", function() { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto.encryptEvent(event, aliceRoom); - event._clearEvent = {}; - event._senderCurve25519Key = null; - event._claimedEd25519Key = null; + event.clearEvent = {}; + event.senderCurve25519Key = null; + event.claimedEd25519Key = null; try { await bobClient.crypto.decryptEvent(event); } catch (e) { @@ -285,7 +285,7 @@ describe("Crypto", function() { } })); - const bobDecryptor = bobClient.crypto._getRoomDecryptor( + const bobDecryptor = bobClient.crypto.getRoomDecryptor( roomId, olmlib.MEGOLM_ALGORITHM, ); @@ -377,7 +377,7 @@ describe("Crypto", function() { // key requests get queued until the sync has finished, but we don't // let the client set up enough for that to happen, so gut-wrench a bit // to force it to send now. - aliceClient.crypto._outgoingRoomKeyRequestManager.sendQueuedRequests(); + aliceClient.crypto.outgoingRoomKeyRequestManager.sendQueuedRequests(); jest.runAllTimers(); await Promise.resolve(); expect(aliceClient.sendToDevice).toBeCalledTimes(1); diff --git a/spec/unit/crypto/algorithms/megolm.spec.js b/spec/unit/crypto/algorithms/megolm.spec.js index 8b56b93b3..e7d63c6a2 100644 --- a/spec/unit/crypto/algorithms/megolm.spec.js +++ b/spec/unit/crypto/algorithms/megolm.spec.js @@ -257,7 +257,7 @@ describe("MegolmDecryption", function() { }); it("re-uses sessions for sequential messages", async function() { - mockCrypto._backupManager = { + mockCrypto.backupManager = { backupGroupSession: () => {}, }; const mockStorage = new MockStorageApi(); @@ -365,9 +365,9 @@ describe("MegolmDecryption", function() { bobClient1.initCrypto(), bobClient2.initCrypto(), ]); - const aliceDevice = aliceClient.crypto._olmDevice; - const bobDevice1 = bobClient1.crypto._olmDevice; - const bobDevice2 = bobClient2.crypto._olmDevice; + const aliceDevice = aliceClient.crypto.olmDevice; + const bobDevice1 = bobClient1.crypto.olmDevice; + const bobDevice2 = bobClient2.crypto.olmDevice; const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", @@ -404,11 +404,11 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto._deviceList.storeDevicesForUser( + aliceClient.crypto.deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); - aliceClient.crypto._deviceList.downloadKeys = async function(userIds) { - return this._getDevicesFromStore(userIds); + aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { + return this.getDevicesFromStore(userIds); }; let run = false; @@ -468,8 +468,8 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const aliceDevice = aliceClient.crypto._olmDevice; - const bobDevice = bobClient.crypto._olmDevice; + const aliceDevice = aliceClient.crypto.olmDevice; + const bobDevice = bobClient.crypto.olmDevice; const encryptionCfg = { "algorithm": "m.megolm.v1.aes-sha2", @@ -508,11 +508,11 @@ describe("MegolmDecryption", function() { }, }; - aliceClient.crypto._deviceList.storeDevicesForUser( + aliceClient.crypto.deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); - aliceClient.crypto._deviceList.downloadKeys = async function(userIds) { - return this._getDevicesFromStore(userIds); + aliceClient.crypto.deviceList.downloadKeys = async function(userIds) { + return this.getDevicesFromStore(userIds); }; aliceClient.claimOneTimeKeys = async () => { @@ -561,11 +561,11 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const bobDevice = bobClient.crypto._olmDevice; + const bobDevice = bobClient.crypto.olmDevice; const roomId = "!someroom"; - aliceClient.crypto._onToDeviceEvent(new MatrixEvent({ + aliceClient.crypto.onToDeviceEvent(new MatrixEvent({ type: "org.matrix.room_key.withheld", sender: "@bob:example.com", content: { @@ -605,13 +605,13 @@ describe("MegolmDecryption", function() { bobClient.initCrypto(), ]); aliceClient.crypto.downloadKeys = async () => {}; - const bobDevice = bobClient.crypto._olmDevice; + const bobDevice = bobClient.crypto.olmDevice; const roomId = "!someroom"; const now = Date.now(); - aliceClient.crypto._onToDeviceEvent(new MatrixEvent({ + aliceClient.crypto.onToDeviceEvent(new MatrixEvent({ type: "org.matrix.room_key.withheld", sender: "@bob:example.com", content: { @@ -655,7 +655,7 @@ describe("MegolmDecryption", function() { aliceClient.initCrypto(), bobClient.initCrypto(), ]); - const bobDevice = bobClient.crypto._olmDevice; + const bobDevice = bobClient.crypto.olmDevice; aliceClient.crypto.downloadKeys = async () => {}; const roomId = "!someroom"; @@ -663,7 +663,7 @@ describe("MegolmDecryption", function() { const now = Date.now(); // pretend we got an event that we can't decrypt - aliceClient.crypto._onToDeviceEvent(new MatrixEvent({ + aliceClient.crypto.onToDeviceEvent(new MatrixEvent({ type: "m.room.encrypted", sender: "@bob:example.com", content: { diff --git a/spec/unit/crypto/backup.spec.js b/spec/unit/crypto/backup.spec.js index ea03b6e5c..7f122c2bb 100644 --- a/spec/unit/crypto/backup.spec.js +++ b/spec/unit/crypto/backup.spec.js @@ -166,7 +166,7 @@ describe("MegolmBackup", function() { let megolmDecryption; beforeEach(async function() { mockCrypto = testUtils.mock(Crypto, 'Crypto'); - mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager"); + mockCrypto.backupManager = testUtils.mock(BackupManager, "BackupManager"); mockCrypto.backupKey = new Olm.PkEncryption(); mockCrypto.backupKey.set_recipient_key( "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo", @@ -244,14 +244,14 @@ describe("MegolmBackup", function() { }; mockCrypto.cancelRoomKeyRequest = function() {}; - mockCrypto._backupManager = { + mockCrypto.backupManager = { backupGroupSession: jest.fn(), }; return event.attemptDecryption(mockCrypto).then(() => { return megolmDecryption.onRoomKeyEvent(event); }).then(() => { - expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled(); + expect(mockCrypto.backupManager.backupGroupSession).toHaveBeenCalled(); }); }); @@ -323,7 +323,7 @@ describe("MegolmBackup", function() { resolve(); return Promise.resolve({}); }; - client.crypto._backupManager.backupGroupSession( + client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", groupSession.session_id(), ); @@ -587,7 +587,7 @@ describe("MegolmBackup", function() { ); } }; - client.crypto._backupManager.backupGroupSession( + client.crypto.backupManager.backupGroupSession( "F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI", groupSession.session_id(), ); diff --git a/spec/unit/crypto/cross-signing.spec.js b/spec/unit/crypto/cross-signing.spec.js index 56b86b26b..8638c1f4d 100644 --- a/spec/unit/crypto/cross-signing.spec.js +++ b/spec/unit/crypto/cross-signing.spec.js @@ -64,8 +64,8 @@ describe("Cross Signing", function() { ); alice.uploadDeviceSigningKeys = jest.fn(async (auth, keys) => { await olmlib.verifySignature( - alice.crypto._olmDevice, keys.master_key, "@alice:example.com", - "Osborne2", alice.crypto._olmDevice.deviceEd25519Key, + alice.crypto.olmDevice, keys.master_key, "@alice:example.com", + "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); }); alice.uploadKeySignatures = async () => {}; @@ -138,7 +138,7 @@ describe("Cross Signing", function() { // set Alice's cross-signing key await resetCrossSigningKeys(alice); // Alice downloads Bob's device key - alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", @@ -203,12 +203,12 @@ describe("Cross Signing", function() { alice.uploadKeySignatures = jest.fn(async (content) => { try { await olmlib.verifySignature( - alice.crypto._olmDevice, + alice.crypto.olmDevice, content["@alice:example.com"][ "nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk" ], "@alice:example.com", - "Osborne2", alice.crypto._olmDevice.deviceEd25519Key, + "Osborne2", alice.crypto.olmDevice.deviceEd25519Key, ); olmlib.pkVerify( content["@alice:example.com"]["Osborne2"], @@ -222,7 +222,7 @@ describe("Cross Signing", function() { }); }); - const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"] + const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", @@ -230,7 +230,7 @@ describe("Cross Signing", function() { }; aliceDevice.keys = deviceInfo.keys; aliceDevice.algorithms = deviceInfo.algorithms; - await alice.crypto._signObject(aliceDevice); + await alice.crypto.signObject(aliceDevice); olmlib.pkSign(aliceDevice, selfSigningKey, "@alice:example.com"); // feed sync result that includes master key, ssk, device key @@ -358,7 +358,7 @@ describe("Cross Signing", function() { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; - alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", @@ -387,7 +387,7 @@ describe("Cross Signing", function() { ["ed25519:" + bobPubkey]: sig, }, }; - alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", { + alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Bob's device key should be TOFU @@ -421,8 +421,8 @@ describe("Cross Signing", function() { null, aliceKeys, ); - alice.crypto._deviceList.startTrackingDeviceList("@bob:example.com"); - alice.crypto._deviceList.stopTrackingAllDeviceLists = () => {}; + alice.crypto.deviceList.startTrackingDeviceList("@bob:example.com"); + alice.crypto.deviceList.stopTrackingAllDeviceLists = () => {}; alice.uploadDeviceSigningKeys = async () => {}; alice.uploadKeySignatures = async () => {}; @@ -437,14 +437,14 @@ describe("Cross Signing", function() { ]); const keyChangePromise = new Promise((resolve, reject) => { - alice.crypto._deviceList.once("userCrossSigningUpdated", (userId) => { + alice.crypto.deviceList.once("userCrossSigningUpdated", (userId) => { if (userId === "@bob:example.com") { resolve(); } }); }); - const deviceInfo = alice.crypto._deviceList._devices["@alice:example.com"] + const deviceInfo = alice.crypto.deviceList.devices["@alice:example.com"] .Osborne2; const aliceDevice = { user_id: "@alice:example.com", @@ -452,7 +452,7 @@ describe("Cross Signing", function() { }; aliceDevice.keys = deviceInfo.keys; aliceDevice.algorithms = deviceInfo.algorithms; - await alice.crypto._signObject(aliceDevice); + await alice.crypto.signObject(aliceDevice); const bobOlmAccount = new global.Olm.Account(); bobOlmAccount.create(); @@ -606,7 +606,7 @@ describe("Cross Signing", function() { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; - alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", @@ -629,7 +629,7 @@ describe("Cross Signing", function() { "ed25519:Dynabook": "someOtherPubkey", }, }; - alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", { + alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Bob's device key should be untrusted @@ -673,7 +673,7 @@ describe("Cross Signing", function() { ["ed25519:" + bobMasterPubkey]: sskSig, }, }; - alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", @@ -701,7 +701,7 @@ describe("Cross Signing", function() { bobDevice.signatures = {}; bobDevice.signatures["@bob:example.com"] = {}; bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey] = sig; - alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", { + alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); // Alice verifies Bob's SSK @@ -733,7 +733,7 @@ describe("Cross Signing", function() { ["ed25519:" + bobMasterPubkey2]: sskSig2, }, }; - alice.crypto._deviceList.storeCrossSigningForUser("@bob:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@bob:example.com", { keys: { master: { user_id: "@bob:example.com", @@ -770,7 +770,7 @@ describe("Cross Signing", function() { // Alice gets new signature for device const sig2 = bobSigning2.sign(bobDeviceString); bobDevice.signatures["@bob:example.com"]["ed25519:" + bobPubkey2] = sig2; - alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", { + alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: bobDevice, }); @@ -805,20 +805,20 @@ describe("Cross Signing", function() { bob.uploadKeySignatures = async () => {}; // set Bob's cross-signing key await resetCrossSigningKeys(bob); - alice.crypto._deviceList.storeDevicesForUser("@bob:example.com", { + alice.crypto.deviceList.storeDevicesForUser("@bob:example.com", { Dynabook: { algorithms: ["m.olm.curve25519-aes-sha256", "m.megolm.v1.aes-sha"], keys: { - "curve25519:Dynabook": bob.crypto._olmDevice.deviceCurve25519Key, - "ed25519:Dynabook": bob.crypto._olmDevice.deviceEd25519Key, + "curve25519:Dynabook": bob.crypto.olmDevice.deviceCurve25519Key, + "ed25519:Dynabook": bob.crypto.olmDevice.deviceEd25519Key, }, verified: 1, known: true, }, }); - alice.crypto._deviceList.storeCrossSigningForUser( + alice.crypto.deviceList.storeCrossSigningForUser( "@bob:example.com", - bob.crypto._crossSigningInfo.toStorage(), + bob.crypto.crossSigningInfo.toStorage(), ); alice.uploadDeviceSigningKeys = async () => {}; @@ -838,7 +838,7 @@ describe("Cross Signing", function() { expect(bobTrust.isTofu()).toBeTruthy(); // "forget" that Bob is trusted - delete alice.crypto._deviceList._crossSigningInfo["@bob:example.com"] + delete alice.crypto.deviceList.crossSigningInfo["@bob:example.com"] .keys.master.signatures["@alice:example.com"]; const bobTrust2 = alice.checkUserTrust("@bob:example.com"); @@ -848,7 +848,7 @@ describe("Cross Signing", function() { upgradePromise = new Promise((resolve) => { upgradeResolveFunc = resolve; }); - alice.crypto._deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); + alice.crypto.deviceList.emit("userCrossSigningUpdated", "@bob:example.com"); await new Promise((resolve) => { alice.crypto.on("userTrustStatusChanged", resolve); }); diff --git a/spec/unit/crypto/crypto-utils.js b/spec/unit/crypto/crypto-utils.js index dcc9db16a..b54b1a18e 100644 --- a/spec/unit/crypto/crypto-utils.js +++ b/spec/unit/crypto/crypto-utils.js @@ -8,26 +8,26 @@ export async function resetCrossSigningKeys(client, { } = {}) { const crypto = client.crypto; - const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys); + const oldKeys = Object.assign({}, crypto.crossSigningInfo.keys); try { - await crypto._crossSigningInfo.resetKeys(level); - await crypto._signObject(crypto._crossSigningInfo.keys.master); + await crypto.crossSigningInfo.resetKeys(level); + await crypto.signObject(crypto.crossSigningInfo.keys.master); // write a copy locally so we know these are trusted keys - await crypto._cryptoStore.doTxn( + await crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto._cryptoStore.storeCrossSigningKeys( - txn, crypto._crossSigningInfo.keys); + crypto.cryptoStore.storeCrossSigningKeys( + txn, crypto.crossSigningInfo.keys); }, ); } catch (e) { // If anything failed here, revert the keys so we know to try again from the start // next time. - crypto._crossSigningInfo.keys = oldKeys; + crypto.crossSigningInfo.keys = oldKeys; throw e; } - crypto._baseApis.emit("crossSigning.keysChanged", {}); - await crypto._afterCrossSigningLocalKeyChange(); + crypto.baseApis.emit("crossSigning.keysChanged", {}); + await crypto.afterCrossSigningLocalKeyChange(); } export async function createSecretStorageKey() { diff --git a/spec/unit/crypto/outgoing-room-key-requests.spec.js b/spec/unit/crypto/outgoing-room-key-requests.spec.js index a1fa62a70..24b9325b4 100644 --- a/spec/unit/crypto/outgoing-room-key-requests.spec.js +++ b/spec/unit/crypto/outgoing-room-key-requests.spec.js @@ -21,25 +21,23 @@ import { MemoryCryptoStore } from '../../../src/crypto/store/memory-crypto-store import 'fake-indexeddb/auto'; import 'jest-localstorage-mock'; -import { - ROOM_KEY_REQUEST_STATES, -} from '../../../src/crypto/OutgoingRoomKeyRequestManager'; +import { RoomKeyRequestState } from '../../../src/crypto/OutgoingRoomKeyRequestManager'; const requests = [ { requestId: "A", requestBody: { session_id: "A", room_id: "A" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "B", requestBody: { session_id: "B", room_id: "B" }, - state: ROOM_KEY_REQUEST_STATES.SENT, + state: RoomKeyRequestState.Sent, }, { requestId: "C", requestBody: { session_id: "C", room_id: "C" }, - state: ROOM_KEY_REQUEST_STATES.UNSENT, + state: RoomKeyRequestState.Unsent, }, ]; @@ -68,9 +66,9 @@ describe.each([ it("getAllOutgoingRoomKeyRequestsByState retrieves all entries in a given state", async () => { const r = await - store.getAllOutgoingRoomKeyRequestsByState(ROOM_KEY_REQUEST_STATES.SENT); + store.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); expect(r).toHaveLength(2); - requests.filter((e) => e.state == ROOM_KEY_REQUEST_STATES.SENT).forEach((e) => { + requests.filter((e) => e.state === RoomKeyRequestState.Sent).forEach((e) => { expect(r).toContainEqual(e); }); }); @@ -78,10 +76,10 @@ describe.each([ test("getOutgoingRoomKeyRequestByState retrieves any entry in a given state", async () => { const r = - await store.getOutgoingRoomKeyRequestByState([ROOM_KEY_REQUEST_STATES.SENT]); + await store.getOutgoingRoomKeyRequestByState([RoomKeyRequestState.Sent]); expect(r).not.toBeNull(); expect(r).not.toBeUndefined(); - expect(r.state).toEqual(ROOM_KEY_REQUEST_STATES.SENT); + expect(r.state).toEqual(RoomKeyRequestState.Sent); expect(requests).toContainEqual(r); }); }); diff --git a/spec/unit/crypto/secrets.spec.js b/spec/unit/crypto/secrets.spec.js index fc82a3259..2a86dfaa1 100644 --- a/spec/unit/crypto/secrets.spec.js +++ b/spec/unit/crypto/secrets.spec.js @@ -99,11 +99,11 @@ describe("Secrets", function() { }, }, ); - alice.crypto._crossSigningInfo.setKeys({ + alice.crypto.crossSigningInfo.setKeys({ master: signingkeyInfo, }); - const secretStorage = alice.crypto._secretStorage; + const secretStorage = alice.crypto.secretStorage; alice.setAccountData = async function(eventType, contents, callback) { alice.store.storeAccountDataEvents([ @@ -120,7 +120,7 @@ describe("Secrets", function() { const keyAccountData = { algorithm: SECRET_STORAGE_ALGORITHM_V1_AES, }; - await alice.crypto._crossSigningInfo.signObject(keyAccountData, 'master'); + await alice.crypto.crossSigningInfo.signObject(keyAccountData, 'master'); alice.store.storeAccountDataEvents([ new MatrixEvent({ @@ -234,11 +234,11 @@ describe("Secrets", function() { }, ); - const vaxDevice = vax.client.crypto._olmDevice; - const osborne2Device = osborne2.client.crypto._olmDevice; - const secretStorage = osborne2.client.crypto._secretStorage; + const vaxDevice = vax.client.crypto.olmDevice; + const osborne2Device = osborne2.client.crypto.olmDevice; + const secretStorage = osborne2.client.crypto.secretStorage; - osborne2.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", { + osborne2.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", { "VAX": { user_id: "@alice:example.com", device_id: "VAX", @@ -249,7 +249,7 @@ describe("Secrets", function() { }, }, }); - vax.client.crypto._deviceList.storeDevicesForUser("@alice:example.com", { + vax.client.crypto.deviceList.storeDevicesForUser("@alice:example.com", { "Osborne2": { user_id: "@alice:example.com", device_id: "Osborne2", @@ -265,7 +265,7 @@ describe("Secrets", function() { const otks = (await osborne2Device.getOneTimeKeys()).curve25519; await osborne2Device.markKeysAsPublished(); - await vax.client.crypto._olmDevice.createOutboundSession( + await vax.client.crypto.olmDevice.createOutboundSession( osborne2Device.deviceCurve25519Key, Object.values(otks)[0], ); @@ -334,8 +334,8 @@ describe("Secrets", function() { createSecretStorageKey, }); - const crossSigning = bob.crypto._crossSigningInfo; - const secretStorage = bob.crypto._secretStorage; + const crossSigning = bob.crypto.crossSigningInfo; + const secretStorage = bob.crypto.secretStorage; expect(crossSigning.getId()).toBeTruthy(); expect(await crossSigning.isStoredInSecretStorage(secretStorage)) @@ -376,10 +376,10 @@ describe("Secrets", function() { ]); this.emit("accountData", event); }; - bob.crypto._backupManager.checkKeyBackup = async () => {}; + bob.crypto.backupManager.checkKeyBackup = async () => {}; - const crossSigning = bob.crypto._crossSigningInfo; - const secretStorage = bob.crypto._secretStorage; + const crossSigning = bob.crypto.crossSigningInfo; + const secretStorage = bob.crypto.secretStorage; // Set up cross-signing keys from scratch with specific storage key await bob.bootstrapCrossSigning({ @@ -394,7 +394,7 @@ describe("Secrets", function() { }); // Clear local cross-signing keys and read from secret storage - bob.crypto._deviceList.storeCrossSigningForUser( + bob.crypto.deviceList.storeCrossSigningForUser( "@bob:example.com", crossSigning.toStorage(), ); @@ -479,7 +479,7 @@ describe("Secrets", function() { }, }), ]); - alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { keys: { master: { user_id: "@alice:example.com", @@ -619,7 +619,7 @@ describe("Secrets", function() { }, }), ]); - alice.crypto._deviceList.storeCrossSigningForUser("@alice:example.com", { + alice.crypto.deviceList.storeCrossSigningForUser("@alice:example.com", { keys: { master: { user_id: "@alice:example.com", diff --git a/spec/unit/crypto/verification/request.spec.js b/spec/unit/crypto/verification/request.spec.js index 11275e6fc..3ad4fe562 100644 --- a/spec/unit/crypto/verification/request.spec.js +++ b/spec/unit/crypto/verification/request.spec.js @@ -49,7 +49,7 @@ describe("verification request integration tests with crypto layer", function() verificationMethods: [verificationMethods.SAS], }, ); - alice.client.crypto._deviceList.getRawStoredDevicesForUser = function() { + alice.client.crypto.deviceList.getRawStoredDevicesForUser = function() { return { Dynabook: { keys: { diff --git a/spec/unit/crypto/verification/sas.spec.js b/spec/unit/crypto/verification/sas.spec.js index 0a643d318..fcb73de29 100644 --- a/spec/unit/crypto/verification/sas.spec.js +++ b/spec/unit/crypto/verification/sas.spec.js @@ -87,8 +87,8 @@ describe("SAS verification", function() { }, ); - const aliceDevice = alice.client.crypto._olmDevice; - const bobDevice = bob.client.crypto._olmDevice; + const aliceDevice = alice.client.crypto.olmDevice; + const bobDevice = bob.client.crypto.olmDevice; ALICE_DEVICES = { Osborne2: { @@ -114,14 +114,14 @@ describe("SAS verification", function() { }, }; - alice.client.crypto._deviceList.storeDevicesForUser( + alice.client.crypto.deviceList.storeDevicesForUser( "@bob:example.com", BOB_DEVICES, ); alice.client.downloadKeys = () => { return Promise.resolve(); }; - bob.client.crypto._deviceList.storeDevicesForUser( + bob.client.crypto.deviceList.storeDevicesForUser( "@alice:example.com", ALICE_DEVICES, ); bob.client.downloadKeys = () => { @@ -296,9 +296,9 @@ describe("SAS verification", function() { await resetCrossSigningKeys(bob.client); - bob.client.crypto._deviceList.storeCrossSigningForUser( + bob.client.crypto.deviceList.storeCrossSigningForUser( "@alice:example.com", { - keys: alice.client.crypto._crossSigningInfo.keys, + keys: alice.client.crypto.crossSigningInfo.keys, }, ); diff --git a/spec/unit/crypto/verification/secret_request.spec.js b/spec/unit/crypto/verification/secret_request.spec.js index 8c9573273..f261afacf 100644 --- a/spec/unit/crypto/verification/secret_request.spec.js +++ b/spec/unit/crypto/verification/secret_request.spec.js @@ -48,18 +48,18 @@ describe("self-verifications", () => { storeCrossSigningKeyCache: jest.fn(), }; - const _crossSigningInfo = new CrossSigningInfo( + const crossSigningInfo = new CrossSigningInfo( userId, {}, cacheCallbacks, ); - _crossSigningInfo.keys = { + crossSigningInfo.keys = { master: { keys: { X: testKeyPub } }, self_signing: { keys: { X: testKeyPub } }, user_signing: { keys: { X: testKeyPub } }, }; - const _secretStorage = { + const secretStorage = { request: jest.fn().mockReturnValue({ promise: Promise.resolve(encodeBase64(testKey)), }), @@ -70,12 +70,12 @@ describe("self-verifications", () => { const client = { crypto: { - _crossSigningInfo, - _secretStorage, + crossSigningInfo, + secretStorage, storeSessionBackupPrivateKey, getSessionBackupPrivateKey: () => null, }, - requestSecret: _secretStorage.request.bind(_secretStorage), + requestSecret: secretStorage.request.bind(secretStorage), getUserId: () => userId, getKeyBackupVersion: () => Promise.resolve({}), restoreKeyBackupWithCache, @@ -99,7 +99,7 @@ describe("self-verifications", () => { /* We should request, and store, 3 cross signing keys and the key backup key */ expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls.length).toBe(3); - expect(_secretStorage.request.mock.calls.length).toBe(4); + expect(secretStorage.request.mock.calls.length).toBe(4); expect(cacheCallbacks.storeCrossSigningKeyCache.mock.calls[0][1]) .toEqual(testKey); diff --git a/spec/unit/event-timeline.spec.js b/spec/unit/event-timeline.spec.js index ed3bfb4d5..f537f39eb 100644 --- a/spec/unit/event-timeline.spec.js +++ b/spec/unit/event-timeline.spec.js @@ -3,8 +3,8 @@ import { EventTimeline } from "../../src/models/event-timeline"; import { RoomState } from "../../src/models/room-state"; function mockRoomStates(timeline) { - timeline._startState = utils.mock(RoomState, "startState"); - timeline._endState = utils.mock(RoomState, "endState"); + timeline.startState = utils.mock(RoomState, "startState"); + timeline.endState = utils.mock(RoomState, "endState"); } describe("EventTimeline", function() { @@ -48,10 +48,10 @@ describe("EventTimeline", function() { }), ]; timeline.initialiseState(events); - expect(timeline._startState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.startState.setStateEvents).toHaveBeenCalledWith( events, ); - expect(timeline._endState.setStateEvents).toHaveBeenCalledWith( + expect(timeline.endState.setStateEvents).toHaveBeenCalledWith( events, ); }); diff --git a/spec/unit/matrix-client.spec.js b/spec/unit/matrix-client.spec.js index 98c6b127e..090ffeed1 100644 --- a/spec/unit/matrix-client.spec.js +++ b/spec/unit/matrix-client.spec.js @@ -1,6 +1,18 @@ import { logger } from "../../src/logger"; import { MatrixClient } from "../../src/client"; import { Filter } from "../../src/filter"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE } from "../../src/models/MSC3089TreeSpace"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE, +} from "../../src/@types/event"; +import { MEGOLM_ALGORITHM } from "../../src/crypto/olmlib"; +import { MatrixEvent } from "../../src/models/event"; +import { Preset } from "../../src/@types/partials"; jest.useFakeTimers(); @@ -171,6 +183,160 @@ describe("MatrixClient", function() { }); }); + it("should create (unstable) file trees", async () => { + const userId = "@test:example.org"; + const roomId = "!room:example.org"; + const roomName = "Test Tree"; + const mockRoom = {}; + const fn = jest.fn().mockImplementation((opts) => { + expect(opts).toMatchObject({ + name: roomName, + preset: Preset.PrivateChat, + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [userId]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + // We use `unstable` to ensure that the code is actually using the right identifier + type: UNSTABLE_MSC3088_PURPOSE.unstable, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.unstable, + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: MEGOLM_ALGORITHM, + }, + }, + ], + }); + return { room_id: roomId }; + }); + client.getUserId = () => userId; + client.createRoom = fn; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = await client.unstableCreateFileTree(roomName); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + expect(fn.mock.calls.length).toBe(1); + }); + + it("should get (unstable) file trees with valid state", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeDefined(); + expect(tree.roomId).toEqual(roomId); + expect(tree.room).toBe(mockRoom); + }); + + it("should not get (unstable) file trees with invalid create contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: "org.example.not_space", + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + // We use `unstable` to ensure that the code is actually using the right identifier + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: true, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + + it("should not get (unstable) file trees with invalid purpose/subtype contents", async () => { + const roomId = "!room:example.org"; + const mockRoom = { + currentState: { + getStateEvents: (eventType, stateKey) => { + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return new MatrixEvent({ + content: { + [RoomCreateTypeField]: RoomType.Space, + }, + }); + } else if (eventType === UNSTABLE_MSC3088_PURPOSE.unstable) { + expect(stateKey).toEqual(UNSTABLE_MSC3089_TREE_SUBTYPE.unstable); + return new MatrixEvent({ + content: { + [UNSTABLE_MSC3088_ENABLED.unstable]: false, + }, + }); + } else { + throw new Error("Unexpected event type or state key"); + } + }, + }, + }; + client.getRoom = (getRoomId) => { + expect(getRoomId).toEqual(roomId); + return mockRoom; + }; + const tree = client.unstableGetFileTreeSpace(roomId); + expect(tree).toBeFalsy(); + }); + it("should not POST /filter if a matching filter already exists", async function() { httpLookups = []; httpLookups.push(PUSH_RULES_RESPONSE); diff --git a/spec/unit/models/MSC3089Branch.spec.ts b/spec/unit/models/MSC3089Branch.spec.ts new file mode 100644 index 000000000..fc8b35815 --- /dev/null +++ b/spec/unit/models/MSC3089Branch.spec.ts @@ -0,0 +1,155 @@ +/* +Copyright 2021 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 { MatrixClient } from "../../../src"; +import { Room } from "../../../src/models/room"; +import { MatrixEvent } from "../../../src/models/event"; +import { UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event"; +import { EventTimelineSet } from "../../../src/models/event-timeline-set"; +import { EventTimeline } from "../../../src/models/event-timeline"; +import { MSC3089Branch } from "../../../src/models/MSC3089Branch"; + +describe("MSC3089Branch", () => { + let client: MatrixClient; + // @ts-ignore - TS doesn't know that this is a type + let indexEvent: MatrixEvent; + let branch: MSC3089Branch; + + const branchRoomId = "!room:example.org"; + const fileEventId = "$file"; + + const staticTimelineSets = {} as EventTimelineSet; + const staticRoom = { + getUnfilteredTimelineSet: () => staticTimelineSets, + } as any as Room; // partial + + beforeEach(() => { + // TODO: Use utility functions to create test rooms and clients + client = { + getRoom: (roomId: string) => { + if (roomId === branchRoomId) { + return staticRoom; + } else { + throw new Error("Unexpected fetch for unknown room"); + } + }, + }; + indexEvent = { + getRoomId: () => branchRoomId, + getStateKey: () => fileEventId, + }; + branch = new MSC3089Branch(client, indexEvent); + }); + + it('should know the file event ID', () => { + expect(branch.id).toEqual(fileEventId); + }); + + it('should know if the file is active or not', () => { + indexEvent.getContent = () => ({}); + expect(branch.isActive).toBe(false); + indexEvent.getContent = () => ({ active: false }); + expect(branch.isActive).toBe(false); + indexEvent.getContent = () => ({ active: true }); + expect(branch.isActive).toBe(true); + indexEvent.getContent = () => ({ active: "true" }); // invalid boolean, inactive + expect(branch.isActive).toBe(false); + }); + + it('should be able to delete the file', async () => { + const stateFn = jest.fn() + .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value + expect(content).toMatchObject({}); + expect(content['active']).toBeUndefined(); + expect(stateKey).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.sendStateEvent = stateFn; + + const redactFn = jest.fn().mockImplementation((roomId: string, eventId: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventId).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.redactEvent = redactFn; + + await branch.delete(); + + expect(stateFn).toHaveBeenCalledTimes(1); + expect(redactFn).toHaveBeenCalledTimes(1); + }); + + it('should know its name', async () => { + const name = "My File.txt"; + indexEvent.getContent = () => ({ active: true, name: name }); + + const res = branch.getName(); + + expect(res).toEqual(name); + }); + + it('should be able to change its name', async () => { + const name = "My File.txt"; + indexEvent.getContent = () => ({ active: true, retained: true }); + const stateFn = jest.fn() + .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(branchRoomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test that we're definitely using the unstable value + expect(content).toMatchObject({ + retained: true, // canary for copying state + active: true, + name: name, + }); + expect(stateKey).toEqual(fileEventId); + + return Promise.resolve(); // return value not used + }); + client.sendStateEvent = stateFn; + + await branch.setName(name); + + expect(stateFn).toHaveBeenCalledTimes(1); + }); + + it('should be able to return event information', async () => { + const mxcLatter = "example.org/file"; + const fileContent = { isFile: "not quite", url: "mxc://" + mxcLatter }; + const eventsArr = [ + { getId: () => "$not-file", getContent: () => ({}) }, + { getId: () => fileEventId, getContent: () => ({ file: fileContent }) }, + ]; + client.getEventTimeline = () => Promise.resolve({ + getEvents: () => eventsArr, + }) as any as Promise; // partial + client.mxcUrlToHttp = (mxc: string) => { + expect(mxc).toEqual("mxc://" + mxcLatter); + return `https://example.org/_matrix/media/v1/download/${mxcLatter}`; + }; + client.decryptEventIfNeeded = () => Promise.resolve(); + + const res = await branch.getFileInfo(); + expect(res).toBeDefined(); + expect(res).toMatchObject({ + info: fileContent, + // Escape regex from MDN guides: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions + httpUrl: expect.stringMatching(`.+${mxcLatter.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`), + }); + }); +}); diff --git a/spec/unit/models/MSC3089TreeSpace.spec.ts b/spec/unit/models/MSC3089TreeSpace.spec.ts new file mode 100644 index 000000000..951ab4c0e --- /dev/null +++ b/spec/unit/models/MSC3089TreeSpace.spec.ts @@ -0,0 +1,963 @@ +/* +Copyright 2021 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 { MatrixClient } from "../../../src"; +import { Room } from "../../../src/models/room"; +import { MatrixEvent } from "../../../src/models/event"; +import { EventType, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../../../src/@types/event"; +import { + DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + MSC3089TreeSpace, + TreePermissions, +} from "../../../src/models/MSC3089TreeSpace"; +import { DEFAULT_ALPHABET } from "../../../src/utils"; +import { MockBlob } from "../../MockBlob"; +import { MatrixError } from "../../../src/http-api"; + +describe("MSC3089TreeSpace", () => { + let client: MatrixClient; + let room: Room; + let tree: MSC3089TreeSpace; + const roomId = "!tree:localhost"; + const targetUser = "@target:example.org"; + + let powerLevels; + + beforeEach(() => { + // TODO: Use utility functions to create test rooms and clients + client = { + getRoom: (fetchRoomId: string) => { + if (fetchRoomId === roomId) { + return room; + } else { + throw new Error("Unexpected fetch for unknown room"); + } + }, + }; + room = { + currentState: { + getStateEvents: (evType: EventType, stateKey: string) => { + if (evType === EventType.RoomPowerLevels && stateKey === "") { + return powerLevels; + } else { + throw new Error("Accessed unexpected state event type or key"); + } + }, + }, + }; + tree = new MSC3089TreeSpace(client, roomId); + makePowerLevels(DEFAULT_TREE_POWER_LEVELS_TEMPLATE); + }); + + function makePowerLevels(content: any) { + powerLevels = new MatrixEvent({ + type: EventType.RoomPowerLevels, + state_key: "", + sender: "@creator:localhost", + event_id: "$powerlevels", + room_id: roomId, + content: content, + }); + } + + it('should populate the room reference', () => { + expect(tree.room).toBe(room); + }); + + it('should proxy the ID member to room ID', () => { + expect(tree.id).toEqual(tree.roomId); + expect(tree.id).toEqual(roomId); + }); + + it('should support setting the name of the space', async () => { + const newName = "NEW NAME"; + const fn = jest.fn() + .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomName); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({ name: newName }); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setName(newName); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should support inviting users to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target, false, false); + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should retry invites to the space', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + if (fn.mock.calls.length === 1) return Promise.reject(new Error("Sample Failure")); + return Promise.resolve(); + }); + client.invite = fn; + await tree.invite(target, false, false); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('should not retry invite permission errors', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.reject(new MatrixError({ errcode: "M_FORBIDDEN", error: "Sample Failure" })); + }); + client.invite = fn; + try { + await tree.invite(target, false, false); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.errcode).toEqual("M_FORBIDDEN"); + } + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('should invite to subspaces', async () => { + const target = targetUser; + const fn = jest.fn().mockImplementation((inviteRoomId: string, userId: string) => { + expect(inviteRoomId).toEqual(roomId); + expect(userId).toEqual(target); + return Promise.resolve(); + }); + client.invite = fn; + tree.getDirectories = () => [ + // Bare minimum overrides. We proxy to our mock function manually so we can + // count the calls, not to ensure accuracy. The invite function behaving correctly + // is covered by another test. + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + { invite: (userId) => fn(tree.roomId, userId) } as MSC3089TreeSpace, + ]; + + await tree.invite(target, true, false); + expect(fn).toHaveBeenCalledTimes(4); + }); + + it('should share keys with invitees', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + // Mock the history check as best as possible + const historyVis = "shared"; + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + // We're not expecting a super rigid test: the function that calls this internally isn't + // really being tested here. + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + // Note: inverse test is implicit from other tests, which disable the call stack of this + // test in order to pass. + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(1); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + + it('should not share keys with invitees if inappropriate history visibility', async () => { + const target = targetUser; + const sendKeysFn = jest.fn().mockImplementation((inviteRoomId: string, userIds: string[]) => { + expect(inviteRoomId).toEqual(roomId); + expect(userIds).toMatchObject([target]); + return Promise.resolve(); + }); + client.invite = () => Promise.resolve(); // we're not testing this here - see other tests + client.sendSharedHistoryKeys = sendKeysFn; + + const historyVis = "joined"; // NOTE: Changed. + const historyFn = jest.fn().mockImplementation((eventType: string, stateKey?: string) => { + expect(eventType).toEqual(EventType.RoomHistoryVisibility); + expect(stateKey).toEqual(""); + return { getContent: () => ({ history_visibility: historyVis }) }; // eslint-disable-line camelcase + }); + room.currentState.getStateEvents = historyFn; + + await tree.invite(target, false, true); + expect(sendKeysFn).toHaveBeenCalledTimes(0); + expect(historyFn).toHaveBeenCalledTimes(1); + }); + + async function evaluatePowerLevels(pls: any, role: TreePermissions, expectedPl: number) { + makePowerLevels(pls); + const fn = jest.fn() + .mockImplementation((stateRoomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(stateRoomId).toEqual(roomId); + expect(eventType).toEqual(EventType.RoomPowerLevels); + expect(stateKey).toEqual(""); + expect(content).toMatchObject({ + ...pls, + users: { + [targetUser]: expectedPl, + }, + }); + return Promise.resolve(); + }); + client.sendStateEvent = fn; + await tree.setPermissions(targetUser, role); + expect(fn.mock.calls.length).toBe(1); + } + + it('should support setting Viewer permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + }, TreePermissions.Viewer, 1024); + }); + + it('should support setting Editor permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + }, TreePermissions.Editor, 1024); + }); + + it('should support setting Owner permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events: { + [EventType.RoomPowerLevels]: 1024, + }, + }, TreePermissions.Owner, 1024); + }); + + it('should support demoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users_default: 1024, + users: { + [targetUser]: 2222, + }, + }, TreePermissions.Viewer, 1024); + }); + + it('should support promoting permissions', () => { + return evaluatePowerLevels({ + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + events_default: 1024, + users: { + [targetUser]: 5, + }, + }, TreePermissions.Editor, 1024); + }); + + it('should support defaults: Viewer', () => { + return evaluatePowerLevels({}, TreePermissions.Viewer, 0); + }); + + it('should support defaults: Editor', () => { + return evaluatePowerLevels({}, TreePermissions.Editor, 50); + }); + + it('should support defaults: Owner', () => { + return evaluatePowerLevels({}, TreePermissions.Owner, 100); + }); + + it('should create subdirectories', async () => { + const subspaceName = "subdirectory"; + const subspaceId = "!subspace:localhost"; + const domain = "domain.example.com"; + client.getRoom = (roomId: string) => { + if (roomId === tree.roomId) { + return tree.room; + } else if (roomId === subspaceId) { + return {} as Room; // we don't need anything important off of this + } else { + throw new Error("Unexpected getRoom call"); + } + }; + client.getDomain = () => domain; + const createFn = jest.fn().mockImplementation(async (name: string) => { + expect(name).toEqual(subspaceName); + return new MSC3089TreeSpace(client, subspaceId); + }); + const sendStateFn = jest.fn() + .mockImplementation(async (roomId: string, eventType: EventType, content: any, stateKey: string) => { + expect([tree.roomId, subspaceId]).toContain(roomId); + if (roomId === subspaceId) { + expect(eventType).toEqual(EventType.SpaceParent); + expect(stateKey).toEqual(tree.roomId); + } else { + expect(eventType).toEqual(EventType.SpaceChild); + expect(stateKey).toEqual(subspaceId); + } + expect(content).toMatchObject({ via: [domain] }); + + // return value not used + }); + client.unstableCreateFileTree = createFn; + client.sendStateEvent = sendStateFn; + + const directory = await tree.createDirectory(subspaceName); + expect(directory).toBeDefined(); + expect(directory).not.toBeNull(); + expect(directory).not.toBe(tree); + expect(directory.roomId).toEqual(subspaceId); + expect(createFn).toHaveBeenCalledTimes(1); + expect(sendStateFn).toHaveBeenCalledTimes(2); + + const content = expect.objectContaining({ via: [domain] }); + expect(sendStateFn).toHaveBeenCalledWith(subspaceId, EventType.SpaceParent, content, tree.roomId); + expect(sendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, content, subspaceId); + }); + + it('should find subdirectories', () => { + const firstChildRoom = "!one:example.org"; + const secondChildRoom = "!two:example.org"; + const thirdChildRoom = "!three:example.org"; // to ensure it doesn't end up in the subdirectories + room.currentState = { + getStateEvents: (eventType: EventType, stateKey?: string) => { + expect(eventType).toEqual(EventType.SpaceChild); + expect(stateKey).toBeUndefined(); + return [ + // Partial implementations of Room + { getStateKey: () => firstChildRoom }, + { getStateKey: () => secondChildRoom }, + { getStateKey: () => thirdChildRoom }, + ]; + }, + }; + client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor + + const getFn = jest.fn().mockImplementation((roomId: string) => { + if (roomId === thirdChildRoom) { + throw new Error("Mock not-a-space room case called (expected)"); + } + expect([firstChildRoom, secondChildRoom]).toContain(roomId); + return new MSC3089TreeSpace(client, roomId); + }); + client.unstableGetFileTreeSpace = getFn; + + const subdirectories = tree.getDirectories(); + expect(subdirectories).toBeDefined(); + expect(subdirectories.length).toBe(2); + expect(subdirectories[0].roomId).toBe(firstChildRoom); + expect(subdirectories[1].roomId).toBe(secondChildRoom); + expect(getFn).toHaveBeenCalledTimes(3); + expect(getFn).toHaveBeenCalledWith(firstChildRoom); + expect(getFn).toHaveBeenCalledWith(secondChildRoom); + expect(getFn).toHaveBeenCalledWith(thirdChildRoom); // check to make sure it tried + }); + + it('should find specific directories', () => { + client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor + + // Only mocking used API + const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace; + const searchedSubdirectory = { roomId: "!find_me:example.org" } as any as MSC3089TreeSpace; + const thirdSubdirectory = { roomId: "!third:example.org" } as any as MSC3089TreeSpace; + tree.getDirectories = () => [firstSubdirectory, searchedSubdirectory, thirdSubdirectory]; + + let result = tree.getDirectory(searchedSubdirectory.roomId); + expect(result).toBe(searchedSubdirectory); + + result = tree.getDirectory("not a subdirectory"); + expect(result).toBeFalsy(); + }); + + it('should be able to delete itself', async () => { + const delete1 = jest.fn().mockImplementation(() => Promise.resolve()); + const subdir1 = { delete: delete1 } as any as MSC3089TreeSpace; // mock tested bits + + const delete2 = jest.fn().mockImplementation(() => Promise.resolve()); + const subdir2 = { delete: delete2 } as any as MSC3089TreeSpace; // mock tested bits + + const joinMemberId = "@join:example.org"; + const knockMemberId = "@knock:example.org"; + const inviteMemberId = "@invite:example.org"; + const leaveMemberId = "@leave:example.org"; + const banMemberId = "@ban:example.org"; + const selfUserId = "@self:example.org"; + + tree.getDirectories = () => [subdir1, subdir2]; + room.currentState = { + getStateEvents: (eventType: EventType, stateKey?: string) => { + expect(eventType).toEqual(EventType.RoomMember); + expect(stateKey).toBeUndefined(); + return [ + // Partial implementations + { getContent: () => ({ membership: "join" }), getStateKey: () => joinMemberId }, + { getContent: () => ({ membership: "knock" }), getStateKey: () => knockMemberId }, + { getContent: () => ({ membership: "invite" }), getStateKey: () => inviteMemberId }, + { getContent: () => ({ membership: "leave" }), getStateKey: () => leaveMemberId }, + { getContent: () => ({ membership: "ban" }), getStateKey: () => banMemberId }, + + // ensure we don't kick ourselves + { getContent: () => ({ membership: "join" }), getStateKey: () => selfUserId }, + ]; + }, + }; + + // These two functions are tested by input expectations, so no expectations in the function bodies + const kickFn = jest.fn().mockImplementation((userId) => Promise.resolve()); + const leaveFn = jest.fn().mockImplementation(() => Promise.resolve()); + client.kick = kickFn; + client.leave = leaveFn; + client.getUserId = () => selfUserId; + + await tree.delete(); + + expect(delete1).toHaveBeenCalledTimes(1); + expect(delete2).toHaveBeenCalledTimes(1); + expect(kickFn).toHaveBeenCalledTimes(3); + expect(kickFn).toHaveBeenCalledWith(tree.roomId, joinMemberId, expect.any(String)); + expect(kickFn).toHaveBeenCalledWith(tree.roomId, knockMemberId, expect.any(String)); + expect(kickFn).toHaveBeenCalledWith(tree.roomId, inviteMemberId, expect.any(String)); + expect(leaveFn).toHaveBeenCalledTimes(1); + }); + + describe('get and set order', () => { + // Danger: these are partial implementations for testing purposes only + + // @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important + let childState: { [roomId: string]: MatrixEvent[] } = {}; + // @ts-ignore - "MatrixEvent is a value but used as a type", which is true but not important + let parentState: MatrixEvent[] = []; + let parentRoom: Room; + let childTrees: MSC3089TreeSpace[]; + let rooms: { [roomId: string]: Room }; + let clientSendStateFn: jest.MockedFunction; + const staticDomain = "static.example.org"; + + function addSubspace(roomId: string, createTs?: number, order?: string) { + const content = { + via: [staticDomain], + }; + if (order) content['order'] = order; + parentState.push({ + getType: () => EventType.SpaceChild, + getStateKey: () => roomId, + getContent: () => content, + }); + childState[roomId] = [ + { + getType: () => EventType.SpaceParent, + getStateKey: () => tree.roomId, + getContent: () => ({ + via: [staticDomain], + }), + }, + ]; + if (createTs) { + childState[roomId].push({ + getType: () => EventType.RoomCreate, + getStateKey: () => "", + getContent: () => ({}), + getTs: () => createTs, + }); + } + rooms[roomId] = makeMockChildRoom(roomId); + childTrees.push(new MSC3089TreeSpace(client, roomId)); + } + + function expectOrder(childRoomId: string, order: number) { + const child = childTrees.find(c => c.roomId === childRoomId); + expect(child).toBeDefined(); + expect(child.getOrder()).toEqual(order); + } + + function makeMockChildRoom(roomId: string): Room { + return { + currentState: { + getStateEvents: (eventType: EventType, stateKey?: string) => { + expect([EventType.SpaceParent, EventType.RoomCreate]).toContain(eventType); + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return childState[roomId].find(e => e.getType() === EventType.RoomCreate); + } else { + expect(stateKey).toBeUndefined(); + return childState[roomId].filter(e => e.getType() === eventType); + } + }, + }, + } as Room; // partial + } + + beforeEach(() => { + childState = {}; + parentState = []; + parentRoom = { + ...tree.room, + roomId: tree.roomId, + currentState: { + getStateEvents: (eventType: EventType, stateKey?: string) => { + expect([ + EventType.SpaceChild, + EventType.RoomCreate, + EventType.SpaceParent, + ]).toContain(eventType); + + if (eventType === EventType.RoomCreate) { + expect(stateKey).toEqual(""); + return parentState.filter(e => e.getType() === EventType.RoomCreate)[0]; + } else { + if (stateKey !== undefined) { + expect(Object.keys(rooms)).toContain(stateKey); + expect(stateKey).not.toEqual(tree.roomId); + return parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey); + } // else fine + return parentState.filter(e => e.getType() === eventType); + } + }, + }, + } as Room; + childTrees = []; + rooms = {}; + rooms[tree.roomId] = parentRoom; + (tree).room = parentRoom; // override readonly + client.getRoom = (r) => rooms[r]; + + clientSendStateFn = jest.fn() + .mockImplementation((roomId: string, eventType: EventType, content: any, stateKey: string) => { + expect(roomId).toEqual(tree.roomId); + expect(eventType).toEqual(EventType.SpaceChild); + expect(content).toMatchObject(expect.objectContaining({ + via: expect.any(Array), + order: expect.any(String), + })); + expect(Object.keys(rooms)).toContain(stateKey); + expect(stateKey).not.toEqual(tree.roomId); + + const stateEvent = parentState.find(e => e.getType() === eventType && e.getStateKey() === stateKey); + expect(stateEvent).toBeDefined(); + stateEvent.getContent = () => content; + + return Promise.resolve(); // return value not used + }); + client.sendStateEvent = clientSendStateFn; + }); + + it('should know when something is top level', () => { + const a = "!a:example.org"; + addSubspace(a); + + expect(tree.isTopLevel).toBe(true); + expect(childTrees[0].isTopLevel).toBe(false); // a bit of a hack to get at this, but it's fine + }); + + it('should return -1 for top level spaces', () => { + // The tree is what we've defined as top level, so it should work + expect(tree.getOrder()).toEqual(-1); + }); + + it('should throw when setting an order at the top level space', async () => { + try { + // The tree is what we've defined as top level, so it should work + await tree.setOrder(2); + + // noinspection ExceptionCaughtLocallyJS + throw new Error("Failed to fail"); + } catch (e) { + expect(e.message).toEqual("Cannot set order of top level spaces currently"); + } + }); + + it('should return a stable order for unordered children', () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(c, 3); + addSubspace(b, 2); + addSubspace(a, 1); + + expectOrder(a, 0); + expectOrder(b, 1); + expectOrder(c, 2); + }); + + it('should return a stable order for ordered children', () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(a, 1, "Z"); + addSubspace(b, 2, "Y"); + addSubspace(c, 3, "X"); + + expectOrder(c, 0); + expectOrder(b, 1); + expectOrder(a, 2); + }); + + it('should return a stable order for partially ordered children', () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + const d = "!d:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(a, 1); + addSubspace(b, 2); + addSubspace(c, 3, "Y"); + addSubspace(d, 4, "X"); + + expectOrder(d, 0); + expectOrder(c, 1); + expectOrder(b, 3); // note order diff due to room ID comparison expectation + expectOrder(a, 2); + }); + + it('should return a stable order if the create event timestamps are the same', () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(c, 3); + addSubspace(b, 3); // same as C + addSubspace(a, 3); // same as C + + expectOrder(a, 0); + expectOrder(b, 1); + expectOrder(c, 2); + }); + + it('should return a stable order if there are no known create events', () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(c); + addSubspace(b); + addSubspace(a); + + expectOrder(a, 0); + expectOrder(b, 1); + expectOrder(c, 2); + }); + + // XXX: These tests rely on `getOrder()` re-calculating and not caching values. + + it('should allow reordering within unordered children', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(c, 3); + addSubspace(b, 2); + addSubspace(a, 1); + + // Order of this state is validated by other tests. + + const treeA = childTrees.find(c => c.roomId === a); + expect(treeA).toBeDefined(); + await treeA.setOrder(1); + + expect(clientSendStateFn).toHaveBeenCalledTimes(3); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + + // Because of how the reordering works (maintain stable ordering before moving), we end up calling this + // function twice for the same room. + order: DEFAULT_ALPHABET[0], + }), a); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: DEFAULT_ALPHABET[1], + }), b); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: DEFAULT_ALPHABET[2], + }), a); + expectOrder(a, 1); + expectOrder(b, 0); + expectOrder(c, 2); + }); + + it('should allow reordering within ordered children', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(c, 3, "Z"); + addSubspace(b, 2, "X"); + addSubspace(a, 1, "V"); + + // Order of this state is validated by other tests. + + const treeA = childTrees.find(c => c.roomId === a); + expect(treeA).toBeDefined(); + await treeA.setOrder(1); + + expect(clientSendStateFn).toHaveBeenCalledTimes(1); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'Y', + }), a); + expectOrder(a, 1); + expectOrder(b, 0); + expectOrder(c, 2); + }); + + it('should allow reordering within partially ordered children', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + const d = "!d:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(a, 1); + addSubspace(b, 2); + addSubspace(c, 3, "Y"); + addSubspace(d, 4, "W"); + + // Order of this state is validated by other tests. + + const treeA = childTrees.find(c => c.roomId === a); + expect(treeA).toBeDefined(); + await treeA.setOrder(2); + + expect(clientSendStateFn).toHaveBeenCalledTimes(1); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'Z', + }), a); + expectOrder(a, 2); + expectOrder(b, 3); + expectOrder(c, 1); + expectOrder(d, 0); + }); + + it('should support moving upwards', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + const d = "!d:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(d, 4, "Z"); + addSubspace(c, 3, "X"); + addSubspace(b, 2, "V"); + addSubspace(a, 1, "T"); + + // Order of this state is validated by other tests. + + const treeB = childTrees.find(c => c.roomId === b); + expect(treeB).toBeDefined(); + await treeB.setOrder(2); + + expect(clientSendStateFn).toHaveBeenCalledTimes(1); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'Y', + }), b); + expectOrder(a, 0); + expectOrder(b, 2); + expectOrder(c, 1); + expectOrder(d, 3); + }); + + it('should support moving downwards', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + const d = "!d:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(d, 4, "Z"); + addSubspace(c, 3, "X"); + addSubspace(b, 2, "V"); + addSubspace(a, 1, "T"); + + // Order of this state is validated by other tests. + + const treeC = childTrees.find(ch => ch.roomId === c); + expect(treeC).toBeDefined(); + await treeC.setOrder(1); + + expect(clientSendStateFn).toHaveBeenCalledTimes(1); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'U', + }), c); + expectOrder(a, 0); + expectOrder(b, 2); + expectOrder(c, 1); + expectOrder(d, 3); + }); + + it('should support moving over the partial ordering boundary', async () => { + const a = "!a:example.org"; + const b = "!b:example.org"; + const c = "!c:example.org"; + const d = "!d:example.org"; + + // Add in reverse order to make sure it gets ordered correctly + addSubspace(d, 4); + addSubspace(c, 3); + addSubspace(b, 2, "V"); + addSubspace(a, 1, "T"); + + // Order of this state is validated by other tests. + + const treeB = childTrees.find(ch => ch.roomId === b); + expect(treeB).toBeDefined(); + await treeB.setOrder(2); + + expect(clientSendStateFn).toHaveBeenCalledTimes(2); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'W', + }), c); + expect(clientSendStateFn).toHaveBeenCalledWith(tree.roomId, EventType.SpaceChild, expect.objectContaining({ + via: [staticDomain], // should retain domain independent of client.getDomain() + order: 'X', + }), b); + expectOrder(a, 0); + expectOrder(b, 2); + expectOrder(c, 1); + expectOrder(d, 3); + }); + }); + + it('should upload files', async () => { + const mxc = "mxc://example.org/file"; + const fileInfo = { + mimetype: "text/plain", + // other fields as required by encryption, but ignored here + }; + const fileEventId = "$file"; + const fileName = "My File.txt"; + const fileContents = "This is a test file"; + + // Mock out Blob for the test environment + (global).Blob = MockBlob; + + const uploadFn = jest.fn().mockImplementation((contents: Blob, opts: any) => { + expect(contents).toBeInstanceOf(Blob); + expect(contents.size).toEqual(fileContents.length); + expect(opts).toMatchObject({ + includeFilename: false, + onlyContentUri: true, // because the tests rely on this - we shouldn't really be testing for this. + }); + return Promise.resolve(mxc); + }); + client.uploadContent = uploadFn; + + const sendMsgFn = jest.fn().mockImplementation((roomId: string, contents: any) => { + expect(roomId).toEqual(tree.roomId); + expect(contents).toMatchObject({ + msgtype: MsgType.File, + body: fileName, + url: mxc, + file: fileInfo, + [UNSTABLE_MSC3089_LEAF.unstable]: {}, // test to ensure we're definitely using unstable + }); + + return Promise.resolve({ event_id: fileEventId }); // eslint-disable-line camelcase + }); + client.sendMessage = sendMsgFn; + + const sendStateFn = jest.fn() + .mockImplementation((roomId: string, eventType: string, content: any, stateKey: string) => { + expect(roomId).toEqual(tree.roomId); + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + expect(content).toMatchObject({ + active: true, + name: fileName, + }); + + return Promise.resolve(); // return value not used. + }); + client.sendStateEvent = sendStateFn; + + const buf = Uint8Array.from(Array.from(fileContents).map((_, i) => fileContents.charCodeAt(i))); + + // We clone the file info just to make sure it doesn't get mutated for the test. + await tree.createFile(fileName, buf, Object.assign({}, fileInfo)); + + expect(uploadFn).toHaveBeenCalledTimes(1); + expect(sendMsgFn).toHaveBeenCalledTimes(1); + expect(sendStateFn).toHaveBeenCalledTimes(1); + }); + + it('should support getting files', () => { + const fileEventId = "$file"; + const fileEvent = { forTest: true }; // MatrixEvent mock + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + return fileEvent; + }, + }; + + const file = tree.getFile(fileEventId); + expect(file).toBeDefined(); + expect(file.indexEvent).toBe(fileEvent); + }); + + it('should return falsy for unknown files', () => { + const fileEventId = "$file"; + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toEqual(fileEventId); + return null; + }, + }; + + const file = tree.getFile(fileEventId); + expect(file).toBeFalsy(); + }); + + it('should list files', () => { + const firstFile = { getContent: () => ({ active: true }) }; + const secondFile = { getContent: () => ({ active: false }) }; // deliberately inactive + room.currentState = { + getStateEvents: (eventType: string, stateKey?: string) => { + expect(eventType).toEqual(UNSTABLE_MSC3089_BRANCH.unstable); // test to ensure we're definitely using unstable + expect(stateKey).toBeUndefined(); + return [firstFile, secondFile]; + }, + }; + + const files = tree.listFiles(); + expect(files).toBeDefined(); + expect(files.length).toEqual(1); + expect(files[0].indexEvent).toBe(firstFile); + }); +}); diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts new file mode 100644 index 000000000..f1a8969dd --- /dev/null +++ b/spec/unit/models/event.spec.ts @@ -0,0 +1,60 @@ +/* +Copyright 2021 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 { MatrixEvent } from "../../../src/models/event"; + +describe('MatrixEvent', () => { + it('should create copies of itself', () => { + const a = new MatrixEvent({ + type: "com.example.test", + content: { + isTest: true, + num: 42, + }, + }); + + const clone = a.toSnapshot(); + expect(clone).toBeDefined(); + expect(clone).not.toBe(a); + expect(clone.event).not.toBe(a.event); + expect(clone.event).toMatchObject(a.event); + + // The other properties we're not super interested in, honestly. + }); + + it('should compare itself to other events using json', () => { + const a = new MatrixEvent({ + type: "com.example.test", + content: { + isTest: true, + num: 42, + }, + }); + const b = new MatrixEvent({ + type: "com.example.test______B", + content: { + isTest: true, + num: 42, + }, + }); + expect(a.isEquivalentTo(b)).toBe(false); + expect(a.isEquivalentTo(a)).toBe(true); + expect(b.isEquivalentTo(a)).toBe(false); + expect(b.isEquivalentTo(b)).toBe(true); + expect(a.toSnapshot().isEquivalentTo(a)).toBe(true); + expect(a.toSnapshot().isEquivalentTo(b)).toBe(false); + }); +}); diff --git a/spec/unit/room-member.spec.js b/spec/unit/room-member.spec.js index ef59d404a..7449c6a04 100644 --- a/spec/unit/room-member.spec.js +++ b/spec/unit/room-member.spec.js @@ -124,6 +124,34 @@ describe("RoomMember", function() { expect(member.powerLevel).toEqual(0); expect(emitCount).toEqual(1); }); + + it("should not honor string power levels.", + function() { + const event = utils.mkEvent({ + type: "m.room.power_levels", + room: roomId, + user: userA, + content: { + users_default: 20, + users: { + "@alice:bar": "5", + }, + }, + event: true, + }); + let emitCount = 0; + + member.on("RoomMember.powerLevel", function(emitEvent, emitMember) { + emitCount += 1; + expect(emitMember.userId).toEqual('@alice:bar'); + expect(emitMember.powerLevel).toEqual(20); + expect(emitEvent).toEqual(event); + }); + + member.setPowerLevelEvent(event); + expect(member.powerLevel).toEqual(20); + expect(emitCount).toEqual(1); + }); }); describe("setTypingEvent", function() { diff --git a/spec/unit/room-state.spec.js b/spec/unit/room-state.spec.js index 182f38d12..31bf2e034 100644 --- a/spec/unit/room-state.spec.js +++ b/spec/unit/room-state.spec.js @@ -1,6 +1,5 @@ import * as utils from "../test-utils"; import { RoomState } from "../../src/models/room-state"; -import { RoomMember } from "../../src/models/room-member"; describe("RoomState", function() { const roomId = "!foo:bar"; @@ -193,12 +192,7 @@ describe("RoomState", function() { expect(emitCount).toEqual(2); }); - it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", - function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - + it("should call setPowerLevelEvent on each RoomMember for m.room.power_levels", function() { const powerLevelEvent = utils.mkEvent({ type: "m.room.power_levels", room: roomId, user: userA, event: true, content: { @@ -208,18 +202,16 @@ describe("RoomState", function() { }, }); + // spy on the room members + jest.spyOn(state.members[userA], "setPowerLevelEvent"); + jest.spyOn(state.members[userB], "setPowerLevelEvent"); state.setStateEvents([powerLevelEvent]); - expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); - expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith( - powerLevelEvent, - ); + expect(state.members[userA].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); + expect(state.members[userB].setPowerLevelEvent).toHaveBeenCalledWith(powerLevelEvent); }); - it("should call setPowerLevelEvent on a new RoomMember if power levels exist", - function() { + it("should call setPowerLevelEvent on a new RoomMember if power levels exist", function() { const memberEvent = utils.mkMembership({ mship: "join", user: userC, room: roomId, event: true, }); @@ -243,13 +235,12 @@ describe("RoomState", function() { }); it("should call setMembershipEvent on the right RoomMember", function() { - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); - const memberEvent = utils.mkMembership({ user: userB, mship: "leave", room: roomId, event: true, }); + // spy on the room members + jest.spyOn(state.members[userA], "setMembershipEvent"); + jest.spyOn(state.members[userB], "setMembershipEvent"); state.setStateEvents([memberEvent]); expect(state.members[userA].setMembershipEvent).not.toHaveBeenCalled(); @@ -374,17 +365,13 @@ describe("RoomState", function() { user_ids: [userA], }, }); - // mock up the room members - state.members[userA] = utils.mock(RoomMember); - state.members[userB] = utils.mock(RoomMember); + // spy on the room members + jest.spyOn(state.members[userA], "setTypingEvent"); + jest.spyOn(state.members[userB], "setTypingEvent"); state.setTypingEvent(typingEvent); - expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); - expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith( - typingEvent, - ); + expect(state.members[userA].setTypingEvent).toHaveBeenCalledWith(typingEvent); + expect(state.members[userB].setTypingEvent).toHaveBeenCalledWith(typingEvent); }); }); diff --git a/spec/unit/room.spec.js b/spec/unit/room.spec.js index 90d65ba8f..7675609d3 100644 --- a/spec/unit/room.spec.js +++ b/spec/unit/room.spec.js @@ -1,8 +1,8 @@ import * as utils from "../test-utils"; -import { EventStatus, MatrixEvent } from "../../src/models/event"; +import { EventStatus, MatrixEvent } from "../../src"; import { EventTimeline } from "../../src/models/event-timeline"; -import { RoomState } from "../../src/models/room-state"; -import { Room } from "../../src/models/room"; +import { RoomState } from "../../src"; +import { Room } from "../../src"; import { TestClient } from "../TestClient"; describe("Room", function() { @@ -16,9 +16,9 @@ describe("Room", function() { beforeEach(function() { room = new Room(roomId); // mock RoomStates - room.oldState = room.getLiveTimeline()._startState = + room.oldState = room.getLiveTimeline().startState = utils.mock(RoomState, "oldState"); - room.currentState = room.getLiveTimeline()._endState = + room.currentState = room.getLiveTimeline().endState = utils.mock(RoomState, "currentState"); }); @@ -86,9 +86,11 @@ describe("Room", function() { ]; it("should call RoomState.setTypingEvent on m.typing events", function() { - room.currentState = utils.mock(RoomState); const typing = utils.mkEvent({ - room: roomId, type: "m.typing", event: true, content: { + room: roomId, + type: "m.typing", + event: true, + content: { user_ids: [userA], }, }); @@ -140,8 +142,8 @@ describe("Room", function() { expect(callCount).toEqual(2); }); - it("should call setStateEvents on the right RoomState with the right " + - "forwardLooking value for new events", function() { + it("should call setStateEvents on the right RoomState with the right forwardLooking value for new events", + function() { const events = [ utils.mkMembership({ room: roomId, mship: "invite", user: userB, skey: userA, event: true, @@ -652,7 +654,8 @@ describe("Room", function() { const roomName = "flibble"; const event = addMember(userA, "invite"); - event.event.invite_room_state = [ + event.event.unsigned = {}; + event.event.unsigned.invite_room_state = [ { type: "m.room.name", state_key: "", @@ -671,7 +674,8 @@ describe("Room", function() { const roomName = "flibble"; setRoomName(roomName); const roomNameToIgnore = "ignoreme"; - event.event.invite_room_state = [ + event.event.unsigned = {}; + event.event.unsigned.invite_room_state = [ { type: "m.room.name", state_key: "", diff --git a/spec/unit/utils.spec.js b/spec/unit/utils.spec.js deleted file mode 100644 index 703326f46..000000000 --- a/spec/unit/utils.spec.js +++ /dev/null @@ -1,262 +0,0 @@ -import * as utils from "../../src/utils"; - -describe("utils", function() { - describe("encodeParams", function() { - it("should url encode and concat with &s", function() { - const params = { - foo: "bar", - baz: "beer@", - }; - expect(utils.encodeParams(params)).toEqual( - "foo=bar&baz=beer%40", - ); - }); - }); - - describe("encodeUri", function() { - it("should replace based on object keys and url encode", function() { - const path = "foo/bar/%something/%here"; - const vals = { - "%something": "baz", - "%here": "beer@", - }; - expect(utils.encodeUri(path, vals)).toEqual( - "foo/bar/baz/beer%40", - ); - }); - }); - - describe("removeElement", function() { - it("should remove only 1 element if there is a match", function() { - const matchFn = function() { - return true; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn); - expect(arr).toEqual([66, 77]); - }); - it("should be able to remove in reverse order", function() { - const matchFn = function() { - return true; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn, true); - expect(arr).toEqual([55, 66]); - }); - it("should remove nothing if the function never returns true", function() { - const matchFn = function() { - return false; - }; - const arr = [55, 66, 77]; - utils.removeElement(arr, matchFn); - expect(arr).toEqual(arr); - }); - }); - - describe("isFunction", function() { - it("should return true for functions", function() { - expect(utils.isFunction([])).toBe(false); - expect(utils.isFunction([5, 3, 7])).toBe(false); - expect(utils.isFunction()).toBe(false); - expect(utils.isFunction(null)).toBe(false); - expect(utils.isFunction({})).toBe(false); - expect(utils.isFunction("foo")).toBe(false); - expect(utils.isFunction(555)).toBe(false); - - expect(utils.isFunction(function() {})).toBe(true); - const s = { foo: function() {} }; - expect(utils.isFunction(s.foo)).toBe(true); - }); - }); - - describe("checkObjectHasKeys", function() { - it("should throw for missing keys", function() { - expect(function() { - utils.checkObjectHasKeys({}, ["foo"]); - }).toThrow(); - expect(function() { - utils.checkObjectHasKeys({ - foo: "bar", - }, ["foo"]); - }).not.toThrow(); - }); - }); - - describe("checkObjectHasNoAdditionalKeys", function() { - it("should throw for extra keys", function() { - expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - baz: 4, - }, ["foo"]); - }).toThrow(); - - expect(function() { - utils.checkObjectHasNoAdditionalKeys({ - foo: "bar", - }, ["foo"]); - }).not.toThrow(); - }); - }); - - describe("deepCompare", function() { - const assert = { - isTrue: function(x) { - expect(x).toBe(true); - }, - isFalse: function(x) { - expect(x).toBe(false); - }, - }; - - it("should handle primitives", function() { - assert.isTrue(utils.deepCompare(null, null)); - assert.isFalse(utils.deepCompare(null, undefined)); - assert.isTrue(utils.deepCompare("hi", "hi")); - assert.isTrue(utils.deepCompare(5, 5)); - assert.isFalse(utils.deepCompare(5, 10)); - }); - - it("should handle regexps", function() { - assert.isTrue(utils.deepCompare(/abc/, /abc/)); - assert.isFalse(utils.deepCompare(/abc/, /123/)); - const r = /abc/; - assert.isTrue(utils.deepCompare(r, r)); - }); - - it("should handle dates", function() { - assert.isTrue(utils.deepCompare(new Date("2011-03-31"), - new Date("2011-03-31"))); - assert.isFalse(utils.deepCompare(new Date("2011-03-31"), - new Date("1970-01-01"))); - }); - - it("should handle arrays", function() { - assert.isTrue(utils.deepCompare([], [])); - assert.isTrue(utils.deepCompare([1, 2], [1, 2])); - assert.isFalse(utils.deepCompare([1, 2], [2, 1])); - assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); - }); - - it("should handle simple objects", function() { - assert.isTrue(utils.deepCompare({}, {})); - assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); - assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); - assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); - - assert.isTrue(utils.deepCompare({ 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } }, - { 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } })); - - assert.isFalse(utils.deepCompare({ 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 26 } }, - { 1: { name: "mhc", age: 28 }, - 2: { name: "arb", age: 27 } })); - - assert.isFalse(utils.deepCompare({}, null)); - assert.isFalse(utils.deepCompare({}, undefined)); - }); - - it("should handle functions", function() { - // no two different function is equal really, they capture their - // context variables so even if they have same toString(), they - // won't have same functionality - const func = function(x) { - return true; - }; - const func2 = function(x) { - return true; - }; - assert.isTrue(utils.deepCompare(func, func)); - assert.isFalse(utils.deepCompare(func, func2)); - assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); - assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); - }); - }); - - describe("extend", function() { - const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; - - it("should extend", function() { - const target = { - "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", - }; - const merged = { - "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", - "newprop": "new", - }; - const sourceOrig = JSON.stringify(SOURCE); - - utils.extend(target, SOURCE); - expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); - - // check the originial wasn't modified - expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); - }); - - it("should ignore null", function() { - const target = { - "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", - }; - const merged = { - "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", - "newprop": "new", - }; - const sourceOrig = JSON.stringify(SOURCE); - - utils.extend(target, null, SOURCE); - expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); - - // check the originial wasn't modified - expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); - }); - - it("should handle properties created with defineProperties", function() { - const source = Object.defineProperties({}, { - "enumerableProp": { - get: function() { - return true; - }, - enumerable: true, - }, - "nonenumerableProp": { - get: function() { - return true; - }, - }, - }); - - const target = {}; - utils.extend(target, source); - expect(target.enumerableProp).toBe(true); - expect(target.nonenumerableProp).toBe(undefined); - }); - }); - - describe("chunkPromises", function() { - it("should execute promises in chunks", async function() { - let promiseCount = 0; - - function fn1() { - return new Promise(async function(resolve, reject) { - await utils.sleep(1); - expect(promiseCount).toEqual(0); - ++promiseCount; - resolve(); - }); - } - - function fn2() { - return new Promise(function(resolve, reject) { - expect(promiseCount).toEqual(1); - ++promiseCount; - resolve(); - }); - } - - await utils.chunkPromises([fn1, fn2], 1); - expect(promiseCount).toEqual(2); - }); - }); -}); diff --git a/spec/unit/utils.spec.ts b/spec/unit/utils.spec.ts new file mode 100644 index 000000000..9611511b4 --- /dev/null +++ b/spec/unit/utils.spec.ts @@ -0,0 +1,496 @@ +import * as utils from "../../src/utils"; +import { + alphabetPad, + averageBetweenStrings, + baseToString, + deepSortedObjectEntries, + DEFAULT_ALPHABET, + lexicographicCompare, + nextString, + prevString, + simpleRetryOperation, + stringToBase, +} from "../../src/utils"; +import { logger } from "../../src/logger"; + +// TODO: Fix types throughout + +describe("utils", function() { + describe("encodeParams", function() { + it("should url encode and concat with &s", function() { + const params = { + foo: "bar", + baz: "beer@", + }; + expect(utils.encodeParams(params)).toEqual( + "foo=bar&baz=beer%40", + ); + }); + }); + + describe("encodeUri", function() { + it("should replace based on object keys and url encode", function() { + const path = "foo/bar/%something/%here"; + const vals = { + "%something": "baz", + "%here": "beer@", + }; + expect(utils.encodeUri(path, vals)).toEqual( + "foo/bar/baz/beer%40", + ); + }); + }); + + describe("removeElement", function() { + it("should remove only 1 element if there is a match", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual([66, 77]); + }); + it("should be able to remove in reverse order", function() { + const matchFn = function() { + return true; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn, true); + expect(arr).toEqual([55, 66]); + }); + it("should remove nothing if the function never returns true", function() { + const matchFn = function() { + return false; + }; + const arr = [55, 66, 77]; + utils.removeElement(arr, matchFn); + expect(arr).toEqual(arr); + }); + }); + + describe("isFunction", function() { + it("should return true for functions", function() { + expect(utils.isFunction([])).toBe(false); + expect(utils.isFunction([5, 3, 7])).toBe(false); + expect(utils.isFunction(undefined)).toBe(false); + expect(utils.isFunction(null)).toBe(false); + expect(utils.isFunction({})).toBe(false); + expect(utils.isFunction("foo")).toBe(false); + expect(utils.isFunction(555)).toBe(false); + + expect(utils.isFunction(function() {})).toBe(true); + const s = { foo: function() {} }; + expect(utils.isFunction(s.foo)).toBe(true); + }); + }); + + describe("checkObjectHasKeys", function() { + it("should throw for missing keys", function() { + expect(function() { + utils.checkObjectHasKeys({}, ["foo"]); + }).toThrow(); + expect(function() { + utils.checkObjectHasKeys({ + foo: "bar", + }, ["foo"]); + }).not.toThrow(); + }); + }); + + describe("checkObjectHasNoAdditionalKeys", function() { + it("should throw for extra keys", function() { + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ foo: "bar", baz: 4 }, ["foo"]); + }).toThrow(); + + expect(function() { + utils.checkObjectHasNoAdditionalKeys({ foo: "bar" }, ["foo"]); + }).not.toThrow(); + }); + }); + + describe("deepCompare", function() { + const assert = { + isTrue: function(x) { + expect(x).toBe(true); + }, + isFalse: function(x) { + expect(x).toBe(false); + }, + }; + + it("should handle primitives", function() { + assert.isTrue(utils.deepCompare(null, null)); + assert.isFalse(utils.deepCompare(null, undefined)); + assert.isTrue(utils.deepCompare("hi", "hi")); + assert.isTrue(utils.deepCompare(5, 5)); + assert.isFalse(utils.deepCompare(5, 10)); + }); + + it("should handle regexps", function() { + assert.isTrue(utils.deepCompare(/abc/, /abc/)); + assert.isFalse(utils.deepCompare(/abc/, /123/)); + const r = /abc/; + assert.isTrue(utils.deepCompare(r, r)); + }); + + it("should handle dates", function() { + assert.isTrue(utils.deepCompare(new Date("2011-03-31"), new Date("2011-03-31"))); + assert.isFalse(utils.deepCompare(new Date("2011-03-31"), new Date("1970-01-01"))); + }); + + it("should handle arrays", function() { + assert.isTrue(utils.deepCompare([], [])); + assert.isTrue(utils.deepCompare([1, 2], [1, 2])); + assert.isFalse(utils.deepCompare([1, 2], [2, 1])); + assert.isFalse(utils.deepCompare([1, 2], [1, 2, 3])); + }); + + it("should handle simple objects", function() { + assert.isTrue(utils.deepCompare({}, {})); + assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 2 })); + assert.isTrue(utils.deepCompare({ a: 1, b: 2 }, { b: 2, a: 1 })); + assert.isFalse(utils.deepCompare({ a: 1, b: 2 }, { a: 1, b: 3 })); + + assert.isTrue(utils.deepCompare({ + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + })); + + assert.isFalse(utils.deepCompare({ + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 26 }, + }, { + 1: { name: "mhc", age: 28 }, + 2: { name: "arb", age: 27 }, + })); + + assert.isFalse(utils.deepCompare({}, null)); + assert.isFalse(utils.deepCompare({}, undefined)); + }); + + it("should handle functions", function() { + // no two different function is equal really, they capture their + // context variables so even if they have same toString(), they + // won't have same functionality + const func = function(x) { + return true; + }; + const func2 = function(x) { + return true; + }; + assert.isTrue(utils.deepCompare(func, func)); + assert.isFalse(utils.deepCompare(func, func2)); + assert.isTrue(utils.deepCompare({ a: { b: func } }, { a: { b: func } })); + assert.isFalse(utils.deepCompare({ a: { b: func } }, { a: { b: func2 } })); + }); + }); + + describe("extend", function() { + const SOURCE = { "prop2": 1, "string2": "x", "newprop": "new" }; + + it("should extend", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should ignore null", function() { + const target = { + "prop1": 5, "prop2": 7, "string1": "baz", "string2": "foo", + }; + const merged = { + "prop1": 5, "prop2": 1, "string1": "baz", "string2": "x", + "newprop": "new", + }; + const sourceOrig = JSON.stringify(SOURCE); + + utils.extend(target, null, SOURCE); + expect(JSON.stringify(target)).toEqual(JSON.stringify(merged)); + + // check the originial wasn't modified + expect(JSON.stringify(SOURCE)).toEqual(sourceOrig); + }); + + it("should handle properties created with defineProperties", function() { + const source = Object.defineProperties({}, { + "enumerableProp": { + get: function() { + return true; + }, + enumerable: true, + }, + "nonenumerableProp": { + get: function() { + return true; + }, + }, + }); + + // TODO: Fix type + const target: any = {}; + utils.extend(target, source); + expect(target.enumerableProp).toBe(true); + expect(target.nonenumerableProp).toBe(undefined); + }); + }); + + describe("chunkPromises", function() { + it("should execute promises in chunks", async function() { + let promiseCount = 0; + + async function fn1() { + await utils.sleep(1); + expect(promiseCount).toEqual(0); + ++promiseCount; + } + + async function fn2() { + expect(promiseCount).toEqual(1); + ++promiseCount; + } + + await utils.chunkPromises([fn1, fn2], 1); + expect(promiseCount).toEqual(2); + }); + }); + + describe('simpleRetryOperation', () => { + it('should retry', async () => { + let count = 0; + const val = {}; + const fn = (attempt) => { + count++; + + // If this expectation fails then it can appear as a Jest Timeout due to + // the retry running beyond the test limit. + expect(attempt).toEqual(count); + + if (count > 1) { + return Promise.resolve(val); + } else { + return Promise.reject(new Error("Iterative failure")); + } + }; + + const ret = await simpleRetryOperation(fn); + expect(ret).toBe(val); + expect(count).toEqual(2); + }); + + // We don't test much else of the function because then we're just testing that the + // underlying library behaves, which should be tested on its own. Our API surface is + // all that concerns us. + }); + + describe('DEFAULT_ALPHABET', () => { + it('should be usefully printable ASCII in order', () => { + expect(DEFAULT_ALPHABET).toEqual( + " !\"#$%&'()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\\]^_`abcdefghijklmnopqrstuvwxyz{|}~", + ); + }); + }); + + describe('alphabetPad', () => { + it('should pad to the alphabet length', () => { + const len = 12; + expect(alphabetPad("a", len)).toEqual("a" + ("".padEnd(len - 1, DEFAULT_ALPHABET[0]))); + expect(alphabetPad("a", len, "123")).toEqual("a" + ("".padEnd(len - 1, '1'))); + }); + }); + + describe('baseToString', () => { + it('should calculate the appropriate string from numbers', () => { + // Verify the whole alphabet + for (let i = BigInt(1); i <= DEFAULT_ALPHABET.length; i++) { + logger.log({ i }); // for debugging + expect(baseToString(i)).toEqual(DEFAULT_ALPHABET[Number(i) - 1]); + } + + // Just quickly double check that repeated characters aren't treated as padding, particularly + // at the beginning of the alphabet where they are most vulnerable to this behaviour. + expect(baseToString(BigInt(1))).toEqual(DEFAULT_ALPHABET[0].repeat(1)); + expect(baseToString(BigInt(96))).toEqual(DEFAULT_ALPHABET[0].repeat(2)); + expect(baseToString(BigInt(9121))).toEqual(DEFAULT_ALPHABET[0].repeat(3)); + expect(baseToString(BigInt(866496))).toEqual(DEFAULT_ALPHABET[0].repeat(4)); + expect(baseToString(BigInt(82317121))).toEqual(DEFAULT_ALPHABET[0].repeat(5)); + expect(baseToString(BigInt(7820126496))).toEqual(DEFAULT_ALPHABET[0].repeat(6)); + + expect(baseToString(BigInt(10))).toEqual(DEFAULT_ALPHABET[9]); + expect(baseToString(BigInt(10), "abcdefghijklmnopqrstuvwxyz")).toEqual('j'); + expect(baseToString(BigInt(6337))).toEqual("ab"); + expect(baseToString(BigInt(80), "abcdefghijklmnopqrstuvwxyz")).toEqual('cb'); + }); + }); + + describe('stringToBase', () => { + it('should calculate the appropriate number for a string', () => { + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(1))).toEqual(BigInt(1)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(2))).toEqual(BigInt(96)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(3))).toEqual(BigInt(9121)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(4))).toEqual(BigInt(866496)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(5))).toEqual(BigInt(82317121)); + expect(stringToBase(DEFAULT_ALPHABET[0].repeat(6))).toEqual(BigInt(7820126496)); + expect(stringToBase("a", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(1)); + expect(stringToBase("a")).toEqual(BigInt(66)); + expect(stringToBase("c", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(3)); + expect(stringToBase("ab")).toEqual(BigInt(6337)); + expect(stringToBase("cb", "abcdefghijklmnopqrstuvwxyz")).toEqual(BigInt(80)); + }); + }); + + describe('averageBetweenStrings', () => { + it('should average appropriately', () => { + expect(averageBetweenStrings(" ", "!!")).toEqual(" P"); + expect(averageBetweenStrings(" ", "!")).toEqual(" "); + expect(averageBetweenStrings('A', 'B')).toEqual('A '); + expect(averageBetweenStrings('AA', 'BB')).toEqual('Aq'); + expect(averageBetweenStrings('A', 'z')).toEqual(']'); + expect(averageBetweenStrings('a', 'z', "abcdefghijklmnopqrstuvwxyz")).toEqual('m'); + expect(averageBetweenStrings('AA', 'zz')).toEqual('^.'); + expect(averageBetweenStrings('aa', 'zz', "abcdefghijklmnopqrstuvwxyz")).toEqual('mz'); + expect(averageBetweenStrings('cat', 'doggo')).toEqual("d9>Cw"); + expect(averageBetweenStrings('cat', 'doggo', "abcdefghijklmnopqrstuvwxyz")).toEqual("cumqh"); + }); + }); + + describe('nextString', () => { + it('should find the next string appropriately', () => { + expect(nextString('A')).toEqual('B'); + expect(nextString('b', 'abcdefghijklmnopqrstuvwxyz')).toEqual('c'); + expect(nextString('cat')).toEqual('cau'); + expect(nextString('cat', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cau'); + }); + }); + + describe('prevString', () => { + it('should find the next string appropriately', () => { + expect(prevString('B')).toEqual('A'); + expect(prevString('c', 'abcdefghijklmnopqrstuvwxyz')).toEqual('b'); + expect(prevString('cau')).toEqual('cat'); + expect(prevString('cau', 'abcdefghijklmnopqrstuvwxyz')).toEqual('cat'); + }); + }); + + // Let's just ensure the ordering is sensible for lexicographic ordering + describe('string averaging unified', () => { + it('should be truly previous and next', () => { + let midpoint = "cat"; + + // We run this test 100 times to ensure we end up with a sane sequence. + for (let i = 0; i < 100; i++) { + const next = nextString(midpoint); + const prev = prevString(midpoint); + logger.log({ i, midpoint, next, prev }); // for test debugging + + expect(lexicographicCompare(midpoint, next) < 0).toBe(true); + expect(lexicographicCompare(midpoint, prev) > 0).toBe(true); + expect(averageBetweenStrings(prev, next)).toBe(midpoint); + + midpoint = next; + } + }); + + it('should roll over', () => { + const lastAlpha = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1]; + const firstAlpha = DEFAULT_ALPHABET[0]; + + const highRoll = firstAlpha + firstAlpha; + const lowRoll = lastAlpha; + + expect(nextString(lowRoll)).toEqual(highRoll); + expect(prevString(highRoll)).toEqual(lowRoll); + }); + + it('should be reversible on small strings', () => { + // Large scale reversibility is tested for max space order value + const input = "cats"; + expect(prevString(nextString(input))).toEqual(input); + }); + + // We want to explicitly make sure that Space order values are supported and roll appropriately + it('should properly handle rolling over at 50 characters', () => { + // Note: we also test reversibility of large strings here. + + const maxSpaceValue = DEFAULT_ALPHABET[DEFAULT_ALPHABET.length - 1].repeat(50); + const fiftyFirstChar = DEFAULT_ALPHABET[0].repeat(51); + + expect(nextString(maxSpaceValue)).toBe(fiftyFirstChar); + expect(prevString(fiftyFirstChar)).toBe(maxSpaceValue); + + // We're testing that the rollover happened, which means that the next string come before + // the maximum space order value lexicographically. + expect(lexicographicCompare(maxSpaceValue, fiftyFirstChar) > 0).toBe(true); + }); + }); + + describe('lexicographicCompare', () => { + it('should work', () => { + // Simple tests + expect(lexicographicCompare('a', 'b') < 0).toBe(true); + expect(lexicographicCompare('ab', 'b') < 0).toBe(true); + expect(lexicographicCompare('cat', 'dog') < 0).toBe(true); + + // Simple tests (reversed) + expect(lexicographicCompare('b', 'a') > 0).toBe(true); + expect(lexicographicCompare('b', 'ab') > 0).toBe(true); + expect(lexicographicCompare('dog', 'cat') > 0).toBe(true); + + // Simple equality tests + expect(lexicographicCompare('a', 'a') === 0).toBe(true); + expect(lexicographicCompare('A', 'A') === 0).toBe(true); + + // ASCII rule testing + expect(lexicographicCompare('A', 'a') < 0).toBe(true); + expect(lexicographicCompare('a', 'A') > 0).toBe(true); + }); + }); + + describe('deepSortedObjectEntries', () => { + it('should auto-return non-objects', () => { + expect(deepSortedObjectEntries(42)).toEqual(42); + expect(deepSortedObjectEntries("not object")).toEqual("not object"); + expect(deepSortedObjectEntries(true)).toEqual(true); + expect(deepSortedObjectEntries([42])).toEqual([42]); + expect(deepSortedObjectEntries(null)).toEqual(null); + expect(deepSortedObjectEntries(undefined)).toEqual(undefined); + }); + + it('should sort objects appropriately', () => { + const input = { + a: 42, + b: { + d: {}, + a: "test", + b: "alpha", + }, + [72]: "test", + }; + const output = [ + ["72", "test"], + ["a", 42], + ["b", [ + ["a", "test"], + ["b", "alpha"], + ["d", []], + ]], + ]; + + expect(deepSortedObjectEntries(input)).toMatchObject(output); + }); + }); +}); diff --git a/src/service-types.js b/src/@types/another-json.ts similarity index 72% rename from src/service-types.js rename to src/@types/another-json.ts index 0803b9247..070332a5c 100644 --- a/src/service-types.js +++ b/src/@types/another-json.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2021 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. @@ -14,7 +14,6 @@ See the License for the specific language governing permissions and limitations under the License. */ -export const SERVICE_TYPES = Object.freeze({ - IS: 'SERVICE_TYPE_IS', // An Identity Service - IM: 'SERVICE_TYPE_IM', // An Integration Manager -}); +declare module "another-json" { + export function stringify(o: object): string; +} diff --git a/src/@types/event.ts b/src/@types/event.ts index 3c905442b..f7b22992e 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { UnstableValue } from "../NamespacedValue"; + export enum EventType { // Room state events RoomCanonicalAlias = "m.room.canonical_alias", @@ -71,6 +73,7 @@ export enum EventType { // Room account_data events FullyRead = "m.fully_read", Tag = "m.tag", + SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230 // User account_data events PushRules = "m.push_rules", @@ -84,6 +87,11 @@ export enum EventType { Dummy = "m.dummy", } +export enum RelationType { + Annotation = "m.annotation", + Replace = "m.replace", +} + export enum MsgType { Text = "m.text", Emote = "m.emote", @@ -100,3 +108,53 @@ export const RoomCreateTypeField = "type"; export enum RoomType { Space = "m.space", } + +/** + * Identifier for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_PURPOSE = new UnstableValue("m.room.purpose", "org.matrix.msc3088.purpose"); + +/** + * Enabled flag for an [MSC3088](https://github.com/matrix-org/matrix-doc/pull/3088) + * room purpose. Note that this reference is UNSTABLE and subject to breaking changes, + * including its eventual removal. + */ +export const UNSTABLE_MSC3088_ENABLED = new UnstableValue("m.enabled", "org.matrix.msc3088.enabled"); + +/** + * Subtype for an [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_TREE_SUBTYPE = new UnstableValue("m.data_tree", "org.matrix.msc3089.data_tree"); + +/** + * Leaf type for an event in a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. + * Note that this reference is UNSTABLE and subject to breaking changes, including its + * eventual removal. + */ +export const UNSTABLE_MSC3089_LEAF = new UnstableValue("m.leaf", "org.matrix.msc3089.leaf"); + +/** + * Branch (Leaf Reference) type for the index approach in a + * [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) space-room. Note that this reference is + * UNSTABLE and subject to breaking changes, including its eventual removal. + */ +export const UNSTABLE_MSC3089_BRANCH = new UnstableValue("m.branch", "org.matrix.msc3089.branch"); + +export interface IEncryptedFile { + url: string; + mimetype?: string; + key: { + alg: string; + key_ops: string[]; // eslint-disable-line camelcase + kty: string; + k: string; + ext: boolean; + }; + iv: string; + hashes: {[alg: string]: string}; + v: string; +} diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts index 337768428..dc39bd730 100644 --- a/src/@types/global.d.ts +++ b/src/@types/global.d.ts @@ -15,7 +15,7 @@ limitations under the License. */ // this is needed to tell TS about global.Olm -import * as Olm from "@matrix-org/olm"; // eslint-disable-line @typescript-eslint/no-unused-vars +import "@matrix-org/olm"; export {}; @@ -34,6 +34,10 @@ declare global { getDesktopCapturerSources(options: GetSourcesOptions): Promise>; } + interface Crypto { + webkitSubtle?: Window["crypto"]["subtle"]; + } + interface MediaDevices { // This is experimental and types don't know about it yet // https://github.com/microsoft/TypeScript/issues/33232 @@ -84,4 +88,17 @@ declare global { // on webkit: we should check if we still need to do this webkitGetUserMedia: DummyInterfaceWeShouldntBeUsingThis; } + + export interface ISettledFulfilled { + status: "fulfilled"; + value: T; + } + export interface ISettledRejected { + status: "rejected"; + reason: any; + } + + interface PromiseConstructor { + allSettled(promises: Promise[]): Promise | ISettledRejected>>; + } } diff --git a/src/@types/partials.ts b/src/@types/partials.ts index 4daa935d0..ecdc6525a 100644 --- a/src/@types/partials.ts +++ b/src/@types/partials.ts @@ -26,3 +26,16 @@ export interface IImageInfo { w?: number; h?: number; } + +export enum Visibility { + Public = "public", + Private = "private", +} + +export enum Preset { + PrivateChat = "private_chat", + TrustedPrivateChat = "trusted_private_chat", + PublicChat = "public_chat", +} + +export type ResizeMethod = "crop" | "scale"; diff --git a/src/@types/requests.ts b/src/@types/requests.ts index 581b4a1b6..eaf682831 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -15,6 +15,10 @@ limitations under the License. */ import { Callback } from "../client"; +import { Preset, Visibility } from "./partials"; + +// allow camelcase as these are things go onto the wire +/* eslint-disable camelcase */ export interface IJoinRoomOpts { /** @@ -40,12 +44,12 @@ export interface IRedactOpts { } export interface ISendEventResponse { - event_id: string; // eslint-disable-line camelcase + event_id: string; } export interface IPresenceOpts { presence: "online" | "offline" | "unavailable"; - status_msg?: string; // eslint-disable-line camelcase + status_msg?: string; } export interface IPaginateOpts { @@ -68,14 +72,32 @@ export interface IEventSearchOpts { term: string; } +export interface IInvite3PID { + id_server: string; + id_access_token?: string; // this gets injected by the js-sdk + medium: string; + address: string; +} + +export interface ICreateRoomStateEvent { + type: string; + state_key?: string; // defaults to an empty string + content: object; +} + export interface ICreateRoomOpts { - room_alias_name?: string; // eslint-disable-line camelcase - visibility?: "public" | "private"; + room_alias_name?: string; + visibility?: Visibility; name?: string; topic?: string; - preset?: string; - // TODO: Types (next line) - invite_3pid?: any[]; // eslint-disable-line camelcase + preset?: Preset; + power_level_content_override?: object; + creation_content?: object; + initial_state?: ICreateRoomStateEvent[]; + invite?: string[]; + invite_3pid?: IInvite3PID[]; + is_direct?: boolean; + room_version?: string; } export interface IRoomDirectoryOptions { @@ -84,7 +106,7 @@ export interface IRoomDirectoryOptions { since?: string; // TODO: Proper types - filter?: any & {generic_search_term: string}; // eslint-disable-line camelcase + filter?: any & {generic_search_term: string}; } export interface IUploadOpts { @@ -96,3 +118,5 @@ export interface IUploadOpts { callback?: Callback; progressHandler?: (state: {loaded: number, total: number}) => void; } + +/* eslint-enable camelcase */ diff --git a/src/NamespacedValue.ts b/src/NamespacedValue.ts new file mode 100644 index 000000000..d493f38aa --- /dev/null +++ b/src/NamespacedValue.ts @@ -0,0 +1,93 @@ +/* +Copyright 2021 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. +*/ + +/** + * Represents a simple Matrix namespaced value. This will assume that if a stable prefix + * is provided that the stable prefix should be used when representing the identifier. + */ +export class NamespacedValue { + // Stable is optional, but one of the two parameters is required, hence the weird-looking types. + // Goal is to to have developers explicitly say there is no stable value (if applicable). + public constructor(public readonly stable: S | null | undefined, public readonly unstable?: U) { + if (!this.unstable && !this.stable) { + throw new Error("One of stable or unstable values must be supplied"); + } + } + + public get name(): U | S { + if (this.stable) { + return this.stable; + } + return this.unstable; + } + + public get altName(): U | S | null { + if (!this.stable) { + return null; + } + return this.unstable; + } + + public matches(val: string): boolean { + return this.name === val || this.altName === val; + } + + // this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class + // so we can instantiate `NamespacedValue` as a default type for that namespace. + public findIn(obj: any): T { + let val: T; + if (this.name) { + val = obj?.[this.name]; + } + if (!val && this.altName) { + val = obj?.[this.altName]; + } + return val; + } + + public includedIn(arr: any[]): boolean { + let included = false; + if (this.name) { + included = arr.includes(this.name); + } + if (!included && this.altName) { + included = arr.includes(this.altName); + } + return included; + } +} + +/** + * Represents a namespaced value which prioritizes the unstable value over the stable + * value. + */ +export class UnstableValue extends NamespacedValue { + // Note: Constructor difference is that `unstable` is *required*. + public constructor(stable: S, unstable: U) { + super(stable, unstable); + if (!this.unstable) { + throw new Error("Unstable value must be supplied"); + } + } + + public get name(): U { + return this.unstable; + } + + public get altName(): S { + return this.stable; + } +} diff --git a/src/client.ts b/src/client.ts index f5a055e15..0cb5856ee 100644 --- a/src/client.ts +++ b/src/client.ts @@ -21,7 +21,7 @@ limitations under the License. import { EventEmitter } from "events"; import { SyncApi } from "./sync"; -import { EventStatus, MatrixEvent } from "./models/event"; +import { EventStatus, IDecryptOptions, MatrixEvent } from "./models/event"; import { StubStore } from "./store/stub"; import { createNewMatrixCall, MatrixCall } from "./webrtc/call"; import { Filter } from "./filter"; @@ -32,13 +32,14 @@ import { Group } from "./models/group"; import { EventTimeline } from "./models/event-timeline"; import { PushAction, PushProcessor } from "./pushprocessor"; import { AutoDiscovery } from "./autodiscovery"; -import { MatrixError } from "./http-api"; +import * as olmlib from "./crypto/olmlib"; import { decodeBase64, encodeBase64 } from "./crypto/olmlib"; import { ReEmitter } from './ReEmitter'; import { RoomList } from './crypto/RoomList'; import { logger } from './logger'; import { SERVICE_TYPES } from './service-types'; import { + MatrixError, MatrixHttpApi, PREFIX_IDENTITY_V2, PREFIX_MEDIA_R0, @@ -46,7 +47,8 @@ import { PREFIX_UNSTABLE, retryNetworkOperation, } from "./http-api"; -import { Crypto, DeviceInfo, fixBackupKey, isCryptoAvailable } from './crypto'; +import { Crypto, fixBackupKey, IBootstrapCrossSigningOpts, IMegolmSessionData, isCryptoAvailable } from './crypto'; +import { DeviceInfo, IDevice } from "./crypto/deviceinfo"; import { decodeRecoveryKey } from './crypto/recoverykey'; import { keyFromAuthData } from './crypto/key_passphrase'; import { User } from "./models/user"; @@ -57,13 +59,12 @@ import { IKeyBackupPrepareOpts, IKeyBackupRestoreOpts, IKeyBackupRestoreResult, - IKeyBackupTrustInfo, - IKeyBackupVersion, + IKeyBackupInfo, } from "./crypto/keybackup"; import { IIdentityServerProvider } from "./@types/IIdentityServerProvider"; import type Request from "request"; import { MatrixScheduler } from "./scheduler"; -import { ICryptoCallbacks, IDeviceTrustLevel, ISecretStorageKeyInfo } from "./matrix"; +import { ICryptoCallbacks, ISecretStorageKeyInfo, NotificationCountType } from "./matrix"; import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store"; import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store"; import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store"; @@ -84,7 +85,7 @@ import { IRecoveryKey, ISecretStorageKey, } from "./crypto/api"; -import { CrossSigningInfo, UserTrustLevel } from "./crypto/CrossSigning"; +import { CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from "./crypto/CrossSigning"; import { Room } from "./models/room"; import { ICreateRoomOpts, @@ -93,19 +94,29 @@ import { IJoinRoomOpts, IPaginateOpts, IPresenceOpts, - IRedactOpts, IRoomDirectoryOptions, + IRedactOpts, + IRoomDirectoryOptions, ISearchOpts, ISendEventResponse, IUploadOpts, } from "./@types/requests"; -import { EventType } from "./@types/event"; -import { IImageInfo } from "./@types/partials"; +import { + EventType, + RoomCreateTypeField, + RoomType, + UNSTABLE_MSC3088_ENABLED, + UNSTABLE_MSC3088_PURPOSE, + UNSTABLE_MSC3089_TREE_SUBTYPE, +} from "./@types/event"; +import { IImageInfo, Preset } from "./@types/partials"; import { EventMapper, eventMapperFor, MapperOpts } from "./event-mapper"; import url from "url"; import { randomString } from "./randomstring"; import { ReadStream } from "fs"; import { WebStorageSessionStore } from "./store/session/webstorage"; -import { BackupManager } from "./crypto/backup"; +import { BackupManager, IKeyBackupCheck, IPreparedKeyBackupVersion, TrustInfo } from "./crypto/backup"; +import { DEFAULT_TREE_POWER_LEVELS_TEMPLATE, MSC3089TreeSpace } from "./models/MSC3089TreeSpace"; +import { ISignatures } from "./@types/signed"; export type Store = StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; export type SessionStore = WebStorageSessionStore; @@ -131,6 +142,12 @@ interface IExportedDevice { deviceId: string; } +export interface IKeysUploadResponse { + one_time_key_counts: { // eslint-disable-line camelcase + [algorithm: string]: number; + }; +} + export interface ICreateClientOpts { baseUrl: string; @@ -281,6 +298,11 @@ export interface IMatrixClientCreateOpts extends ICreateClientOpts { usingExternalCrypto?: boolean; } +export enum PendingEventOrdering { + Chronological = "chronological", + Detached = "detached", +} + export interface IStartClientOpts { /** * The event limit= to apply to initial sync. Default: 8. @@ -303,7 +325,7 @@ export interface IStartClientOpts { * pending messages will appear in a separate list, accessbile via {@link module:models/room#getPendingEvents}. * Default: "chronological". */ - pendingEventOrdering?: "chronological" | "detached"; + pendingEventOrdering?: PendingEventOrdering; /** * The number of milliseconds to wait on /sync. Default: 30000 (30 seconds). @@ -339,6 +361,72 @@ export interface IStoredClientOpts extends IStartClientOpts { canResetEntireTimeline: ResetTimelineCallback; } +export enum RoomVersionStability { + Stable = "stable", + Unstable = "unstable", +} + +export interface IRoomVersionsCapability { + default: string; + available: Record; +} + +export interface IChangePasswordCapability { + enabled: boolean; +} + +interface ICapabilities { + [key: string]: any; + "m.change_password"?: IChangePasswordCapability; + "m.room_versions"?: IRoomVersionsCapability; +} + +/* eslint-disable camelcase */ +export interface ICrossSigningKey { + keys: { [algorithm: string]: string }; + signatures?: ISignatures; + usage: string[]; + user_id: string; +} + +enum CrossSigningKeyType { + MasterKey = "master_key", + SelfSigningKey = "self_signing_key", + UserSigningKey = "user_signing_key", +} + +export type CrossSigningKeys = Record; + +export interface ISignedKey { + keys: Record; + signatures: ISignatures; + user_id: string; + algorithms: string[]; + device_id: string; +} +/* eslint-enable camelcase */ + +export type KeySignatures = Record>; +interface IUploadKeySignaturesResponse { + failures: Record>; +} + +export interface IPreviewUrlResponse { + [key: string]: string | number; + "og:title": string; + "og:type": string; + "og:url": string; + "og:image"?: string; + "og:image:type"?: string; + "og:image:height"?: number; + "og:image:width"?: number; + "og:description"?: string; + "matrix:image:size"?: number; +} + /** * Represents a Matrix Client. Only directly construct this if you want to use * custom modules. Normally, {@link createClient} should be used @@ -384,7 +472,7 @@ export class MatrixClient extends EventEmitter { protected fallbackICEServerAllowed = false; protected roomList: RoomList; protected syncApi: SyncApi; - protected pushRules: any; // TODO: Types + public pushRules: any; // TODO: Types protected syncLeftRoomsPromise: Promise; protected syncedLeftRooms = false; protected clientOpts: IStoredClientOpts; @@ -399,7 +487,7 @@ export class MatrixClient extends EventEmitter { protected serverVersionsPromise: Promise; protected cachedCapabilities: { - capabilities: Record; + capabilities: ICapabilities; expiration: number; }; protected clientWellKnown: any; @@ -520,7 +608,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (!room) return; - const currentCount = room.getUnreadNotificationCount("highlight"); + const currentCount = room.getUnreadNotificationCount(NotificationCountType.Highlight); // Ensure the unread counts are kept up to date if the event is encrypted // We also want to make sure that the notification count goes up if we already @@ -536,12 +624,12 @@ export class MatrixClient extends EventEmitter { let newCount = currentCount; if (newHighlight && !oldHighlight) newCount++; if (!newHighlight && oldHighlight) newCount--; - room.setUnreadNotificationCount("highlight", newCount); + room.setUnreadNotificationCount(NotificationCountType.Highlight, newCount); // Fix 'Mentions Only' rooms from not having the right badge count - const totalCount = room.getUnreadNotificationCount('total'); + const totalCount = room.getUnreadNotificationCount(NotificationCountType.Total); if (totalCount < newCount) { - room.setUnreadNotificationCount('total', newCount); + room.setUnreadNotificationCount(NotificationCountType.Total, newCount); } } } @@ -806,7 +894,7 @@ export class MatrixClient extends EventEmitter { return; } // XXX: Private member access. - return await this.crypto._dehydrationManager.setKeyAndQueueDehydration( + return await this.crypto.dehydrationManager.setKeyAndQueueDehydration( key, keyInfo, deviceDisplayName, ); } @@ -829,11 +917,11 @@ export class MatrixClient extends EventEmitter { logger.warn('not dehydrating device if crypto is not enabled'); return; } - await this.crypto._dehydrationManager.setKey( + await this.crypto.dehydrationManager.setKey( key, keyInfo, deviceDisplayName, ); // XXX: Private member access. - return await this.crypto._dehydrationManager.dehydrateDevice(); + return await this.crypto.dehydrationManager.dehydrateDevice(); } public async exportDevice(): Promise { @@ -845,7 +933,7 @@ export class MatrixClient extends EventEmitter { userId: this.credentials.userId, deviceId: this.deviceId, // XXX: Private member access. - olmDevice: await this.crypto._olmDevice.export(), + olmDevice: await this.crypto.olmDevice.export(), }; } @@ -1049,7 +1137,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves to the capabilities of the homeserver * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public getCapabilities(fresh = false): Promise> { + public getCapabilities(fresh = false): Promise { const now = new Date().getTime(); if (this.cachedCapabilities && !fresh) { @@ -1067,7 +1155,7 @@ export class MatrixClient extends EventEmitter { return null; // otherwise consume the error }).then((r) => { if (!r) r = {}; - const capabilities = r["capabilities"] || {}; + const capabilities: ICapabilities = r["capabilities"] || {}; // If the capabilities missed the cache, cache it for a shorter amount // of time to try and refresh them later. @@ -1076,7 +1164,7 @@ export class MatrixClient extends EventEmitter { : 60000 + (Math.random() * 5000); this.cachedCapabilities = { - capabilities: capabilities, + capabilities, expiration: now + cacheMs, }; @@ -1209,12 +1297,12 @@ export class MatrixClient extends EventEmitter { * Upload the device keys to the homeserver. * @return {Promise} A promise that will resolve when the keys are uploaded. */ - public uploadKeys(): Promise { + public async uploadKeys(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto.uploadDeviceKeys(); + await this.crypto.uploadDeviceKeys(); } /** @@ -1229,7 +1317,7 @@ export class MatrixClient extends EventEmitter { public downloadKeys( userIds: string[], forceDownload?: boolean, - ): Promise>> { + ): Promise>> { if (!this.crypto) { return Promise.reject(new Error("End-to-end encryption disabled")); } @@ -1535,9 +1623,9 @@ export class MatrixClient extends EventEmitter { * @param {string} userId The ID of the user whose devices is to be checked. * @param {string} deviceId The ID of the device to check * - * @returns {IDeviceTrustLevel} + * @returns {DeviceTrustLevel} */ - public checkDeviceTrust(userId: string, deviceId: string): IDeviceTrustLevel { + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1601,7 +1689,7 @@ export class MatrixClient extends EventEmitter { * return true. * @return {boolean} True if cross-signing is ready to be used on this device */ - public isCrossSigningReady(): boolean { + public isCrossSigningReady(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1628,10 +1716,7 @@ export class MatrixClient extends EventEmitter { * auth data as an object. Can be called multiple times, first with an empty * authDict, to obtain the flows. */ - public bootstrapCrossSigning(opts: { - authUploadDeviceSigningKeys: (makeRequest: (authData: any) => void) => Promise, - setupNewCrossSigning?: boolean, - }) { + public bootstrapCrossSigning(opts: IBootstrapCrossSigningOpts) { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1726,7 +1811,7 @@ export class MatrixClient extends EventEmitter { * * @return {boolean} True if secret storage is ready to be used on this device */ - public isSecretStorageReady(): boolean { + public isSecretStorageReady(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1818,7 +1903,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} the contents of the secret */ - public getSecret(name: string): string { + public getSecret(name: string): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1855,7 +1940,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} the contents of the secret */ - public requestSecret(name: string, devices: string[]): string { + public requestSecret(name: string, devices: string[]): any { // TODO types if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1869,7 +1954,7 @@ export class MatrixClient extends EventEmitter { * * @return {string} The default key ID or null if no default key ID is set */ - public getDefaultSecretStorageKeyId(): string { + public getDefaultSecretStorageKeyId(): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -1915,7 +2000,7 @@ export class MatrixClient extends EventEmitter { * * @return {Promise} */ - public getEventSenderDeviceInfo(event: MatrixEvent): Promise { + public async getEventSenderDeviceInfo(event: MatrixEvent): Promise { if (!this.crypto) { return null; } @@ -2029,7 +2114,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} a promise which resolves when the keys * have been imported */ - public importRoomKeys(keys: any[], opts: IImportRoomKeysOpts): Promise { + public importRoomKeys(keys: IMegolmSessionData[], opts: IImportRoomKeysOpts): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } @@ -2045,15 +2130,15 @@ export class MatrixClient extends EventEmitter { * trust information (as returned by isKeyBackupTrusted) * in trustInfo. */ - public checkKeyBackup(): IKeyBackupVersion { - return this.crypto._backupManager.checkKeyBackup(); + public checkKeyBackup(): Promise { + return this.crypto.backupManager.checkKeyBackup(); } /** * Get information about the current key backup. * @returns {Promise} Information object from API or null */ - public async getKeyBackupVersion(): Promise { + public async getKeyBackupVersion(): Promise { let res; try { res = await this.http.authedRequest( @@ -2085,8 +2170,8 @@ export class MatrixClient extends EventEmitter { * ] * } */ - public isKeyBackupTrusted(info: IKeyBackupVersion): IKeyBackupTrustInfo { - return this.crypto._backupManager.isKeyBackupTrusted(info); + public isKeyBackupTrusted(info: IKeyBackupInfo): Promise { + return this.crypto.backupManager.isKeyBackupTrusted(info); } /** @@ -2098,7 +2183,7 @@ export class MatrixClient extends EventEmitter { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.getKeyBackupEnabled(); + return this.crypto.backupManager.getKeyBackupEnabled(); } /** @@ -2108,12 +2193,12 @@ export class MatrixClient extends EventEmitter { * @param {object} info Backup information object as returned by getKeyBackupVersion * @returns {Promise} Resolves when complete. */ - public enableKeyBackup(info: IKeyBackupVersion): Promise { + public enableKeyBackup(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.enableKeyBackup(info); + return this.crypto.backupManager.enableKeyBackup(info); } /** @@ -2124,7 +2209,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - this.crypto._backupManager.disableKeyBackup(); + this.crypto.backupManager.disableKeyBackup(); } /** @@ -2145,14 +2230,14 @@ export class MatrixClient extends EventEmitter { public async prepareKeyBackupVersion( password: string, opts: IKeyBackupPrepareOpts = { secureSecretStorage: false }, - ): Promise { + ): Promise> { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } // eslint-disable-next-line camelcase const { algorithm, auth_data, recovery_key, privateKey } = - await this.crypto._backupManager.prepareKeyBackupVersion(password); + await this.crypto.backupManager.prepareKeyBackupVersion(password); if (opts.secureSecretStorage) { await this.storeSecret("m.megolm_backup.v1", encodeBase64(privateKey)); @@ -2163,7 +2248,7 @@ export class MatrixClient extends EventEmitter { algorithm, auth_data, recovery_key, - } as any; // TODO: Types + }; } /** @@ -2184,12 +2269,12 @@ export class MatrixClient extends EventEmitter { * @returns {Promise} Object with 'version' param indicating the version created */ // TODO: Fix types - public async createKeyBackupVersion(info: IKeyBackupVersion): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { if (!this.crypto) { throw new Error("End-to-end encryption disabled"); } - await this.crypto._backupManager.createKeyBackupVersion(info); + await this.crypto.backupManager.createKeyBackupVersion(info); const data = { algorithm: info.algorithm, @@ -2200,19 +2285,19 @@ export class MatrixClient extends EventEmitter { // older devices with cross-signing. This can probably go away very soon in // favour of just signing with the cross-singing master key. // XXX: Private member access - await this.crypto._signObject(data.auth_data); + await this.crypto.signObject(data.auth_data); if ( this.cryptoCallbacks.getCrossSigningKey && // XXX: Private member access - this.crypto._crossSigningInfo.getId() + this.crypto.crossSigningInfo.getId() ) { // now also sign the auth data with the cross-signing master key // we check for the callback explicitly here because we still want to be able // to create an un-cross-signed key backup if there is a cross-signing key but // no callback supplied. // XXX: Private member access - await this.crypto._crossSigningInfo.signObject(data.auth_data, "master"); + await this.crypto.crossSigningInfo.signObject(data.auth_data, "master"); } const res = await this.http.authedRequest( @@ -2239,8 +2324,8 @@ export class MatrixClient extends EventEmitter { // If we're currently backing up to this backup... stop. // (We start using it automatically in createKeyBackupVersion // so this is symmetrical). - if (this.crypto._backupManager.version) { - this.crypto._backupManager.disableKeyBackup(); + if (this.crypto.backupManager.version) { + this.crypto.backupManager.disableKeyBackup(); } const path = utils.encodeUri("/room_keys/version/$version", { @@ -2278,7 +2363,7 @@ export class MatrixClient extends EventEmitter { * Back up session keys to the homeserver. * @param {string} roomId ID of the room that the keys are for Optional. * @param {string} sessionId ID of the session that the keys are for Optional. - * @param {integer} version backup version Optional. + * @param {number} version backup version Optional. * @param {object} data Object keys to send * @return {Promise} a promise that will resolve when the keys * are uploaded @@ -2305,7 +2390,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - await this.crypto._backupManager.scheduleAllGroupSessionsForBackup(); + await this.crypto.backupManager.scheduleAllGroupSessionsForBackup(); } /** @@ -2318,7 +2403,7 @@ export class MatrixClient extends EventEmitter { throw new Error("End-to-end encryption disabled"); } - return this.crypto._backupManager.flagAllGroupSessionsForBackup(); + return this.crypto.backupManager.flagAllGroupSessionsForBackup(); } public isValidRecoveryKey(recoveryKey: string): boolean { @@ -2340,7 +2425,7 @@ export class MatrixClient extends EventEmitter { * @param {object} backupInfo Backup metadata from `checkKeyBackup` * @return {Promise} key backup key */ - public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupVersion): Promise { + public keyBackupKeyFromPassword(password: string, backupInfo: IKeyBackupInfo): Promise { return keyFromAuthData(backupInfo.auth_data, password); } @@ -2375,7 +2460,7 @@ export class MatrixClient extends EventEmitter { password: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = await keyFromAuthData(backupInfo.auth_data, password); @@ -2399,7 +2484,7 @@ export class MatrixClient extends EventEmitter { */ // TODO: Types public async restoreKeyBackupWithSecretStorage( - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, targetRoomId?: string, targetSessionId?: string, opts?: IKeyBackupRestoreOpts, @@ -2439,36 +2524,32 @@ export class MatrixClient extends EventEmitter { recoveryKey: string, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts: IKeyBackupRestoreOpts, ): Promise { const privKey = decodeRecoveryKey(recoveryKey); - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); } // TODO: Types public async restoreKeyBackupWithCache( targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, - opts: IKeyBackupRestoreOpts, + backupInfo: IKeyBackupInfo, + opts?: IKeyBackupRestoreOpts, ): Promise { const privKey = await this.crypto.getSessionBackupPrivateKey(); if (!privKey) { throw new Error("Couldn't get key"); } - return this.restoreKeyBackup( - privKey, targetRoomId, targetSessionId, backupInfo, opts, - ); + return this.restoreKeyBackup(privKey, targetRoomId, targetSessionId, backupInfo, opts); } private async restoreKeyBackup( - privKey: Uint8Array, + privKey: ArrayLike, targetRoomId: string, targetSessionId: string, - backupInfo: IKeyBackupVersion, + backupInfo: IKeyBackupInfo, opts?: IKeyBackupRestoreOpts, ): Promise { const cacheCompleteCallback = opts?.cacheCompleteCallback; @@ -2603,7 +2684,7 @@ export class MatrixClient extends EventEmitter { } // XXX: Private member access - const alg = this.crypto._getRoomDecryptor(roomId, roomEncryption.algorithm); + const alg = this.crypto.getRoomDecryptor(roomId, roomEncryption.algorithm); if (alg.sendSharedHistoryInboundSessions) { await alg.sendSharedHistoryInboundSessions(devicesByUser); } else { @@ -2721,7 +2802,7 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves: TODO * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public setAccountData(eventType: string, content: any, callback?: Callback): Promise { + public setAccountData(eventType: EventType | string, content: any, callback?: Callback): Promise { const path = utils.encodeUri("/user/$userId/account_data/$type", { $userId: this.credentials.userId, $type: eventType, @@ -2740,7 +2821,7 @@ export class MatrixClient extends EventEmitter { * @param {string} eventType The event type * @return {?object} The contents of the given account data event */ - public getAccountData(eventType: string): any { + public getAccountData(eventType: string): MatrixEvent { return this.store.getAccountData(eventType); } @@ -3033,7 +3114,7 @@ export class MatrixClient extends EventEmitter { if (event && event.getType() === "m.room.power_levels") { // take a copy of the content to ensure we don't corrupt // existing client state with a failed power level change - content = utils.deepCopy(event.getContent()); + content = utils.deepCopy(event.getContent()) as typeof content; } content.users[userId] = powerLevel; const path = utils.encodeUri("/rooms/$roomId/state/m.room.power_levels", { @@ -3535,7 +3616,7 @@ export class MatrixClient extends EventEmitter { const room = this.getRoom(event.getRoomId()); if (room) { - room._addLocalEchoReceipt(this.credentials.userId, event, receiptType); + room.addLocalEchoReceipt(this.credentials.userId, event, receiptType); } return promise; } @@ -3605,7 +3686,7 @@ export class MatrixClient extends EventEmitter { throw new Error(`Cannot set read receipt to a pending event (${rrEventId})`); } if (room) { - room._addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); + room.addLocalEchoReceipt(this.credentials.userId, rrEvent, "m.read"); } } @@ -3627,11 +3708,15 @@ export class MatrixClient extends EventEmitter { * @return {module:http-api.MatrixError} Rejects: with an error response. * May return synthesized attributes if the URL lacked OG meta. */ - public getUrlPreview(url: string, ts: number, callback?: Callback): Promise { + public getUrlPreview(url: string, ts: number, callback?: Callback): Promise { // bucket the timestamp to the nearest minute to prevent excessive spam to the server // Surely 60-second accuracy is enough for anyone. ts = Math.floor(ts / 60000) * 60000; + const parsed = new URL(url); + parsed.hash = ""; // strip the hash as it won't affect the preview + url = parsed.toString(); + const key = ts + "_" + url; // If there's already a request in flight (or we've handled it), return that instead. @@ -3770,10 +3855,10 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @param {module:client.callback} callback Optional. * @param {string} reason Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise { + public invite(roomId: string, userId: string, callback?: Callback, reason?: string): Promise<{}> { return this.membershipChange(roomId, userId, "invite", reason, callback); } @@ -3782,10 +3867,10 @@ export class MatrixClient extends EventEmitter { * @param {string} roomId The room to invite the user to. * @param {string} email The email address to invite. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise { + public inviteByEmail(roomId: string, email: string, callback?: Callback): Promise<{}> { return this.inviteByThreePid(roomId, "email", email, callback); } @@ -3795,10 +3880,10 @@ export class MatrixClient extends EventEmitter { * @param {string} medium The medium to invite the user e.g. "email". * @param {string} address The address for the specified medium. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise { + public async inviteByThreePid(roomId: string, medium: string, address: string, callback?: Callback): Promise<{}> { const path = utils.encodeUri( "/rooms/$roomId/invite", { $roomId: roomId }, @@ -3834,10 +3919,10 @@ export class MatrixClient extends EventEmitter { /** * @param {string} roomId * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public leave(roomId: string, callback?: Callback): Promise { + public leave(roomId: string, callback?: Callback): Promise<{}> { return this.membershipChange(roomId, undefined, "leave", undefined, callback); } @@ -3905,10 +3990,10 @@ export class MatrixClient extends EventEmitter { * @param {boolean} deleteRoom True to delete the room from the store on success. * Default: true. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise { + public forget(roomId: string, deleteRoom?: boolean, callback?: Callback): Promise<{}> { if (deleteRoom === undefined) { deleteRoom = true; } @@ -3953,10 +4038,10 @@ export class MatrixClient extends EventEmitter { * @param {string} userId * @param {string} reason Optional. * @param {module:client.callback} callback Optional. - * @return {Promise} Resolves: TODO + * @return {Promise} Resolves: {} an empty object. * @return {module:http-api.MatrixError} Rejects: with an error response. */ - public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise { + public kick(roomId: string, userId: string, reason?: string, callback?: Callback): Promise<{}> { return this.setMembershipState(roomId, userId, "leave", reason, callback); } @@ -4000,7 +4085,7 @@ export class MatrixClient extends EventEmitter { membership: string, reason?: string, callback?: Callback, - ): Promise { + ): Promise<{}> { if (utils.isFunction(reason)) { callback = reason as any as Callback; // legacy reason = undefined; @@ -4221,7 +4306,7 @@ export class MatrixClient extends EventEmitter { // reduce the required number of events appropriately limit = limit - numAdded; - const prom = new Promise((resolve, reject) => { + const prom = new Promise((resolve, reject) => { // wait for a time before doing this request // (which may be 0 in order not to special case the code paths) sleep(timeToWaitMs).then(() => { @@ -4287,7 +4372,7 @@ export class MatrixClient extends EventEmitter { * {@link module:models/event-timeline~EventTimeline} including the given * event */ - public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): EventTimeline { + public getEventTimeline(timelineSet: EventTimelineSet, eventId: string): Promise { // don't allow any timeline support unless it's been enabled. if (!this.timelineSupport) { throw new Error("timeline support is disabled. Set the 'timelineSupport'" + @@ -4396,7 +4481,7 @@ export class MatrixClient extends EventEmitter { // XXX: it's horrific that /messages' filter parameter doesn't match // /sync's one - see https://matrix.org/jira/browse/SPEC-451 filter = filter || {}; - Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()); + Object.assign(filter, timelineFilter.getRoomTimelineFilterComponent()?.toJSON()); } if (filter) { params.filter = JSON.stringify(filter); @@ -4439,7 +4524,7 @@ export class MatrixClient extends EventEmitter { return Promise.resolve(false); } - const pendingRequest = eventTimeline._paginationRequests[dir]; + const pendingRequest = eventTimeline.paginationRequests[dir]; if (pendingRequest) { // already a request in progress - return the existing promise @@ -4488,9 +4573,9 @@ export class MatrixClient extends EventEmitter { } return res.next_token ? true : false; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } else { const room = this.getRoom(eventTimeline.getRoomId()); if (!room) { @@ -4522,9 +4607,9 @@ export class MatrixClient extends EventEmitter { } return res.end != res.start; }).finally(() => { - eventTimeline._paginationRequests[dir] = null; + eventTimeline.paginationRequests[dir] = null; }); - eventTimeline._paginationRequests[dir] = promise; + eventTimeline.paginationRequests[dir] = promise; } return promise; @@ -5576,15 +5661,21 @@ export class MatrixClient extends EventEmitter { /** * Query the server to see if it is forcing encryption to be enabled for * a given room preset, based on the /versions response. - * @param {string} presetName The name of the preset to check. + * @param {Preset} presetName The name of the preset to check. * @returns {Promise} true if the server is forcing encryption * for the preset. */ - public async doesServerForceEncryptionForPreset(presetName: string): Promise { + public async doesServerForceEncryptionForPreset(presetName: Preset): Promise { const response = await this.getVersions(); if (!response) return false; const unstableFeatures = response["unstable_features"]; - return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${presetName}`]; + + // The preset name in the versions response will be without the _chat suffix. + const versionsPresetName = presetName.includes("_chat") + ? presetName.substring(0, presetName.indexOf("_chat")) + : presetName; + + return unstableFeatures && !!unstableFeatures[`io.element.e2ee_forced.${versionsPresetName}`]; } /** @@ -5672,7 +5763,7 @@ export class MatrixClient extends EventEmitter { */ public getCrossSigningCacheCallbacks(): any { // TODO: Types // XXX: Private member access - return this.crypto?._crossSigningInfo.getCacheCallbacks(); + return this.crypto?.crossSigningInfo.getCacheCallbacks(); } /** @@ -5692,13 +5783,13 @@ export class MatrixClient extends EventEmitter { * @param {boolean} options.isRetry True if this is a retry (enables more logging) * @param {boolean} options.emit Emits "event.decrypted" if set to true */ - public decryptEventIfNeeded(event: MatrixEvent, options?: { emit: boolean, isRetry: boolean }): Promise { + public decryptEventIfNeeded(event: MatrixEvent, options?: IDecryptOptions): Promise { if (event.shouldAttemptDecryption()) { event.attemptDecryption(this.crypto, options); } if (event.isBeingDecrypted()) { - return event._decryptionPromise; + return event.getDecryptionPromise(); } else { return Promise.resolve(); } @@ -7051,11 +7142,11 @@ export class MatrixClient extends EventEmitter { * @return {Promise} Resolves: result object. Rejects: with * an error response ({@link module:http-api.MatrixError}). */ - public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise { // TODO: Types + public uploadKeysRequest(content: any, opts?: any, callback?: Callback): Promise { return this.http.authedRequest(callback, "POST", "/keys/upload", undefined, content); } - public uploadKeySignatures(content: any): Promise { // TODO: Types + public uploadKeySignatures(content: KeySignatures): Promise { return this.http.authedRequest( undefined, "POST", '/keys/signatures/upload', undefined, content, { @@ -7154,7 +7245,7 @@ export class MatrixClient extends EventEmitter { return this.http.authedRequest(undefined, "GET", path, qps, undefined); } - public uploadDeviceSigningKeys(auth: any, keys: any): Promise { // TODO: Lots of types + public uploadDeviceSigningKeys(auth: any, keys: CrossSigningKeys): Promise<{}> { // TODO: types const data = Object.assign({}, keys); if (auth) Object.assign(data, { auth }); return this.http.authedRequest( @@ -7566,7 +7657,11 @@ export class MatrixClient extends EventEmitter { * supplied. * @return {Promise} Resolves to the result object */ - public sendToDevice(eventType: string, contentMap: any, txnId?: string): Promise { // TODO: Types + public sendToDevice( + eventType: string, + contentMap: { [userId: string]: { [deviceId: string]: Record } }, + txnId?: string, + ): Promise<{}> { const path = utils.encodeUri("/sendToDevice/$eventType/$txnId", { $eventType: eventType, $txnId: txnId ? txnId : this.makeTxnId(), @@ -7708,6 +7803,73 @@ export class MatrixClient extends EventEmitter { }); } + /** + * Creates a new file tree space with the given name. The client will pick + * defaults for how it expects to be able to support the remaining API offered + * by the returned class. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} name The name of the tree space. + * @returns {Promise} Resolves to the created space. + */ + public async unstableCreateFileTree(name: string): Promise { + const { room_id: roomId } = await this.createRoom({ + name: name, + preset: Preset.PrivateChat, + power_level_content_override: { + ...DEFAULT_TREE_POWER_LEVELS_TEMPLATE, + users: { + [this.getUserId()]: 100, + }, + }, + creation_content: { + [RoomCreateTypeField]: RoomType.Space, + }, + initial_state: [ + { + type: UNSTABLE_MSC3088_PURPOSE.name, + state_key: UNSTABLE_MSC3089_TREE_SUBTYPE.name, + content: { + [UNSTABLE_MSC3088_ENABLED.name]: true, + }, + }, + { + type: EventType.RoomEncryption, + state_key: "", + content: { + algorithm: olmlib.MEGOLM_ALGORITHM, + }, + }, + ], + }); + return new MSC3089TreeSpace(this, roomId); + } + + /** + * Gets a reference to a tree space, if the room ID given is a tree space. If the room + * does not appear to be a tree space then null is returned. + * + * Note that this is UNSTABLE and may have breaking changes without notice. + * @param {string} roomId The room ID to get a tree space reference for. + * @returns {MSC3089TreeSpace} The tree space, or null if not a tree space. + */ + public unstableGetFileTreeSpace(roomId: string): MSC3089TreeSpace { + const room = this.getRoom(roomId); + if (!room) return null; + + const createEvent = room.currentState.getStateEvents(EventType.RoomCreate, ""); + const purposeEvent = room.currentState.getStateEvents( + UNSTABLE_MSC3088_PURPOSE.name, + UNSTABLE_MSC3089_TREE_SUBTYPE.name); + + if (!createEvent) throw new Error("Expected single room create event"); + + if (!purposeEvent?.getContent()?.[UNSTABLE_MSC3088_ENABLED.name]) return null; + if (createEvent.getContent()?.[RoomCreateTypeField] !== RoomType.Space) return null; + + return new MSC3089TreeSpace(this, roomId); + } + // TODO: Remove this warning, alongside the functions // See https://github.com/vector-im/element-web/issues/17532 // ====================================================== diff --git a/src/content-helpers.js b/src/content-helpers.ts similarity index 86% rename from src/content-helpers.js rename to src/content-helpers.ts index c82f808c5..061073c5e 100644 --- a/src/content-helpers.js +++ b/src/content-helpers.ts @@ -1,6 +1,6 @@ /* Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018 - 2021 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. @@ -23,7 +23,7 @@ limitations under the License. * @param {string} htmlBody the HTML representation of the message * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlMessage(body, htmlBody) { +export function makeHtmlMessage(body: string, htmlBody: string) { return { msgtype: "m.text", format: "org.matrix.custom.html", @@ -38,7 +38,7 @@ export function makeHtmlMessage(body, htmlBody) { * @param {string} htmlBody the HTML representation of the notice * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlNotice(body, htmlBody) { +export function makeHtmlNotice(body: string, htmlBody: string) { return { msgtype: "m.notice", format: "org.matrix.custom.html", @@ -53,7 +53,7 @@ export function makeHtmlNotice(body, htmlBody) { * @param {string} htmlBody the HTML representation of the emote * @returns {{msgtype: string, format: string, body: string, formatted_body: string}} */ -export function makeHtmlEmote(body, htmlBody) { +export function makeHtmlEmote(body: string, htmlBody: string) { return { msgtype: "m.emote", format: "org.matrix.custom.html", @@ -67,7 +67,7 @@ export function makeHtmlEmote(body, htmlBody) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeTextMessage(body) { +export function makeTextMessage(body: string) { return { msgtype: "m.text", body: body, @@ -79,7 +79,7 @@ export function makeTextMessage(body) { * @param {string} body the plaintext body of the notice * @returns {{msgtype: string, body: string}} */ -export function makeNotice(body) { +export function makeNotice(body: string) { return { msgtype: "m.notice", body: body, @@ -91,7 +91,7 @@ export function makeNotice(body) { * @param {string} body the plaintext body of the emote * @returns {{msgtype: string, body: string}} */ -export function makeEmoteMessage(body) { +export function makeEmoteMessage(body: string) { return { msgtype: "m.emote", body: body, diff --git a/src/content-repo.js b/src/content-repo.ts similarity index 79% rename from src/content-repo.js rename to src/content-repo.ts index 1b92d59ae..287259651 100644 --- a/src/content-repo.js +++ b/src/content-repo.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -34,8 +33,14 @@ import * as utils from "./utils"; * for such URLs. * @return {string} The complete URL to the content. */ -export function getHttpUriForMxc(baseUrl, mxc, width, height, - resizeMethod, allowDirectLinks) { +export function getHttpUriForMxc( + baseUrl: string, + mxc: string, + width: number, + height: number, + resizeMethod: string, + allowDirectLinks = false, +): string { if (typeof mxc !== "string" || !mxc) { return ''; } @@ -51,13 +56,13 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, const params = {}; if (width) { - params.width = Math.round(width); + params["width"] = Math.round(width); } if (height) { - params.height = Math.round(height); + params["height"] = Math.round(height); } if (resizeMethod) { - params.method = resizeMethod; + params["method"] = resizeMethod; } if (Object.keys(params).length > 0) { // these are thumbnailing params so they probably want the @@ -71,7 +76,7 @@ export function getHttpUriForMxc(baseUrl, mxc, width, height, fragment = serverAndMediaId.substr(fragmentOffset); serverAndMediaId = serverAndMediaId.substr(0, fragmentOffset); } - return baseUrl + prefix + serverAndMediaId + - (Object.keys(params).length === 0 ? "" : - ("?" + utils.encodeParams(params))) + fragment; + + const urlParams = (Object.keys(params).length === 0 ? "" : ("?" + utils.encodeParams(params))); + return baseUrl + prefix + serverAndMediaId + urlParams + fragment; } diff --git a/src/crypto/CrossSigning.js b/src/crypto/CrossSigning.ts similarity index 74% rename from src/crypto/CrossSigning.js rename to src/crypto/CrossSigning.ts index e2af6ce3b..aacb47148 100644 --- a/src/crypto/CrossSigning.js +++ b/src/crypto/CrossSigning.ts @@ -1,6 +1,5 @@ /* -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2019 - 2021 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. @@ -20,22 +19,44 @@ limitations under the License. * @module crypto/CrossSigning */ -import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib'; import { EventEmitter } from 'events'; + +import { decodeBase64, encodeBase64, pkSign, pkVerify } from './olmlib'; import { logger } from '../logger'; import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store'; import { decryptAES, encryptAES } from './aes'; +import { PkSigning } from "@matrix-org/olm"; +import { DeviceInfo } from "./deviceinfo"; +import { SecretStorage } from "./SecretStorage"; +import { CryptoStore, ICrossSigningKey, ISignedKey, MatrixClient } from "../client"; +import { OlmDevice } from "./OlmDevice"; +import { ICryptoCallbacks } from "../matrix"; +import { ISignatures } from "../@types/signed"; const KEY_REQUEST_TIMEOUT_MS = 1000 * 60; -function publicKeyFromKeyInfo(keyInfo) { +function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string { // `keys` is an object with { [`ed25519:${pubKey}`]: pubKey } // We assume only a single key, and we want the bare form without type // prefix, so we select the values. return Object.values(keyInfo.keys)[0]; } +export interface ICacheCallbacks { + getCrossSigningKeyCache?(type: string, expectedPublicKey?: string): Promise; + storeCrossSigningKeyCache?(type: string, key: Uint8Array): Promise; +} + export class CrossSigningInfo extends EventEmitter { + public keys: Record = {}; + public firstUse = true; + // This tracks whether we've ever verified this user with any identity. + // When you verify a user, any devices online at the time that receive + // the verifying signature via the homeserver will latch this to true + // and can use it in the future to detect cases where the user has + // become unverified later for any reason. + private crossSigningVerifiedBefore = false; + /** * Information about a user's cross-signing keys * @@ -46,27 +67,15 @@ export class CrossSigningInfo extends EventEmitter { * Requires getCrossSigningKey and saveCrossSigningKeys * @param {object} cacheCallbacks Callbacks used to interact with the cache */ - constructor(userId, callbacks, cacheCallbacks) { + constructor( + public readonly userId: string, + private callbacks: ICryptoCallbacks = {}, + private cacheCallbacks: ICacheCallbacks = {}, + ) { super(); - - // you can't change the userId - Object.defineProperty(this, 'userId', { - enumerable: true, - value: userId, - }); - this._callbacks = callbacks || {}; - this._cacheCallbacks = cacheCallbacks || {}; - this.keys = {}; - this.firstUse = true; - // This tracks whether we've ever verified this user with any identity. - // When you verify a user, any devices online at the time that receive - // the verifying signature via the homeserver will latch this to true - // and can use it in the future to detect cases where the user has - // become unverifed later for any reason. - this.crossSigningVerifiedBefore = false; } - static fromStorage(obj, userId) { + public static fromStorage(obj: object, userId: string): CrossSigningInfo { const res = new CrossSigningInfo(userId); for (const prop in obj) { if (obj.hasOwnProperty(prop)) { @@ -76,7 +85,7 @@ export class CrossSigningInfo extends EventEmitter { return res; } - toStorage() { + public toStorage(): object { return { keys: this.keys, firstUse: this.firstUse, @@ -92,10 +101,10 @@ export class CrossSigningInfo extends EventEmitter { * the stored public key for the given key type. * @returns {Array} An array with [ public key, Olm.PkSigning ] */ - async getCrossSigningKey(type, expectedPubkey) { + public async getCrossSigningKey(type: string, expectedPubkey?: string): Promise<[string, PkSigning]> { const shouldCache = ["master", "self_signing", "user_signing"].indexOf(type) >= 0; - if (!this._callbacks.getCrossSigningKey) { + if (!this.callbacks.getCrossSigningKey) { throw new Error("No getCrossSigningKey callback supplied"); } @@ -103,7 +112,7 @@ export class CrossSigningInfo extends EventEmitter { expectedPubkey = this.getId(type); } - function validateKey(key) { + function validateKey(key: Uint8Array): [string, PkSigning] { if (!key) return; const signing = new global.Olm.PkSigning(); const gotPubkey = signing.init_with_seed(key); @@ -114,9 +123,8 @@ export class CrossSigningInfo extends EventEmitter { } let privkey; - if (this._cacheCallbacks.getCrossSigningKeyCache && shouldCache) { - privkey = await this._cacheCallbacks - .getCrossSigningKeyCache(type, expectedPubkey); + if (this.cacheCallbacks.getCrossSigningKeyCache && shouldCache) { + privkey = await this.cacheCallbacks.getCrossSigningKeyCache(type, expectedPubkey); } const cacheresult = validateKey(privkey); @@ -124,11 +132,11 @@ export class CrossSigningInfo extends EventEmitter { return cacheresult; } - privkey = await this._callbacks.getCrossSigningKey(type, expectedPubkey); + privkey = await this.callbacks.getCrossSigningKey(type, expectedPubkey); const result = validateKey(privkey); if (result) { - if (this._cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { - await this._cacheCallbacks.storeCrossSigningKeyCache(type, privkey); + if (this.cacheCallbacks.storeCrossSigningKeyCache && shouldCache) { + await this.cacheCallbacks.storeCrossSigningKeyCache(type, privkey); } return result; } @@ -156,10 +164,9 @@ export class CrossSigningInfo extends EventEmitter { * with, or null if it is not present or not encrypted with a trusted * key */ - async isStoredInSecretStorage(secretStorage) { + public async isStoredInSecretStorage(secretStorage: SecretStorage): Promise> { // check what SSSS keys have encrypted the master key (if any) - const stored = - await secretStorage.isStored("m.cross_signing.master", false) || {}; + const stored = await secretStorage.isStored("m.cross_signing.master", false) || {}; // then check which of those SSSS keys have also encrypted the SSK and USK function intersect(s) { for (const k of Object.keys(stored)) { @@ -169,9 +176,7 @@ export class CrossSigningInfo extends EventEmitter { } } for (const type of ["self_signing", "user_signing"]) { - intersect( - await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}, - ); + intersect(await secretStorage.isStored(`m.cross_signing.${type}`, false) || {}); } return Object.keys(stored).length ? stored : null; } @@ -184,7 +189,10 @@ export class CrossSigningInfo extends EventEmitter { * @param {Map} keys The keys to store * @param {SecretStorage} secretStorage The secret store using account data */ - static async storeInSecretStorage(keys, secretStorage) { + public static async storeInSecretStorage( + keys: Map, + secretStorage: SecretStorage, + ): Promise { for (const [type, privateKey] of keys) { const encodedKey = encodeBase64(privateKey); await secretStorage.store(`m.cross_signing.${type}`, encodedKey); @@ -200,7 +208,7 @@ export class CrossSigningInfo extends EventEmitter { * @param {SecretStorage} secretStorage The secret store using account data * @return {Uint8Array} The private key */ - static async getFromSecretStorage(type, secretStorage) { + public static async getFromSecretStorage(type: string, secretStorage: SecretStorage): Promise { const encodedKey = await secretStorage.get(`m.cross_signing.${type}`); if (!encodedKey) { return null; @@ -215,8 +223,8 @@ export class CrossSigningInfo extends EventEmitter { * "self_signing", or "user_signing". Optional, will check all by default. * @returns {boolean} True if all keys are stored in the local cache. */ - async isStoredInKeyCache(type) { - const cacheCallbacks = this._cacheCallbacks; + public async isStoredInKeyCache(type?: string): Promise { + const cacheCallbacks = this.cacheCallbacks; if (!cacheCallbacks) return false; const types = type ? [type] : ["master", "self_signing", "user_signing"]; for (const t of types) { @@ -232,9 +240,9 @@ export class CrossSigningInfo extends EventEmitter { * * @returns {Map} A map from key type (string) to private key (Uint8Array) */ - async getCrossSigningKeysFromCache() { + public async getCrossSigningKeysFromCache(): Promise> { const keys = new Map(); - const cacheCallbacks = this._cacheCallbacks; + const cacheCallbacks = this.cacheCallbacks; if (!cacheCallbacks) return keys; for (const type of ["master", "self_signing", "user_signing"]) { const privKey = await cacheCallbacks.getCrossSigningKeyCache(type); @@ -255,8 +263,7 @@ export class CrossSigningInfo extends EventEmitter { * * @return {string} the ID */ - getId(type) { - type = type || "master"; + public getId(type = "master"): string { if (!this.keys[type]) return null; const keyInfo = this.keys[type]; return publicKeyFromKeyInfo(keyInfo); @@ -269,8 +276,8 @@ export class CrossSigningInfo extends EventEmitter { * * @param {CrossSigningLevel} level The key types to reset */ - async resetKeys(level) { - if (!this._callbacks.saveCrossSigningKeys) { + public async resetKeys(level?: CrossSigningLevel): Promise { + if (!this.callbacks.saveCrossSigningKeys) { throw new Error("No saveCrossSigningKeys callback supplied"); } @@ -285,12 +292,12 @@ export class CrossSigningInfo extends EventEmitter { CrossSigningLevel.USER_SIGNING | CrossSigningLevel.SELF_SIGNING ); - } else if (level === 0) { + } else if (level === 0 as CrossSigningLevel) { return; } - const privateKeys = {}; - const keys = {}; + const privateKeys: Record = {}; + const keys: Record = {}; // TODO types let masterSigning; let masterPub; @@ -347,7 +354,7 @@ export class CrossSigningInfo extends EventEmitter { } Object.assign(this.keys, keys); - this._callbacks.saveCrossSigningKeys(privateKeys); + this.callbacks.saveCrossSigningKeys(privateKeys); } finally { if (masterSigning) { masterSigning.free(); @@ -358,12 +365,12 @@ export class CrossSigningInfo extends EventEmitter { /** * unsets the keys, used when another session has reset the keys, to disable cross-signing */ - clearKeys() { + public clearKeys(): void { this.keys = {}; } - setKeys(keys) { - const signingKeys = {}; + public setKeys(keys: Record): void { + const signingKeys: Record = {}; if (keys.master) { if (keys.master.user_id !== this.userId) { const error = "Mismatched user ID " + keys.master.user_id + @@ -434,7 +441,7 @@ export class CrossSigningInfo extends EventEmitter { } } - updateCrossSigningVerifiedBefore(isCrossSigningVerified) { + public updateCrossSigningVerifiedBefore(isCrossSigningVerified: boolean): void { // It is critical that this value latches forward from false to true but // never back to false to avoid a downgrade attack. if (!this.crossSigningVerifiedBefore && isCrossSigningVerified) { @@ -442,7 +449,7 @@ export class CrossSigningInfo extends EventEmitter { } } - async signObject(data, type) { + public async signObject(data: T, type: string): Promise { if (!this.keys[type]) { throw new Error( "Attempted to sign with " + type + " key but no such key present", @@ -451,13 +458,13 @@ export class CrossSigningInfo extends EventEmitter { const [pubkey, signing] = await this.getCrossSigningKey(type); try { pkSign(data, signing, this.userId, pubkey); - return data; + return data as T & { signatures: ISignatures }; } finally { signing.free(); } } - async signUser(key) { + public async signUser(key: CrossSigningInfo): Promise { if (!this.keys.user_signing) { logger.info("No user signing key: not signing user"); return; @@ -465,7 +472,7 @@ export class CrossSigningInfo extends EventEmitter { return this.signObject(key.keys.master, "user_signing"); } - async signDevice(userId, device) { + public async signDevice(userId: string, device: DeviceInfo): Promise { if (userId !== this.userId) { throw new Error( `Trying to sign ${userId}'s device; can only sign our own device`, @@ -475,7 +482,7 @@ export class CrossSigningInfo extends EventEmitter { logger.info("No self signing key: not signing device"); return; } - return this.signObject( + return this.signObject>( { algorithms: device.algorithms, keys: device.keys, @@ -492,7 +499,7 @@ export class CrossSigningInfo extends EventEmitter { * * @returns {UserTrustLevel} */ - checkUserTrust(userCrossSigning) { + public checkUserTrust(userCrossSigning: CrossSigningInfo): UserTrustLevel { // if we're checking our own key, then it's trusted if the master key // and self-signing key match if (this.userId === userCrossSigning.userId @@ -530,12 +537,17 @@ export class CrossSigningInfo extends EventEmitter { * * @param {CrossSigningInfo} userCrossSigning Cross signing info for user * @param {module:crypto/deviceinfo} device The device to check - * @param {bool} localTrust Whether the device is trusted locally - * @param {bool} trustCrossSignedDevices Whether we trust cross signed devices + * @param {boolean} localTrust Whether the device is trusted locally + * @param {boolean} trustCrossSignedDevices Whether we trust cross signed devices * * @returns {DeviceTrustLevel} */ - checkDeviceTrust(userCrossSigning, device, localTrust, trustCrossSignedDevices) { + public checkDeviceTrust( + userCrossSigning: CrossSigningInfo, + device: DeviceInfo, + localTrust: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { const userTrust = this.checkUserTrust(userCrossSigning); const userSSK = userCrossSigning.keys.self_signing; @@ -552,29 +564,23 @@ export class CrossSigningInfo extends EventEmitter { // if we can verify the user's SSK from their master key... pkVerify(userSSK, userCrossSigning.getId(), userCrossSigning.userId); // ...and this device's key from their SSK... - pkVerify( - deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId, - ); + pkVerify(deviceObj, publicKeyFromKeyInfo(userSSK), userCrossSigning.userId); // ...then we trust this device as much as far as we trust the user - return DeviceTrustLevel.fromUserTrustLevel( - userTrust, localTrust, trustCrossSignedDevices, - ); + return DeviceTrustLevel.fromUserTrustLevel(userTrust, localTrust, trustCrossSignedDevices); } catch (e) { - return new DeviceTrustLevel( - false, false, localTrust, trustCrossSignedDevices, - ); + return new DeviceTrustLevel(false, false, localTrust, trustCrossSignedDevices); } } /** * @returns {object} Cache callbacks */ - getCacheCallbacks() { - return this._cacheCallbacks; + public getCacheCallbacks(): ICacheCallbacks { + return this.cacheCallbacks; } } -function deviceToObject(device, userId) { +function deviceToObject(device: DeviceInfo, userId: string) { return { algorithms: device.algorithms, keys: device.keys, @@ -584,49 +590,49 @@ function deviceToObject(device, userId) { }; } -export const CrossSigningLevel = { - MASTER: 4, - USER_SIGNING: 2, - SELF_SIGNING: 1, -}; +export enum CrossSigningLevel { + MASTER = 4, + USER_SIGNING = 2, + SELF_SIGNING = 1, +} /** * Represents the ways in which we trust a user */ export class UserTrustLevel { - constructor(crossSigningVerified, crossSigningVerifiedBefore, tofu) { - this._crossSigningVerified = crossSigningVerified; - this._crossSigningVerifiedBefore = crossSigningVerifiedBefore; - this._tofu = tofu; - } + constructor( + private readonly crossSigningVerified: boolean, + private readonly crossSigningVerifiedBefore: boolean, + private readonly tofu: boolean, + ) {} /** - * @returns {bool} true if this user is verified via any means + * @returns {boolean} true if this user is verified via any means */ - isVerified() { + public isVerified(): boolean { return this.isCrossSigningVerified(); } /** - * @returns {bool} true if this user is verified via cross signing + * @returns {boolean} true if this user is verified via cross signing */ - isCrossSigningVerified() { - return this._crossSigningVerified; + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; } /** - * @returns {bool} true if we ever verified this user before (at least for + * @returns {boolean} true if we ever verified this user before (at least for * the history of verifications observed by this device). */ - wasCrossSigningVerified() { - return this._crossSigningVerifiedBefore; + public wasCrossSigningVerified(): boolean { + return this.crossSigningVerifiedBefore; } /** - * @returns {bool} true if this user's key is trusted on first use + * @returns {boolean} true if this user's key is trusted on first use */ - isTofu() { - return this._tofu; + public isTofu(): boolean { + return this.tofu; } } @@ -634,58 +640,62 @@ export class UserTrustLevel { * Represents the ways in which we trust a device */ export class DeviceTrustLevel { - constructor(crossSigningVerified, tofu, localVerified, trustCrossSignedDevices) { - this._crossSigningVerified = crossSigningVerified; - this._tofu = tofu; - this._localVerified = localVerified; - this._trustCrossSignedDevices = trustCrossSignedDevices; - } + constructor( + public readonly crossSigningVerified: boolean, + public readonly tofu: boolean, + private readonly localVerified: boolean, + private readonly trustCrossSignedDevices: boolean, + ) {} - static fromUserTrustLevel(userTrustLevel, localVerified, trustCrossSignedDevices) { + public static fromUserTrustLevel( + userTrustLevel: UserTrustLevel, + localVerified: boolean, + trustCrossSignedDevices: boolean, + ): DeviceTrustLevel { return new DeviceTrustLevel( - userTrustLevel._crossSigningVerified, - userTrustLevel._tofu, + userTrustLevel.isCrossSigningVerified(), + userTrustLevel.isTofu(), localVerified, trustCrossSignedDevices, ); } /** - * @returns {bool} true if this device is verified via any means + * @returns {boolean} true if this device is verified via any means */ - isVerified() { + public isVerified(): boolean { return Boolean(this.isLocallyVerified() || ( - this._trustCrossSignedDevices && this.isCrossSigningVerified() + this.trustCrossSignedDevices && this.isCrossSigningVerified() )); } /** - * @returns {bool} true if this device is verified via cross signing + * @returns {boolean} true if this device is verified via cross signing */ - isCrossSigningVerified() { - return this._crossSigningVerified; + public isCrossSigningVerified(): boolean { + return this.crossSigningVerified; } /** - * @returns {bool} true if this device is verified locally + * @returns {boolean} true if this device is verified locally */ - isLocallyVerified() { - return this._localVerified; + public isLocallyVerified(): boolean { + return this.localVerified; } /** - * @returns {bool} true if this device is trusted from a user's key + * @returns {boolean} true if this device is trusted from a user's key * that is trusted on first use */ - isTofu() { - return this._tofu; + public isTofu(): boolean { + return this.tofu; } } -export function createCryptoStoreCacheCallbacks(store, olmdevice) { +export function createCryptoStoreCacheCallbacks(store: CryptoStore, olmDevice: OlmDevice): ICacheCallbacks { return { - getCrossSigningKeyCache: async function(type, _expectedPublicKey) { - const key = await new Promise((resolve) => { + getCrossSigningKeyCache: async function(type: string, _expectedPublicKey: string): Promise { + const key = await new Promise((resolve) => { return store.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], @@ -696,26 +706,26 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) { }); if (key && key.ciphertext) { - const pickleKey = Buffer.from(olmdevice._pickleKey); + const pickleKey = Buffer.from(olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, type); return decodeBase64(decrypted); } else { return key; } }, - storeCrossSigningKeyCache: async function(type, key) { + storeCrossSigningKeyCache: async function(type: string, key: Uint8Array): Promise { if (!(key instanceof Uint8Array)) { throw new Error( `storeCrossSigningKeyCache expects Uint8Array, got ${key}`, ); } - const pickleKey = Buffer.from(olmdevice._pickleKey); - key = await encryptAES(encodeBase64(key), pickleKey, type); + const pickleKey = Buffer.from(olmDevice._pickleKey); + const encryptedKey = await encryptAES(encodeBase64(key), pickleKey, type); return store.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - store.storeSecretStorePrivateKey(txn, type, key); + store.storeSecretStorePrivateKey(txn, type, encryptedKey); }, ); }, @@ -729,7 +739,7 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) { * @param {string} userId The user ID being verified * @param {string} deviceId The device ID being verified */ -export async function requestKeysDuringVerification(baseApis, userId, deviceId) { +export async function requestKeysDuringVerification(baseApis: MatrixClient, userId: string, deviceId: string) { // If this is a self-verification, ask the other party for keys if (baseApis.getUserId() !== userId) { return; @@ -739,7 +749,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) // it. We return here in order to test. return new Promise((resolve, reject) => { const client = baseApis; - const original = client.crypto._crossSigningInfo; + const original = client.crypto.crossSigningInfo; // We already have all of the infrastructure we need to validate and // cache cross-signing keys, so instead of replicating that, here we set @@ -748,8 +758,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) const crossSigning = new CrossSigningInfo( original.userId, { getCrossSigningKey: async (type) => { - logger.debug("Cross-signing: requesting secret", - type, deviceId); + logger.debug("Cross-signing: requesting secret", type, deviceId); const { promise } = client.requestSecret( `m.cross_signing.${type}`, [deviceId], ); @@ -757,7 +766,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) const decoded = decodeBase64(result); return Uint8Array.from(decoded); } }, - original._cacheCallbacks, + original.getCacheCallbacks(), ); crossSigning.keys = original.keys; @@ -774,7 +783,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) }); // also request and cache the key backup key - const backupKeyPromise = new Promise(async resolve => { + const backupKeyPromise = (async () => { const cachedKey = await client.crypto.getSessionBackupPrivateKey(); if (!cachedKey) { logger.info("No cached backup key found. Requesting..."); @@ -791,14 +800,11 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId) logger.info("Backup key stored. Starting backup restore..."); const backupInfo = await client.getKeyBackupVersion(); // no need to await for this - just let it go in the bg - client.restoreKeyBackupWithCache( - undefined, undefined, backupInfo, - ).then(() => { + client.restoreKeyBackupWithCache(undefined, undefined, backupInfo).then(() => { logger.info("Backup restored."); }); } - resolve(); - }); + })(); // We call getCrossSigningKey() for its side-effects return Promise.race([ diff --git a/src/crypto/DeviceList.js b/src/crypto/DeviceList.ts similarity index 61% rename from src/crypto/DeviceList.js rename to src/crypto/DeviceList.ts index 9b74a54db..71d3d364d 100644 --- a/src/crypto/DeviceList.js +++ b/src/crypto/DeviceList.ts @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2017 - 2021 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. @@ -23,14 +21,17 @@ limitations under the License. */ import { EventEmitter } from 'events'; + import { logger } from '../logger'; -import { DeviceInfo } from './deviceinfo'; +import { DeviceInfo, IDevice } from './deviceinfo'; import { CrossSigningInfo } from './CrossSigning'; import * as olmlib from './olmlib'; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { chunkPromises, defer, sleep } from '../utils'; +import { chunkPromises, defer, IDeferred, sleep } from '../utils'; +import { MatrixClient, CryptoStore } from "../client"; +import { OlmDevice } from "./OlmDevice"; -/* State transition diagram for DeviceList._deviceTrackingStatus +/* State transition diagram for DeviceList.deviceTrackingStatus * * | * stopTrackingDeviceList V @@ -50,92 +51,97 @@ import { chunkPromises, defer, sleep } from '../utils'; * +----------------------- UP_TO_DATE ------------------------+ */ -// constants for DeviceList._deviceTrackingStatus -const TRACKING_STATUS_NOT_TRACKED = 0; -const TRACKING_STATUS_PENDING_DOWNLOAD = 1; -const TRACKING_STATUS_DOWNLOAD_IN_PROGRESS = 2; -const TRACKING_STATUS_UP_TO_DATE = 3; +// constants for DeviceList.deviceTrackingStatus +enum TrackingStatus { + NotTracked, + PendingDownload, + DownloadInProgress, + UpToDate, +} + +export type DeviceInfoMap = Record>; /** * @alias module:crypto/DeviceList */ export class DeviceList extends EventEmitter { - constructor(baseApis, cryptoStore, olmDevice, keyDownloadChunkSize = 250) { + // userId -> { + // deviceId -> { + // [device info] + // } + // } + private devices: Record> = {}; + + // userId -> { + // [key info] + // } + public crossSigningInfo: Record = {}; + + // map of identity keys to the user who owns it + private userByIdentityKey: Record = {}; + + // which users we are tracking device status for. + // userId -> TRACKING_STATUS_* + private deviceTrackingStatus: Record = {}; // loaded from storage in load() + + // The 'next_batch' sync token at the point the data was written, + // ie. a token representing the point immediately after the + // moment represented by the snapshot in the db. + private syncToken: string = null; + + // userId -> promise + private keyDownloadsInProgressByUser: Record> = {}; + + // Set whenever changes are made other than setting the sync token + private dirty = false; + + // Promise resolved when device data is saved + private savePromise: Promise = null; + // Function that resolves the save promise + private resolveSavePromise: (saved: boolean) => void = null; + // The time the save is scheduled for + private savePromiseTime: number = null; + // The timer used to delay the save + private saveTimer: NodeJS.Timeout = null; + // True if we have fetched data from the server or loaded a non-empty + // set of device data from the store + private hasFetched: boolean = null; + + private readonly serialiser: DeviceListUpdateSerialiser; + + constructor( + baseApis: MatrixClient, + private readonly cryptoStore: CryptoStore, + olmDevice: OlmDevice, + // Maximum number of user IDs per request to prevent server overload (#1619) + public readonly keyDownloadChunkSize = 250, + ) { super(); - this._cryptoStore = cryptoStore; - - // userId -> { - // deviceId -> { - // [device info] - // } - // } - this._devices = {}; - - // userId -> { - // [key info] - // } - this._crossSigningInfo = {}; - - // map of identity keys to the user who owns it - this._userByIdentityKey = {}; - - // which users we are tracking device status for. - // userId -> TRACKING_STATUS_* - this._deviceTrackingStatus = {}; // loaded from storage in load() - - // The 'next_batch' sync token at the point the data was writen, - // ie. a token representing the point immediately after the - // moment represented by the snapshot in the db. - this._syncToken = null; - - this._serialiser = new DeviceListUpdateSerialiser( - baseApis, olmDevice, this, - ); - - // userId -> promise - this._keyDownloadsInProgressByUser = {}; - - // Maximum number of user IDs per request to prevent server overload (#1619) - this._keyDownloadChunkSize = keyDownloadChunkSize; - - // Set whenever changes are made other than setting the sync token - this._dirty = false; - - // Promise resolved when device data is saved - this._savePromise = null; - // Function that resolves the save promise - this._resolveSavePromise = null; - // The time the save is scheduled for - this._savePromiseTime = null; - // The timer used to delay the save - this._saveTimer = null; - // True if we have fetched data from the server or loaded a non-empty - // set of device data from the store - this._hasFetched = null; + this.serialiser = new DeviceListUpdateSerialiser(baseApis, olmDevice, this); } /** * Load the device tracking state from storage */ - async load() { - await this._cryptoStore.doTxn( + public async load() { + await this.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this._cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { - this._hasFetched = Boolean(deviceData && deviceData.devices); - this._devices = deviceData ? deviceData.devices : {}, - this._crossSigningInfo = deviceData ? + this.cryptoStore.getEndToEndDeviceData(txn, (deviceData) => { + this.hasFetched = Boolean(deviceData && deviceData.devices); + this.devices = deviceData ? deviceData.devices : {}, + this.crossSigningInfo = deviceData ? deviceData.crossSigningInfo || {} : {}; - this._deviceTrackingStatus = deviceData ? + this.deviceTrackingStatus = deviceData ? deviceData.trackingStatus : {}; - this._syncToken = deviceData ? deviceData.syncToken : null; - this._userByIdentityKey = {}; - for (const user of Object.keys(this._devices)) { - const userDevices = this._devices[user]; + this.syncToken = deviceData ? deviceData.syncToken : null; + this.userByIdentityKey = {}; + for (const user of Object.keys(this.devices)) { + const userDevices = this.devices[user]; for (const device of Object.keys(userDevices)) { const idKey = userDevices[device].keys['curve25519:'+device]; if (idKey !== undefined) { - this._userByIdentityKey[idKey] = user; + this.userByIdentityKey[idKey] = user; } } } @@ -143,17 +149,17 @@ export class DeviceList extends EventEmitter { }, ); - for (const u of Object.keys(this._deviceTrackingStatus)) { + for (const u of Object.keys(this.deviceTrackingStatus)) { // if a download was in progress when we got shut down, it isn't any more. - if (this._deviceTrackingStatus[u] == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { - this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; + if (this.deviceTrackingStatus[u] == TrackingStatus.DownloadInProgress) { + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; } } } - stop() { - if (this._saveTimer !== null) { - clearTimeout(this._saveTimer); + public stop() { + if (this.saveTimer !== null) { + clearTimeout(this.saveTimer); } } @@ -164,74 +170,73 @@ export class DeviceList extends EventEmitter { * The actual save will be delayed by a short amount of time to * aggregate multiple writes to the database. * - * @param {integer} delay Time in ms before which the save actually happens. + * @param {number} delay Time in ms before which the save actually happens. * By default, the save is delayed for a short period in order to batch * multiple writes, but this behaviour can be disabled by passing 0. * - * @return {Promise} true if the data was saved, false if + * @return {Promise} true if the data was saved, false if * it was not (eg. because no changes were pending). The promise * will only resolve once the data is saved, so may take some time * to resolve. */ - async saveIfDirty(delay) { - if (!this._dirty) return Promise.resolve(false); + public async saveIfDirty(delay = 500): Promise { + if (!this.dirty) return Promise.resolve(false); // Delay saves for a bit so we can aggregate multiple saves that happen // in quick succession (eg. when a whole room's devices are marked as known) - if (delay === undefined) delay = 500; - const targetTime = Date.now + delay; - if (this._savePromiseTime && targetTime < this._savePromiseTime) { + const targetTime = Date.now() + delay; + if (this.savePromiseTime && targetTime < this.savePromiseTime) { // There's a save scheduled but for after we would like: cancel // it & schedule one for the time we want - clearTimeout(this._saveTimer); - this._saveTimer = null; - this._savePromiseTime = null; + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.savePromiseTime = null; // (but keep the save promise since whatever called save before // will still want to know when the save is done) } - let savePromise = this._savePromise; + let savePromise = this.savePromise; if (savePromise === null) { savePromise = new Promise((resolve, reject) => { - this._resolveSavePromise = resolve; + this.resolveSavePromise = resolve; }); - this._savePromise = savePromise; + this.savePromise = savePromise; } - if (this._saveTimer === null) { - const resolveSavePromise = this._resolveSavePromise; - this._savePromiseTime = targetTime; - this._saveTimer = setTimeout(() => { - logger.log('Saving device tracking data', this._syncToken); + if (this.saveTimer === null) { + const resolveSavePromise = this.resolveSavePromise; + this.savePromiseTime = targetTime; + this.saveTimer = setTimeout(() => { + logger.log('Saving device tracking data', this.syncToken); // null out savePromise now (after the delay but before the write), // otherwise we could return the existing promise when the save has // actually already happened. - this._savePromiseTime = null; - this._saveTimer = null; - this._savePromise = null; - this._resolveSavePromise = null; + this.savePromiseTime = null; + this.saveTimer = null; + this.savePromise = null; + this.resolveSavePromise = null; - this._cryptoStore.doTxn( + this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_DEVICE_DATA], (txn) => { - this._cryptoStore.storeEndToEndDeviceData({ - devices: this._devices, - crossSigningInfo: this._crossSigningInfo, - trackingStatus: this._deviceTrackingStatus, - syncToken: this._syncToken, + this.cryptoStore.storeEndToEndDeviceData({ + devices: this.devices, + crossSigningInfo: this.crossSigningInfo, + trackingStatus: this.deviceTrackingStatus, + syncToken: this.syncToken, }, txn); }, ).then(() => { - // The device list is considered dirty until the write - // completes. - this._dirty = false; - resolveSavePromise(); + // The device list is considered dirty until the write completes. + this.dirty = false; + resolveSavePromise(true); }, err => { - logger.error('Failed to save device tracking data', this._syncToken); + logger.error('Failed to save device tracking data', this.syncToken); logger.error(err); }); }, delay); } + return savePromise; } @@ -240,8 +245,8 @@ export class DeviceList extends EventEmitter { * * @return {string} The sync token */ - getSyncToken() { - return this._syncToken; + public getSyncToken(): string { + return this.syncToken; } /** @@ -254,8 +259,8 @@ export class DeviceList extends EventEmitter { * * @param {string} st The sync token */ - setSyncToken(st) { - this._syncToken = st; + public setSyncToken(st: string): void { + this.syncToken = st; } /** @@ -263,33 +268,33 @@ export class DeviceList extends EventEmitter { * downloading and storing them if they're not (or if forceDownload is * true). * @param {Array} userIds The users to fetch. - * @param {bool} forceDownload Always download the keys even if cached. + * @param {boolean} forceDownload Always download the keys even if cached. * * @return {Promise} A promise which resolves to a map userId->deviceId->{@link * module:crypto/deviceinfo|DeviceInfo}. */ - downloadKeys(userIds, forceDownload) { + public downloadKeys(userIds: string[], forceDownload: boolean): Promise { const usersToDownload = []; const promises = []; userIds.forEach((u) => { - const trackingStatus = this._deviceTrackingStatus[u]; - if (this._keyDownloadsInProgressByUser[u]) { + const trackingStatus = this.deviceTrackingStatus[u]; + if (this.keyDownloadsInProgressByUser[u]) { // already a key download in progress/queued for this user; its results // will be good enough for us. logger.log( `downloadKeys: already have a download in progress for ` + `${u}: awaiting its result`, ); - promises.push(this._keyDownloadsInProgressByUser[u]); - } else if (forceDownload || trackingStatus != TRACKING_STATUS_UP_TO_DATE) { + promises.push(this.keyDownloadsInProgressByUser[u]); + } else if (forceDownload || trackingStatus != TrackingStatus.UpToDate) { usersToDownload.push(u); } }); if (usersToDownload.length != 0) { logger.log("downloadKeys: downloading for", usersToDownload); - const downloadPromise = this._doKeyDownload(usersToDownload); + const downloadPromise = this.doKeyDownload(usersToDownload); promises.push(downloadPromise); } @@ -298,7 +303,7 @@ export class DeviceList extends EventEmitter { } return Promise.all(promises).then(() => { - return this._getDevicesFromStore(userIds); + return this.getDevicesFromStore(userIds); }); } @@ -309,12 +314,11 @@ export class DeviceList extends EventEmitter { * * @return {Object} userId->deviceId->{@link module:crypto/deviceinfo|DeviceInfo}. */ - _getDevicesFromStore(userIds) { - const stored = {}; - const self = this; - userIds.map(function(u) { + private getDevicesFromStore(userIds: string[]): DeviceInfoMap { + const stored: DeviceInfoMap = {}; + userIds.map((u) => { stored[u] = {}; - const devices = self.getStoredDevicesForUser(u) || []; + const devices = this.getStoredDevicesForUser(u) || []; devices.map(function(dev) { stored[u][dev.deviceId] = dev; }); @@ -327,8 +331,8 @@ export class DeviceList extends EventEmitter { * * @return {array} All known user IDs */ - getKnownUserIds() { - return Object.keys(this._devices); + public getKnownUserIds(): string[] { + return Object.keys(this.devices); } /** @@ -339,8 +343,8 @@ export class DeviceList extends EventEmitter { * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't * managed to get a list of devices for this user yet. */ - getStoredDevicesForUser(userId) { - const devs = this._devices[userId]; + public getStoredDevicesForUser(userId: string): DeviceInfo[] | null { + const devs = this.devices[userId]; if (!devs) { return null; } @@ -361,19 +365,19 @@ export class DeviceList extends EventEmitter { * @return {Object} deviceId->{object} devices, or undefined if * there is no data for this user. */ - getRawStoredDevicesForUser(userId) { - return this._devices[userId]; + public getRawStoredDevicesForUser(userId: string): Record { + return this.devices[userId]; } - getStoredCrossSigningForUser(userId) { - if (!this._crossSigningInfo[userId]) return null; + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo { + if (!this.crossSigningInfo[userId]) return null; - return CrossSigningInfo.fromStorage(this._crossSigningInfo[userId], userId); + return CrossSigningInfo.fromStorage(this.crossSigningInfo[userId], userId); } - storeCrossSigningForUser(userId, info) { - this._crossSigningInfo[userId] = info; - this._dirty = true; + public storeCrossSigningForUser(userId: string, info: CrossSigningInfo): void { + this.crossSigningInfo[userId] = info; + this.dirty = true; } /** @@ -385,8 +389,8 @@ export class DeviceList extends EventEmitter { * @return {module:crypto/deviceinfo?} device, or undefined * if we don't know about this device */ - getStoredDevice(userId, deviceId) { - const devs = this._devices[userId]; + public getStoredDevice(userId: string, deviceId: string): DeviceInfo { + const devs = this.devices[userId]; if (!devs || !devs[deviceId]) { return undefined; } @@ -401,7 +405,7 @@ export class DeviceList extends EventEmitter { * * @return {string} user ID */ - getUserByIdentityKey(algorithm, senderKey) { + public getUserByIdentityKey(algorithm: string, senderKey: string): string { if ( algorithm !== olmlib.OLM_ALGORITHM && algorithm !== olmlib.MEGOLM_ALGORITHM @@ -410,7 +414,7 @@ export class DeviceList extends EventEmitter { return null; } - return this._userByIdentityKey[senderKey]; + return this.userByIdentityKey[senderKey]; } /** @@ -421,13 +425,13 @@ export class DeviceList extends EventEmitter { * * @return {module:crypto/deviceinfo?} */ - getDeviceByIdentityKey(algorithm, senderKey) { + public getDeviceByIdentityKey(algorithm: string, senderKey: string): DeviceInfo | null { const userId = this.getUserByIdentityKey(algorithm, senderKey); if (!userId) { return null; } - const devices = this._devices[userId]; + const devices = this.devices[userId]; if (!devices) { return null; } @@ -459,28 +463,12 @@ export class DeviceList extends EventEmitter { /** * Replaces the list of devices for a user with the given device list * - * @param {string} u The user ID - * @param {Object} devs New device info for user + * @param {string} userId The user ID + * @param {Object} devices New device info for user */ - storeDevicesForUser(u, devs) { - // remove previous devices from _userByIdentityKey - if (this._devices[u] !== undefined) { - for (const [deviceId, dev] of Object.entries(this._devices[u])) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - delete this._userByIdentityKey[identityKey]; - } - } - - this._devices[u] = devs; - - // add new ones - for (const [deviceId, dev] of Object.entries(devs)) { - const identityKey = dev.keys['curve25519:'+deviceId]; - - this._userByIdentityKey[identityKey] = u; - } - this._dirty = true; + public storeDevicesForUser(userId: string, devices: Record): void { + this.setRawStoredDevicesForUser(userId, devices); + this.dirty = true; } /** @@ -492,7 +480,7 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - startTrackingDeviceList(userId) { + public startTrackingDeviceList(userId: string): void { // sanity-check the userId. This is mostly paranoia, but if synapse // can't parse the userId we give it as an mxid, it 500s the whole // request and we can never update the device lists again (because @@ -503,12 +491,12 @@ export class DeviceList extends EventEmitter { if (typeof userId !== 'string') { throw new Error('userId must be a string; was '+userId); } - if (!this._deviceTrackingStatus[userId]) { + if (!this.deviceTrackingStatus[userId]) { logger.log('Now tracking device list for ' + userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -521,14 +509,14 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - stopTrackingDeviceList(userId) { - if (this._deviceTrackingStatus[userId]) { + public stopTrackingDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { logger.log('No longer tracking device list for ' + userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -538,11 +526,11 @@ export class DeviceList extends EventEmitter { * This will flag each user whose devices we are tracking as in need of an * update. */ - stopTrackingAllDeviceLists() { - for (const userId of Object.keys(this._deviceTrackingStatus)) { - this._deviceTrackingStatus[userId] = TRACKING_STATUS_NOT_TRACKED; + public stopTrackingAllDeviceLists(): void { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + this.deviceTrackingStatus[userId] = TrackingStatus.NotTracked; } - this._dirty = true; + this.dirty = true; } /** @@ -556,14 +544,14 @@ export class DeviceList extends EventEmitter { * * @param {String} userId */ - invalidateUserDeviceList(userId) { - if (this._deviceTrackingStatus[userId]) { + public invalidateUserDeviceList(userId: string): void { + if (this.deviceTrackingStatus[userId]) { logger.log("Marking device list outdated for", userId); - this._deviceTrackingStatus[userId] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[userId] = TrackingStatus.PendingDownload; // we don't yet persist the tracking status, since there may be a lot // of calls; we save all data together once the sync is done - this._dirty = true; + this.dirty = true; } } @@ -573,18 +561,18 @@ export class DeviceList extends EventEmitter { * @returns {Promise} which completes when the download completes; normally there * is no need to wait for this (it's mostly for the unit tests). */ - refreshOutdatedDeviceLists() { + public refreshOutdatedDeviceLists(): Promise { this.saveIfDirty(); const usersToDownload = []; - for (const userId of Object.keys(this._deviceTrackingStatus)) { - const stat = this._deviceTrackingStatus[userId]; - if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) { + for (const userId of Object.keys(this.deviceTrackingStatus)) { + const stat = this.deviceTrackingStatus[userId]; + if (stat == TrackingStatus.PendingDownload) { usersToDownload.push(userId); } } - return this._doKeyDownload(usersToDownload); + return this.doKeyDownload(usersToDownload); } /** @@ -595,34 +583,34 @@ export class DeviceList extends EventEmitter { * * @param {Object} devices deviceId->{object} the new devices */ - _setRawStoredDevicesForUser(userId, devices) { - // remove old devices from _userByIdentityKey - if (this._devices[userId] !== undefined) { - for (const [deviceId, dev] of Object.entries(this._devices[userId])) { + public setRawStoredDevicesForUser(userId: string, devices: Record): void { + // remove old devices from userByIdentityKey + if (this.devices[userId] !== undefined) { + for (const [deviceId, dev] of Object.entries(this.devices[userId])) { const identityKey = dev.keys['curve25519:'+deviceId]; - delete this._userByIdentityKey[identityKey]; + delete this.userByIdentityKey[identityKey]; } } - this._devices[userId] = devices; + this.devices[userId] = devices; - // add new devices into _userByIdentityKey + // add new devices into userByIdentityKey for (const [deviceId, dev] of Object.entries(devices)) { const identityKey = dev.keys['curve25519:'+deviceId]; - this._userByIdentityKey[identityKey] = userId; + this.userByIdentityKey[identityKey] = userId; } } - setRawStoredCrossSigningForUser(userId, info) { - this._crossSigningInfo[userId] = info; + public setRawStoredCrossSigningForUser(userId: string, info: object): void { + this.crossSigningInfo[userId] = info; } /** * Fire off download update requests for the given users, and update the * device list tracking status for them, and the - * _keyDownloadsInProgressByUser map for them. + * keyDownloadsInProgressByUser map for them. * * @param {String[]} users list of userIds * @@ -630,15 +618,13 @@ export class DeviceList extends EventEmitter { * been updated. rejects if there was a problem updating any of the * users. */ - _doKeyDownload(users) { + private doKeyDownload(users: string[]): Promise { if (users.length === 0) { // nothing to do return Promise.resolve(); } - const prom = this._serialiser.updateDevicesForUsers( - users, this._syncToken, - ).then(() => { + const prom = this.serialiser.updateDevicesForUsers(users, this.syncToken).then(() => { finished(true); }, (e) => { logger.error( @@ -649,42 +635,41 @@ export class DeviceList extends EventEmitter { }); users.forEach((u) => { - this._keyDownloadsInProgressByUser[u] = prom; - const stat = this._deviceTrackingStatus[u]; - if (stat == TRACKING_STATUS_PENDING_DOWNLOAD) { - this._deviceTrackingStatus[u] = TRACKING_STATUS_DOWNLOAD_IN_PROGRESS; + this.keyDownloadsInProgressByUser[u] = prom; + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.PendingDownload) { + this.deviceTrackingStatus[u] = TrackingStatus.DownloadInProgress; } }); const finished = (success) => { - this.emit("crypto.willUpdateDevices", users, !this._hasFetched); + this.emit("crypto.willUpdateDevices", users, !this.hasFetched); users.forEach((u) => { - this._dirty = true; + this.dirty = true; // we may have queued up another download request for this user // since we started this request. If that happens, we should // ignore the completion of the first one. - if (this._keyDownloadsInProgressByUser[u] !== prom) { - logger.log('Another update in the queue for', u, - '- not marking up-to-date'); + if (this.keyDownloadsInProgressByUser[u] !== prom) { + logger.log('Another update in the queue for', u, '- not marking up-to-date'); return; } - delete this._keyDownloadsInProgressByUser[u]; - const stat = this._deviceTrackingStatus[u]; - if (stat == TRACKING_STATUS_DOWNLOAD_IN_PROGRESS) { + delete this.keyDownloadsInProgressByUser[u]; + const stat = this.deviceTrackingStatus[u]; + if (stat == TrackingStatus.DownloadInProgress) { if (success) { // we didn't get any new invalidations since this download started: // this user's device list is now up to date. - this._deviceTrackingStatus[u] = TRACKING_STATUS_UP_TO_DATE; + this.deviceTrackingStatus[u] = TrackingStatus.UpToDate; logger.log("Device list for", u, "now up to date"); } else { - this._deviceTrackingStatus[u] = TRACKING_STATUS_PENDING_DOWNLOAD; + this.deviceTrackingStatus[u] = TrackingStatus.PendingDownload; } } }); this.saveIfDirty(); - this.emit("crypto.devicesUpdated", users, !this._hasFetched); - this._hasFetched = true; + this.emit("crypto.devicesUpdated", users, !this.hasFetched); + this.hasFetched = true; }; return prom; @@ -701,29 +686,28 @@ export class DeviceList extends EventEmitter { * time (and queuing other requests up). */ class DeviceListUpdateSerialiser { + private downloadInProgress = false; + + // users which are queued for download + // userId -> true + private keyDownloadsQueuedByUser: Record = {}; + + // deferred which is resolved when the queued users are downloaded. + // non-null indicates that we have users queued for download. + private queuedQueryDeferred: IDeferred = null; + + private syncToken: string = null; // The sync token we send with the requests + /* * @param {object} baseApis Base API object * @param {object} olmDevice The Olm Device - * @param {object} deviceList The device list object + * @param {object} deviceList The device list object, the device list to be updated */ - constructor(baseApis, olmDevice, deviceList) { - this._baseApis = baseApis; - this._olmDevice = olmDevice; - this._deviceList = deviceList; // the device list to be updated - - this._downloadInProgress = false; - - // users which are queued for download - // userId -> true - this._keyDownloadsQueuedByUser = {}; - - // deferred which is resolved when the queued users are downloaded. - // - // non-null indicates that we have users queued for download. - this._queuedQueryDeferred = null; - - this._syncToken = null; // The sync token we send with the requests - } + constructor( + private readonly baseApis: MatrixClient, + private readonly olmDevice: OlmDevice, + private readonly deviceList: DeviceList, + ) {} /** * Make a key query request for the given users @@ -737,57 +721,57 @@ class DeviceListUpdateSerialiser { * been updated. rejects if there was a problem updating any of the * users. */ - updateDevicesForUsers(users, syncToken) { + public updateDevicesForUsers(users: string[], syncToken: string): Promise { users.forEach((u) => { - this._keyDownloadsQueuedByUser[u] = true; + this.keyDownloadsQueuedByUser[u] = true; }); - if (!this._queuedQueryDeferred) { - this._queuedQueryDeferred = defer(); + if (!this.queuedQueryDeferred) { + this.queuedQueryDeferred = defer(); } // We always take the new sync token and just use the latest one we've // been given, since it just needs to be at least as recent as the // sync response the device invalidation message arrived in - this._syncToken = syncToken; + this.syncToken = syncToken; - if (this._downloadInProgress) { + if (this.downloadInProgress) { // just queue up these users logger.log('Queued key download for', users); - return this._queuedQueryDeferred.promise; + return this.queuedQueryDeferred.promise; } // start a new download. - return this._doQueuedQueries(); + return this.doQueuedQueries(); } - _doQueuedQueries() { - if (this._downloadInProgress) { + private doQueuedQueries(): Promise { + if (this.downloadInProgress) { throw new Error( - "DeviceListUpdateSerialiser._doQueuedQueries called with request active", + "DeviceListUpdateSerialiser.doQueuedQueries called with request active", ); } - const downloadUsers = Object.keys(this._keyDownloadsQueuedByUser); - this._keyDownloadsQueuedByUser = {}; - const deferred = this._queuedQueryDeferred; - this._queuedQueryDeferred = null; + const downloadUsers = Object.keys(this.keyDownloadsQueuedByUser); + this.keyDownloadsQueuedByUser = {}; + const deferred = this.queuedQueryDeferred; + this.queuedQueryDeferred = null; logger.log('Starting key download for', downloadUsers); - this._downloadInProgress = true; + this.downloadInProgress = true; - const opts = {}; - if (this._syncToken) { - opts.token = this._syncToken; + const opts: Parameters[1] = {}; + if (this.syncToken) { + opts.token = this.syncToken; } const factories = []; - for (let i = 0; i < downloadUsers.length; i += this._deviceList._keyDownloadChunkSize) { - const userSlice = downloadUsers.slice(i, i + this._deviceList._keyDownloadChunkSize); - factories.push(() => this._baseApis.downloadKeysForUsers(userSlice, opts)); + for (let i = 0; i < downloadUsers.length; i += this.deviceList.keyDownloadChunkSize) { + const userSlice = downloadUsers.slice(i, i + this.deviceList.keyDownloadChunkSize); + factories.push(() => this.baseApis.downloadKeysForUsers(userSlice, opts)); } - chunkPromises(factories, 3).then(async (responses) => { + chunkPromises(factories, 3).then(async (responses: any[]) => { const dk = Object.assign({}, ...(responses.map(res => res.device_keys || {}))); const masterKeys = Object.assign({}, ...(responses.map(res => res.master_keys || {}))); const ssks = Object.assign({}, ...(responses.map(res => res.self_signing_keys || {}))); @@ -802,7 +786,7 @@ class DeviceListUpdateSerialiser { for (const userId of downloadUsers) { await sleep(5); try { - await this._processQueryResponseForUser( + await this.processQueryResponseForUser( userId, dk[userId], { master: masterKeys[userId], self_signing: ssks[userId], @@ -818,32 +802,34 @@ class DeviceListUpdateSerialiser { }).then(() => { logger.log('Completed key download for ' + downloadUsers); - this._downloadInProgress = false; + this.downloadInProgress = false; deferred.resolve(); // if we have queued users, fire off another request. - if (this._queuedQueryDeferred) { - this._doQueuedQueries(); + if (this.queuedQueryDeferred) { + this.doQueuedQueries(); } }, (e) => { logger.warn('Error downloading keys for ' + downloadUsers + ':', e); - this._downloadInProgress = false; + this.downloadInProgress = false; deferred.reject(e); }); return deferred.promise; } - async _processQueryResponseForUser( - userId, dkResponse, crossSigningResponse, - ) { + private async processQueryResponseForUser( + userId: string, + dkResponse: object, + crossSigningResponse: any, // TODO types + ): Promise { logger.log('got device keys for ' + userId + ':', dkResponse); logger.log('got cross-signing keys for ' + userId + ':', crossSigningResponse); { // map from deviceid -> deviceinfo for this user - const userStore = {}; - const devs = this._deviceList.getRawStoredDevicesForUser(userId); + const userStore: Record = {}; + const devs = this.deviceList.getRawStoredDevicesForUser(userId); if (devs) { Object.keys(devs).forEach((deviceId) => { const d = DeviceInfo.fromStorage(devs[deviceId], deviceId); @@ -851,18 +837,18 @@ class DeviceListUpdateSerialiser { }); } - await _updateStoredDeviceKeysForUser( - this._olmDevice, userId, userStore, dkResponse || {}, - this._baseApis.getUserId(), this._baseApis.deviceId, + await updateStoredDeviceKeysForUser( + this.olmDevice, userId, userStore, dkResponse || {}, + this.baseApis.getUserId(), this.baseApis.deviceId, ); // put the updates into the object that will be returned as our results - const storage = {}; + const storage: Record = {}; Object.keys(userStore).forEach((deviceId) => { storage[deviceId] = userStore[deviceId].toStorage(); }); - this._deviceList._setRawStoredDevicesForUser(userId, storage); + this.deviceList.setRawStoredDevicesForUser(userId, storage); } // now do the same for the cross-signing keys @@ -873,26 +859,31 @@ class DeviceListUpdateSerialiser { && (crossSigningResponse.master || crossSigningResponse.self_signing || crossSigningResponse.user_signing)) { const crossSigning - = this._deviceList.getStoredCrossSigningForUser(userId) + = this.deviceList.getStoredCrossSigningForUser(userId) || new CrossSigningInfo(userId); crossSigning.setKeys(crossSigningResponse); - this._deviceList.setRawStoredCrossSigningForUser( + this.deviceList.setRawStoredCrossSigningForUser( userId, crossSigning.toStorage(), ); // NB. Unlike most events in the js-sdk, this one is internal to the // js-sdk and is not re-emitted - this._deviceList.emit('userCrossSigningUpdated', userId); + this.deviceList.emit('userCrossSigningUpdated', userId); } } } } -async function _updateStoredDeviceKeysForUser( - _olmDevice, userId, userStore, userResult, localUserId, localDeviceId, -) { +async function updateStoredDeviceKeysForUser( + olmDevice: OlmDevice, + userId: string, + userStore: Record, + userResult: object, + localUserId: string, + localDeviceId: string, +): Promise { let updated = false; // remove any devices in the store which aren't in the response @@ -936,7 +927,7 @@ async function _updateStoredDeviceKeysForUser( continue; } - if (await _storeDeviceKeys(_olmDevice, userStore, deviceResult)) { + if (await storeDeviceKeys(olmDevice, userStore, deviceResult)) { updated = true; } } @@ -949,7 +940,11 @@ async function _updateStoredDeviceKeysForUser( * * returns (a promise for) true if a change was made, else false */ -async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { +async function storeDeviceKeys( + olmDevice: OlmDevice, + userStore: Record, + deviceResult: any, // TODO types +): Promise { if (!deviceResult.keys) { // no keys? return false; @@ -961,8 +956,7 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { const signKeyId = "ed25519:" + deviceId; const signKey = deviceResult.keys[signKeyId]; if (!signKey) { - logger.warn("Device " + userId + ":" + deviceId + - " has no ed25519 key"); + logger.warn("Device " + userId + ":" + deviceId + " has no ed25519 key"); return false; } @@ -970,10 +964,9 @@ async function _storeDeviceKeys(_olmDevice, userStore, deviceResult) { const signatures = deviceResult.signatures || {}; try { - await olmlib.verifySignature(_olmDevice, deviceResult, userId, deviceId, signKey); + await olmlib.verifySignature(olmDevice, deviceResult, userId, deviceId, signKey); } catch (e) { - logger.warn("Unable to verify signature on device " + - userId + ":" + deviceId + ":" + e); + logger.warn("Unable to verify signature on device " + userId + ":" + deviceId + ":" + e); return false; } diff --git a/src/crypto/EncryptionSetup.js b/src/crypto/EncryptionSetup.ts similarity index 59% rename from src/crypto/EncryptionSetup.js rename to src/crypto/EncryptionSetup.ts index d7f861a03..140c4cadb 100644 --- a/src/crypto/EncryptionSetup.js +++ b/src/crypto/EncryptionSetup.ts @@ -1,11 +1,24 @@ import { logger } from "../logger"; import { MatrixEvent } from "../models/event"; import { EventEmitter } from "events"; -import { createCryptoStoreCacheCallbacks } from "./CrossSigning"; +import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning"; import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { PREFIX_UNSTABLE } from "../http-api"; +import { Crypto, IBootstrapCrossSigningOpts } from "./index"; import { - PREFIX_UNSTABLE, -} from "../http-api"; + CrossSigningKeys, + ICrossSigningKey, + ICryptoCallbacks, + ISecretStorageKeyInfo, + ISignedKey, + KeySignatures, +} from "../matrix"; +import { IKeyBackupInfo } from "./keybackup"; + +interface ICrossSigningKeys { + authUpload: IBootstrapCrossSigningOpts["authUploadDeviceSigningKeys"]; + keys: Record; +} /** * Builds an EncryptionSetupOperation by calling any of the add.. methods. @@ -17,18 +30,23 @@ import { * more than once. */ export class EncryptionSetupBuilder { + public readonly accountDataClientAdapter: AccountDataClientAdapter; + public readonly crossSigningCallbacks: CrossSigningCallbacks; + public readonly ssssCryptoCallbacks: SSSSCryptoCallbacks; + + private crossSigningKeys: ICrossSigningKeys = null; + private keySignatures: KeySignatures = null; + private keyBackupInfo: IKeyBackupInfo = null; + private sessionBackupPrivateKey: Uint8Array; + /** * @param {Object.} accountData pre-existing account data, will only be read, not written. * @param {CryptoCallbacks} delegateCryptoCallbacks crypto callbacks to delegate to if the key isn't in cache yet */ - constructor(accountData, delegateCryptoCallbacks) { + constructor(accountData: Record, delegateCryptoCallbacks: ICryptoCallbacks) { this.accountDataClientAdapter = new AccountDataClientAdapter(accountData); this.crossSigningCallbacks = new CrossSigningCallbacks(); this.ssssCryptoCallbacks = new SSSSCryptoCallbacks(delegateCryptoCallbacks); - - this._crossSigningKeys = null; - this._keySignatures = null; - this._keyBackupInfo = null; } /** @@ -42,8 +60,8 @@ export class EncryptionSetupBuilder { * an empty authDict, to obtain the flows. * @param {Object} keys the new keys */ - addCrossSigningKeys(authUpload, keys) { - this._crossSigningKeys = { authUpload, keys }; + public addCrossSigningKeys(authUpload: ICrossSigningKeys["authUpload"], keys: ICrossSigningKeys["keys"]): void { + this.crossSigningKeys = { authUpload, keys }; } /** @@ -54,8 +72,8 @@ export class EncryptionSetupBuilder { * * @param {Object} keyBackupInfo as received from/sent to the server */ - addSessionBackup(keyBackupInfo) { - this._keyBackupInfo = keyBackupInfo; + public addSessionBackup(keyBackupInfo: IKeyBackupInfo): void { + this.keyBackupInfo = keyBackupInfo; } /** @@ -65,8 +83,8 @@ export class EncryptionSetupBuilder { * * @param {Uint8Array} privateKey */ - addSessionBackupPrivateKeyToCache(privateKey) { - this._sessionBackupPrivateKey = privateKey; + public addSessionBackupPrivateKeyToCache(privateKey: Uint8Array): void { + this.sessionBackupPrivateKey = privateKey; } /** @@ -75,14 +93,14 @@ export class EncryptionSetupBuilder { * * @param {String} userId * @param {String} deviceId - * @param {String} signature + * @param {Object} signature */ - addKeySignature(userId, deviceId, signature) { - if (!this._keySignatures) { - this._keySignatures = {}; + public addKeySignature(userId: string, deviceId: string, signature: ISignedKey): void { + if (!this.keySignatures) { + this.keySignatures = {}; } - const userSignatures = this._keySignatures[userId] || {}; - this._keySignatures[userId] = userSignatures; + const userSignatures = this.keySignatures[userId] || {}; + this.keySignatures[userId] = userSignatures; userSignatures[deviceId] = signature; } @@ -91,7 +109,7 @@ export class EncryptionSetupBuilder { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { + public setAccountData(type: string, content: object): Promise { return this.accountDataClientAdapter.setAccountData(type, content); } @@ -99,13 +117,13 @@ export class EncryptionSetupBuilder { * builds the operation containing all the parts that have been added to the builder * @return {EncryptionSetupOperation} */ - buildOperation() { - const accountData = this.accountDataClientAdapter._values; + public buildOperation(): EncryptionSetupOperation { + const accountData = this.accountDataClientAdapter.values; return new EncryptionSetupOperation( accountData, - this._crossSigningKeys, - this._keyBackupInfo, - this._keySignatures, + this.crossSigningKeys, + this.keyBackupInfo, + this.keySignatures, ); } @@ -118,28 +136,27 @@ export class EncryptionSetupBuilder { * @param {Crypto} crypto * @return {Promise} */ - async persist(crypto) { + public async persist(crypto: Crypto): Promise { // store private keys in cache - if (this._crossSigningKeys) { - const cacheCallbacks = createCryptoStoreCacheCallbacks( - crypto._cryptoStore, crypto._olmDevice); + if (this.crossSigningKeys) { + const cacheCallbacks = createCryptoStoreCacheCallbacks(crypto.cryptoStore, crypto.olmDevice); for (const type of ["master", "self_signing", "user_signing"]) { logger.log(`Cache ${type} cross-signing private key locally`); const privateKey = this.crossSigningCallbacks.privateKeys.get(type); await cacheCallbacks.storeCrossSigningKeyCache(type, privateKey); } // store own cross-sign pubkeys as trusted - await crypto._cryptoStore.doTxn( + await crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - crypto._cryptoStore.storeCrossSigningKeys( - txn, this._crossSigningKeys.keys); + crypto.cryptoStore.storeCrossSigningKeys( + txn, this.crossSigningKeys.keys); }, ); } // store session backup key in cache - if (this._sessionBackupPrivateKey) { - await crypto.storeSessionBackupPrivateKey(this._sessionBackupPrivateKey); + if (this.sessionBackupPrivateKey) { + await crypto.storeSessionBackupPrivateKey(this.sessionBackupPrivateKey); } } } @@ -157,58 +174,58 @@ export class EncryptionSetupOperation { * @param {Object} keyBackupInfo * @param {Object} keySignatures */ - constructor(accountData, crossSigningKeys, keyBackupInfo, keySignatures) { - this._accountData = accountData; - this._crossSigningKeys = crossSigningKeys; - this._keyBackupInfo = keyBackupInfo; - this._keySignatures = keySignatures; - } + constructor( + private readonly accountData: Map, + private readonly crossSigningKeys: ICrossSigningKeys, + private readonly keyBackupInfo: IKeyBackupInfo, + private readonly keySignatures: KeySignatures, + ) {} /** * Runs the (remaining part of, in the future) operation by sending requests to the server. - * @param {Crypto} crypto + * @param {Crypto} crypto */ - async apply(crypto) { - const baseApis = crypto._baseApis; + public async apply(crypto: Crypto): Promise { + const baseApis = crypto.baseApis; // upload cross-signing keys - if (this._crossSigningKeys) { - const keys = {}; - for (const [name, key] of Object.entries(this._crossSigningKeys.keys)) { + if (this.crossSigningKeys) { + const keys: Partial = {}; + for (const [name, key] of Object.entries(this.crossSigningKeys.keys)) { keys[name + "_key"] = key; } // We must only call `uploadDeviceSigningKeys` from inside this auth // helper to ensure we properly handle auth errors. - await this._crossSigningKeys.authUpload(authDict => { - return baseApis.uploadDeviceSigningKeys(authDict, keys); + await this.crossSigningKeys.authUpload(authDict => { + return baseApis.uploadDeviceSigningKeys(authDict, keys as CrossSigningKeys); }); // pass the new keys to the main instance of our own CrossSigningInfo. - crypto._crossSigningInfo.setKeys(this._crossSigningKeys.keys); + crypto.crossSigningInfo.setKeys(this.crossSigningKeys.keys); } // set account data - if (this._accountData) { - for (const [type, content] of this._accountData) { + if (this.accountData) { + for (const [type, content] of this.accountData) { await baseApis.setAccountData(type, content); } } // upload first cross-signing signatures with the new key // (e.g. signing our own device) - if (this._keySignatures) { - await baseApis.uploadKeySignatures(this._keySignatures); + if (this.keySignatures) { + await baseApis.uploadKeySignatures(this.keySignatures); } // need to create/update key backup info - if (this._keyBackupInfo) { - if (this._keyBackupInfo.version) { + if (this.keyBackupInfo) { + if (this.keyBackupInfo.version) { // session backup signature // The backup is trusted because the user provided the private key. // Sign the backup with the cross signing key so the key backup can // be trusted via cross-signing. await baseApis.http.authedRequest( - undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version, + undefined, "PUT", "/room_keys/version/" + this.keyBackupInfo.version, undefined, { - algorithm: this._keyBackupInfo.algorithm, - auth_data: this._keyBackupInfo.auth_data, + algorithm: this.keyBackupInfo.algorithm, + auth_data: this.keyBackupInfo.auth_data, }, { prefix: PREFIX_UNSTABLE }, ); @@ -216,7 +233,7 @@ export class EncryptionSetupOperation { // add new key backup await baseApis.http.authedRequest( undefined, "POST", "/room_keys/version", - undefined, this._keyBackupInfo, + undefined, this.keyBackupInfo, { prefix: PREFIX_UNSTABLE }, ); } @@ -229,20 +246,20 @@ export class EncryptionSetupOperation { * implementing the methods related to account data in MatrixClient */ class AccountDataClientAdapter extends EventEmitter { + public readonly values = new Map(); + /** - * @param {Object.} accountData existing account data + * @param {Object.} existingValues existing account data */ - constructor(accountData) { + constructor(private readonly existingValues: Record) { super(); - this._existingValues = accountData; - this._values = new Map(); } /** * @param {String} type * @return {Promise} the content of the account data */ - getAccountDataFromServer(type) { + public getAccountDataFromServer(type: string): Promise { return Promise.resolve(this.getAccountData(type)); } @@ -250,12 +267,12 @@ class AccountDataClientAdapter extends EventEmitter { * @param {String} type * @return {Object} the content of the account data */ - getAccountData(type) { - const modifiedValue = this._values.get(type); + public getAccountData(type: string): object { + const modifiedValue = this.values.get(type); if (modifiedValue) { return modifiedValue; } - const existingValue = this._existingValues[type]; + const existingValue = this.existingValues[type]; if (existingValue) { return existingValue.getContent(); } @@ -267,9 +284,9 @@ class AccountDataClientAdapter extends EventEmitter { * @param {Object} content * @return {Promise} */ - setAccountData(type, content) { - const lastEvent = this._values.get(type); - this._values.set(type, content); + public setAccountData(type: string, content: object): Promise { + const lastEvent = this.values.get(type); + this.values.set(type, content); // ensure accountData is emitted on the next tick, // as SecretStorage listens for it while calling this method // and it seems to rely on this. @@ -285,27 +302,25 @@ class AccountDataClientAdapter extends EventEmitter { * by both cache callbacks (see createCryptoStoreCacheCallbacks) as non-cache callbacks. * See CrossSigningInfo constructor */ -class CrossSigningCallbacks { - constructor() { - this.privateKeys = new Map(); - } +class CrossSigningCallbacks implements ICryptoCallbacks, ICacheCallbacks { + public readonly privateKeys = new Map(); // cache callbacks - getCrossSigningKeyCache(type, expectedPublicKey) { + public getCrossSigningKeyCache(type: string, expectedPublicKey: string): Promise { return this.getCrossSigningKey(type, expectedPublicKey); } - storeCrossSigningKeyCache(type, key) { + public storeCrossSigningKeyCache(type: string, key: Uint8Array): Promise { this.privateKeys.set(type, key); return Promise.resolve(); } // non-cache callbacks - getCrossSigningKey(type, _expectedPubkey) { + public getCrossSigningKey(type: string, expectedPubkey: string): Promise { return Promise.resolve(this.privateKeys.get(type)); } - saveCrossSigningKeys(privateKeys) { + public saveCrossSigningKeys(privateKeys: Record) { for (const [type, privateKey] of Object.entries(privateKeys)) { this.privateKeys.set(type, privateKey); } @@ -317,39 +332,36 @@ class CrossSigningCallbacks { * the SecretStorage crypto callbacks */ class SSSSCryptoCallbacks { - constructor(delegateCryptoCallbacks) { - this._privateKeys = new Map(); - this._delegateCryptoCallbacks = delegateCryptoCallbacks; - } + private readonly privateKeys = new Map(); - async getSecretStorageKey({ keys }, name) { + constructor(private readonly delegateCryptoCallbacks: ICryptoCallbacks) {} + + public async getSecretStorageKey( + { keys }: { keys: Record }, + name: string, + ): Promise<[string, Uint8Array]> { for (const keyId of Object.keys(keys)) { - const privateKey = this._privateKeys.get(keyId); + const privateKey = this.privateKeys.get(keyId); if (privateKey) { return [keyId, privateKey]; } } // if we don't have the key cached yet, ask // for it to the general crypto callbacks and cache it - if (this._delegateCryptoCallbacks) { - const result = await this._delegateCryptoCallbacks. + if (this.delegateCryptoCallbacks) { + const result = await this.delegateCryptoCallbacks. getSecretStorageKey({ keys }, name); if (result) { const [keyId, privateKey] = result; - this._privateKeys.set(keyId, privateKey); + this.privateKeys.set(keyId, privateKey); } return result; } } - addPrivateKey(keyId, keyInfo, privKey) { - this._privateKeys.set(keyId, privKey); + public addPrivateKey(keyId: string, keyInfo: ISecretStorageKeyInfo, privKey: Uint8Array): void { + this.privateKeys.set(keyId, privKey); // Also pass along to application to cache if it wishes - if ( - this._delegateCryptoCallbacks && - this._delegateCryptoCallbacks.cacheSecretStorageKey - ) { - this._delegateCryptoCallbacks.cacheSecretStorageKey(keyId, keyInfo, privKey); - } + this.delegateCryptoCallbacks?.cacheSecretStorageKey?.(keyId, keyInfo, privKey); } } diff --git a/src/crypto/OutgoingRoomKeyRequestManager.js b/src/crypto/OutgoingRoomKeyRequestManager.ts similarity index 54% rename from src/crypto/OutgoingRoomKeyRequestManager.js rename to src/crypto/OutgoingRoomKeyRequestManager.ts index 7f64b5313..a684b2c71 100644 --- a/src/crypto/OutgoingRoomKeyRequestManager.js +++ b/src/crypto/OutgoingRoomKeyRequestManager.ts @@ -1,5 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd +Copyright 2017 - 2021 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. @@ -15,6 +15,10 @@ limitations under the License. */ import { logger } from '../logger'; +import { CryptoStore, MatrixClient } from "../client"; +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "./index"; +import { OutgoingRoomKeyRequest } from './store/base'; +import { EventType } from "../@types/event"; /** * Internal module. Management of outgoing room key requests. @@ -57,61 +61,58 @@ const SEND_KEY_REQUESTS_DELAY_MS = 500; * * @enum {number} */ -export const ROOM_KEY_REQUEST_STATES = { +export enum RoomKeyRequestState { /** request not yet sent */ - UNSENT: 0, - + Unsent, /** request sent, awaiting reply */ - SENT: 1, - + Sent, /** reply received, cancellation not yet sent */ - CANCELLATION_PENDING: 2, - + CancellationPending, /** * Cancellation not yet sent and will transition to UNSENT instead of * being deleted once the cancellation has been sent. */ - CANCELLATION_PENDING_AND_WILL_RESEND: 3, -}; + CancellationPendingAndWillResend, +} export class OutgoingRoomKeyRequestManager { - constructor(baseApis, deviceId, cryptoStore) { - this._baseApis = baseApis; - this._deviceId = deviceId; - this._cryptoStore = cryptoStore; + // handle for the delayed call to sendOutgoingRoomKeyRequests. Non-null + // if the callback has been set, or if it is still running. + private sendOutgoingRoomKeyRequestsTimer: NodeJS.Timeout = null; - // handle for the delayed call to _sendOutgoingRoomKeyRequests. Non-null - // if the callback has been set, or if it is still running. - this._sendOutgoingRoomKeyRequestsTimer = null; + // sanity check to ensure that we don't end up with two concurrent runs + // of sendOutgoingRoomKeyRequests + private sendOutgoingRoomKeyRequestsRunning = false; - // sanity check to ensure that we don't end up with two concurrent runs - // of _sendOutgoingRoomKeyRequests - this._sendOutgoingRoomKeyRequestsRunning = false; + private clientRunning = false; - this._clientRunning = false; - } + constructor( + private readonly baseApis: MatrixClient, + private readonly deviceId: string, + private readonly cryptoStore: CryptoStore, + ) {} /** * Called when the client is started. Sets background processes running. */ - start() { - this._clientRunning = true; + public start(): void { + this.clientRunning = true; } /** * Called when the client is stopped. Stops any running background processes. */ - stop() { + public stop(): void { logger.log('stopping OutgoingRoomKeyRequestManager'); // stop the timer on the next run - this._clientRunning = false; + this.clientRunning = false; } /** * Send any requests that have been queued */ - sendQueuedRequests() { - this._startTimer(); + public sendQueuedRequests(): void { + this.startTimer(); } /** @@ -131,95 +132,99 @@ export class OutgoingRoomKeyRequestManager { * pending list (or we have established that a similar request already * exists) */ - async queueRoomKeyRequest(requestBody, recipients, resend=false) { - const req = await this._cryptoStore.getOutgoingRoomKeyRequest( + public async queueRoomKeyRequest( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise { + const req = await this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ); if (!req) { - await this._cryptoStore.getOrAddOutgoingRoomKeyRequest({ + await this.cryptoStore.getOrAddOutgoingRoomKeyRequest({ requestBody: requestBody, recipients: recipients, - requestId: this._baseApis.makeTxnId(), - state: ROOM_KEY_REQUEST_STATES.UNSENT, + requestId: this.baseApis.makeTxnId(), + state: RoomKeyRequestState.Unsent, }); } else { switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - case ROOM_KEY_REQUEST_STATES.UNSENT: - // nothing to do here, since we're going to send a request anyways - return; + case RoomKeyRequestState.CancellationPendingAndWillResend: + case RoomKeyRequestState.Unsent: + // nothing to do here, since we're going to send a request anyways + return; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: { - // existing request is about to be cancelled. If we want to - // resend, then change the state so that it resends after - // cancelling. Otherwise, just cancel the cancellation. - const state = resend ? - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND : - ROOM_KEY_REQUEST_STATES.SENT; - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - }, - ); - break; - } - case ROOM_KEY_REQUEST_STATES.SENT: { - // a request has already been sent. If we don't want to - // resend, then do nothing. If we do want to, then cancel the - // existing request and send a new one. - if (resend) { - const state = - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND; - const updatedReq = - await this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state, - cancellationTxnId: this._baseApis.makeTxnId(), - // need to use a new transaction ID so that - // the request gets sent - requestTxnId: this._baseApis.makeTxnId(), - }, - ); - if (!updatedReq) { - // updateOutgoingRoomKeyRequest couldn't find the request - // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have - // raced with another tab to mark the request cancelled. - // Try again, to make sure the request is resent. - return await this.queueRoomKeyRequest( - requestBody, recipients, resend, - ); - } - - // We don't want to wait for the timer, so we send it - // immediately. (We might actually end up racing with the timer, - // but that's ok: even if we make the request twice, we'll do it - // with the same transaction_id, so only one message will get - // sent). - // - // (We also don't want to wait for the response from the server - // here, as it will slow down processing of received keys if we - // do.) - try { - await this._sendOutgoingRoomKeyRequestCancellation( - updatedReq, - true, - ); - } catch (e) { - logger.error( - "Error sending room key request cancellation;" - + " will retry later.", e, - ); - } - // The request has transitioned from - // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We - // still need to resend the request which is now UNSENT, so - // start the timer if it isn't already started. + case RoomKeyRequestState.CancellationPending: { + // existing request is about to be cancelled. If we want to + // resend, then change the state so that it resends after + // cancelling. Otherwise, just cancel the cancellation. + const state = resend ? + RoomKeyRequestState.CancellationPendingAndWillResend : + RoomKeyRequestState.Sent; + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + }, + ); + break; } - break; - } - default: - throw new Error('unhandled state: ' + req.state); + case RoomKeyRequestState.Sent: { + // a request has already been sent. If we don't want to + // resend, then do nothing. If we do want to, then cancel the + // existing request and send a new one. + if (resend) { + const state = + RoomKeyRequestState.CancellationPendingAndWillResend; + const updatedReq = + await this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state, + cancellationTxnId: this.baseApis.makeTxnId(), + // need to use a new transaction ID so that + // the request gets sent + requestTxnId: this.baseApis.makeTxnId(), + }, + ); + if (!updatedReq) { + // updateOutgoingRoomKeyRequest couldn't find the request + // in state ROOM_KEY_REQUEST_STATES.SENT, so we must have + // raced with another tab to mark the request cancelled. + // Try again, to make sure the request is resent. + return await this.queueRoomKeyRequest( + requestBody, recipients, resend, + ); + } + + // We don't want to wait for the timer, so we send it + // immediately. (We might actually end up racing with the timer, + // but that's ok: even if we make the request twice, we'll do it + // with the same transaction_id, so only one message will get + // sent). + // + // (We also don't want to wait for the response from the server + // here, as it will slow down processing of received keys if we + // do.) + try { + await this.sendOutgoingRoomKeyRequestCancellation( + updatedReq, + true, + ); + } catch (e) { + logger.error( + "Error sending room key request cancellation;" + + " will retry later.", e, + ); + } + // The request has transitioned from + // CANCELLATION_PENDING_AND_WILL_RESEND to UNSENT. We + // still need to resend the request which is now UNSENT, so + // start the timer if it isn't already started. + } + break; + } + default: + throw new Error('unhandled state: ' + req.state); } } } @@ -232,8 +237,8 @@ export class OutgoingRoomKeyRequestManager { * @returns {Promise} resolves when the request has been updated in our * pending list. */ - cancelRoomKeyRequest(requestBody) { - return this._cryptoStore.getOutgoingRoomKeyRequest( + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): Promise { + return this.cryptoStore.getOutgoingRoomKeyRequest( requestBody, ).then((req) => { if (!req) { @@ -241,12 +246,12 @@ export class OutgoingRoomKeyRequestManager { return; } switch (req.state) { - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: + case RoomKeyRequestState.CancellationPending: + case RoomKeyRequestState.CancellationPendingAndWillResend: // nothing to do here return; - case ROOM_KEY_REQUEST_STATES.UNSENT: + case RoomKeyRequestState.Unsent: // just delete it // FIXME: ghahah we may have attempted to send it, and @@ -258,16 +263,16 @@ export class OutgoingRoomKeyRequestManager { 'deleting unnecessary room key request for ' + stringifyRequestBody(requestBody), ); - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, ); - case ROOM_KEY_REQUEST_STATES.SENT: { + case RoomKeyRequestState.Sent: { // send a cancellation. - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.SENT, { - state: ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - cancellationTxnId: this._baseApis.makeTxnId(), + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Sent, { + state: RoomKeyRequestState.CancellationPending, + cancellationTxnId: this.baseApis.makeTxnId(), }, ).then((updatedReq) => { if (!updatedReq) { @@ -294,14 +299,14 @@ export class OutgoingRoomKeyRequestManager { // (We also don't want to wait for the response from the server // here, as it will slow down processing of received keys if we // do.) - this._sendOutgoingRoomKeyRequestCancellation( + this.sendOutgoingRoomKeyRequestCancellation( updatedReq, ).catch((e) => { logger.error( "Error sending room key request cancellation;" + " will retry later.", e, ); - this._startTimer(); + this.startTimer(); }); }); } @@ -320,10 +325,8 @@ export class OutgoingRoomKeyRequestManager { * @return {Promise} resolves to a list of all the * {@link module:crypto/store/base~OutgoingRoomKeyRequest} */ - getOutgoingSentRoomKeyRequest(userId, deviceId) { - return this._cryptoStore.getOutgoingRoomKeyRequestsByTarget( - userId, deviceId, [ROOM_KEY_REQUEST_STATES.SENT], - ); + public getOutgoingSentRoomKeyRequest(userId: string, deviceId: string): OutgoingRoomKeyRequest[] { + return this.cryptoStore.getOutgoingRoomKeyRequestsByTarget(userId, deviceId, [RoomKeyRequestState.Sent]); } /** @@ -333,29 +336,27 @@ export class OutgoingRoomKeyRequestManager { * For example, after initialization or self-verification. * @return {Promise} An array of `queueRoomKeyRequest` outputs. */ - async cancelAndResendAllOutgoingRequests() { - const outgoings = await this._cryptoStore.getAllOutgoingRoomKeyRequestsByState( - ROOM_KEY_REQUEST_STATES.SENT, - ); + public async cancelAndResendAllOutgoingRequests(): Promise { + const outgoings = await this.cryptoStore.getAllOutgoingRoomKeyRequestsByState(RoomKeyRequestState.Sent); return Promise.all(outgoings.map(({ requestBody, recipients }) => this.queueRoomKeyRequest(requestBody, recipients, true))); } // start the background timer to send queued requests, if the timer isn't // already running - _startTimer() { - if (this._sendOutgoingRoomKeyRequestsTimer) { + private startTimer(): void { + if (this.sendOutgoingRoomKeyRequestsTimer) { return; } const startSendingOutgoingRoomKeyRequests = () => { - if (this._sendOutgoingRoomKeyRequestsRunning) { + if (this.sendOutgoingRoomKeyRequestsRunning) { throw new Error("RoomKeyRequestSend already in progress!"); } - this._sendOutgoingRoomKeyRequestsRunning = true; + this.sendOutgoingRoomKeyRequestsRunning = true; - this._sendOutgoingRoomKeyRequests().finally(() => { - this._sendOutgoingRoomKeyRequestsRunning = false; + this.sendOutgoingRoomKeyRequests().finally(() => { + this.sendOutgoingRoomKeyRequestsRunning = false; }).catch((e) => { // this should only happen if there is an indexeddb error, // in which case we're a bit stuffed anyway. @@ -365,7 +366,7 @@ export class OutgoingRoomKeyRequestManager { }); }; - this._sendOutgoingRoomKeyRequestsTimer = global.setTimeout( + this.sendOutgoingRoomKeyRequestsTimer = global.setTimeout( startSendingOutgoingRoomKeyRequests, SEND_KEY_REQUESTS_DELAY_MS, ); @@ -374,47 +375,47 @@ export class OutgoingRoomKeyRequestManager { // look for and send any queued requests. Runs itself recursively until // there are no more requests, or there is an error (in which case, the // timer will be restarted before the promise resolves). - _sendOutgoingRoomKeyRequests() { - if (!this._clientRunning) { - this._sendOutgoingRoomKeyRequestsTimer = null; + private sendOutgoingRoomKeyRequests(): Promise { + if (!this.clientRunning) { + this.sendOutgoingRoomKeyRequestsTimer = null; return Promise.resolve(); } - return this._cryptoStore.getOutgoingRoomKeyRequestByState([ - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - ROOM_KEY_REQUEST_STATES.UNSENT, - ]).then((req) => { + return this.cryptoStore.getOutgoingRoomKeyRequestByState([ + RoomKeyRequestState.CancellationPending, + RoomKeyRequestState.CancellationPendingAndWillResend, + RoomKeyRequestState.Unsent, + ]).then((req: OutgoingRoomKeyRequest) => { if (!req) { - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; return; } let prom; switch (req.state) { - case ROOM_KEY_REQUEST_STATES.UNSENT: - prom = this._sendOutgoingRoomKeyRequest(req); + case RoomKeyRequestState.Unsent: + prom = this.sendOutgoingRoomKeyRequest(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING: - prom = this._sendOutgoingRoomKeyRequestCancellation(req); + case RoomKeyRequestState.CancellationPending: + prom = this.sendOutgoingRoomKeyRequestCancellation(req); break; - case ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND: - prom = this._sendOutgoingRoomKeyRequestCancellation(req, true); + case RoomKeyRequestState.CancellationPendingAndWillResend: + prom = this.sendOutgoingRoomKeyRequestCancellation(req, true); break; } return prom.then(() => { // go around the loop again - return this._sendOutgoingRoomKeyRequests(); + return this.sendOutgoingRoomKeyRequests(); }).catch((e) => { logger.error("Error sending room key request; will retry later.", e); - this._sendOutgoingRoomKeyRequestsTimer = null; + this.sendOutgoingRoomKeyRequestsTimer = null; }); }); } // given a RoomKeyRequest, send it and update the request record - _sendOutgoingRoomKeyRequest(req) { + private sendOutgoingRoomKeyRequest(req: OutgoingRoomKeyRequest): Promise { logger.log( `Requesting keys for ${stringifyRequestBody(req.requestBody)}` + ` from ${stringifyRecipientList(req.recipients)}` + @@ -423,24 +424,24 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, body: req.requestBody, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.requestTxnId || req.requestId, ).then(() => { - return this._cryptoStore.updateOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.UNSENT, - { state: ROOM_KEY_REQUEST_STATES.SENT }, + return this.cryptoStore.updateOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.Unsent, + { state: RoomKeyRequestState.Sent }, ); }); } // Given a RoomKeyRequest, cancel it and delete the request record unless // andResend is set, in which case transition to UNSENT. - _sendOutgoingRoomKeyRequestCancellation(req, andResend) { + private sendOutgoingRoomKeyRequestCancellation(req: OutgoingRoomKeyRequest, andResend = false): Promise { logger.log( `Sending cancellation for key request for ` + `${stringifyRequestBody(req.requestBody)} to ` + @@ -450,30 +451,30 @@ export class OutgoingRoomKeyRequestManager { const requestMessage = { action: "request_cancellation", - requesting_device_id: this._deviceId, + requesting_device_id: this.deviceId, request_id: req.requestId, }; - return this._sendMessageToDevices( + return this.sendMessageToDevices( requestMessage, req.recipients, req.cancellationTxnId, ).then(() => { if (andResend) { // We want to resend, so transition to UNSENT - return this._cryptoStore.updateOutgoingRoomKeyRequest( + return this.cryptoStore.updateOutgoingRoomKeyRequest( req.requestId, - ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING_AND_WILL_RESEND, - { state: ROOM_KEY_REQUEST_STATES.UNSENT }, + RoomKeyRequestState.CancellationPendingAndWillResend, + { state: RoomKeyRequestState.Unsent }, ); } - return this._cryptoStore.deleteOutgoingRoomKeyRequest( - req.requestId, ROOM_KEY_REQUEST_STATES.CANCELLATION_PENDING, + return this.cryptoStore.deleteOutgoingRoomKeyRequest( + req.requestId, RoomKeyRequestState.CancellationPending, ); }); } // send a RoomKeyRequest to a list of recipients - _sendMessageToDevices(message, recipients, txnId) { - const contentMap = {}; + private sendMessageToDevices(message, recipients, txnId: string): Promise<{}> { + const contentMap: Record>> = {}; for (const recip of recipients) { if (!contentMap[recip.userId]) { contentMap[recip.userId] = {}; @@ -481,9 +482,7 @@ export class OutgoingRoomKeyRequestManager { contentMap[recip.userId][recip.deviceId] = message; } - return this._baseApis.sendToDevice( - 'm.room_key_request', contentMap, txnId, - ); + return this.baseApis.sendToDevice(EventType.RoomKeyRequest, contentMap, txnId); } } diff --git a/src/crypto/RoomList.js b/src/crypto/RoomList.ts similarity index 52% rename from src/crypto/RoomList.js rename to src/crypto/RoomList.ts index b00445247..ab653f456 100644 --- a/src/crypto/RoomList.js +++ b/src/crypto/RoomList.ts @@ -1,5 +1,5 @@ /* -Copyright 2018, 2019 New Vector Ltd +Copyright 2018 - 2021 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. @@ -21,44 +21,51 @@ limitations under the License. */ import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { CryptoStore } from "../client"; + +/* eslint-disable camelcase */ +interface IRoomEncryption { + algorithm: string; + rotation_period_ms: number; + rotation_period_msgs: number; +} +/* eslint-enable camelcase */ /** * @alias module:crypto/RoomList */ export class RoomList { - constructor(cryptoStore) { - this._cryptoStore = cryptoStore; + // Object of roomId -> room e2e info object (body of the m.room.encryption event) + private roomEncryption: Record = {}; - // Object of roomId -> room e2e info object (body of the m.room.encryption event) - this._roomEncryption = {}; - } + constructor(private readonly cryptoStore: CryptoStore) {} - async init() { - await this._cryptoStore.doTxn( + public async init(): Promise { + await this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this._cryptoStore.getEndToEndRooms(txn, (result) => { - this._roomEncryption = result; + this.cryptoStore.getEndToEndRooms(txn, (result) => { + this.roomEncryption = result; }); }, ); } - getRoomEncryption(roomId) { - return this._roomEncryption[roomId] || null; + public getRoomEncryption(roomId: string): IRoomEncryption { + return this.roomEncryption[roomId] || null; } - isRoomEncrypted(roomId) { + public isRoomEncrypted(roomId: string): boolean { return Boolean(this.getRoomEncryption(roomId)); } - async setRoomEncryption(roomId, roomInfo) { + public async setRoomEncryption(roomId: string, roomInfo: IRoomEncryption): Promise { // important that this happens before calling into the store // as it prevents the Crypto::setRoomEncryption from calling // this twice for consecutive m.room.encryption events - this._roomEncryption[roomId] = roomInfo; - await this._cryptoStore.doTxn( + this.roomEncryption[roomId] = roomInfo; + await this.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ROOMS], (txn) => { - this._cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); + this.cryptoStore.storeEndToEndRoom(roomId, roomInfo, txn); }, ); } diff --git a/src/crypto/SecretStorage.js b/src/crypto/SecretStorage.js index 5e506007a..065a22def 100644 --- a/src/crypto/SecretStorage.js +++ b/src/crypto/SecretStorage.js @@ -474,11 +474,11 @@ export class SecretStorage extends EventEmitter { }; const encryptedContent = { algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._baseApis.crypto._olmDevice.deviceCurve25519Key, + sender_key: this._baseApis.crypto.olmDevice.deviceCurve25519Key, ciphertext: {}, }; await olmlib.ensureOlmSessionsForDevices( - this._baseApis.crypto._olmDevice, + this._baseApis.crypto.olmDevice, this._baseApis, { [sender]: [ @@ -490,7 +490,7 @@ export class SecretStorage extends EventEmitter { encryptedContent.ciphertext, this._baseApis.getUserId(), this._baseApis.deviceId, - this._baseApis.crypto._olmDevice, + this._baseApis.crypto.olmDevice, sender, this._baseApis.getStoredDevice(sender, deviceId), payload, @@ -521,7 +521,7 @@ export class SecretStorage extends EventEmitter { if (requestControl) { // make sure that the device that sent it is one of the devices that // we requested from - const deviceInfo = this._baseApis.crypto._deviceList.getDeviceByIdentityKey( + const deviceInfo = this._baseApis.crypto.deviceList.getDeviceByIdentityKey( olmlib.OLM_ALGORITHM, event.getSenderKey(), ); diff --git a/src/crypto/aes.js b/src/crypto/aes.ts similarity index 75% rename from src/crypto/aes.js rename to src/crypto/aes.ts index 4e6c2668e..25cedcc6c 100644 --- a/src/crypto/aes.js +++ b/src/crypto/aes.ts @@ -1,5 +1,5 @@ /* -Copyright 2020 The Matrix.org Foundation C.I.C. +Copyright 2020 - 2021 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. @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import type { BinaryLike } from "crypto"; + import { getCrypto } from '../utils'; import { decodeBase64, encodeBase64 } from './olmlib'; @@ -21,7 +23,13 @@ const subtleCrypto = (typeof window !== "undefined" && window.crypto) ? (window.crypto.subtle || window.crypto.webkitSubtle) : null; // salt for HKDF, with 8 bytes of zeros -const zerosalt = new Uint8Array(8); +const zeroSalt = new Uint8Array(8); + +export interface IEncryptedPayload { + iv: string; + ciphertext: string; + mac: string; +} /** * encrypt a string in Node.js @@ -31,7 +39,7 @@ const zerosalt = new Uint8Array(8); * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptNode(data, key, name, ivStr) { +async function encryptNode(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -52,15 +60,17 @@ async function encryptNode(data, key, name, ivStr) { const [aesKey, hmacKey] = deriveKeysNode(key, name); const cipher = crypto.createCipheriv("aes-256-ctr", aesKey, iv); - const ciphertext = cipher.update(data, "utf-8", "base64") - + cipher.final("base64"); + const ciphertext = Buffer.concat([ + cipher.update(data, "utf8"), + cipher.final(), + ]); const hmac = crypto.createHmac("sha256", hmacKey) - .update(ciphertext, "base64").digest("base64"); + .update(ciphertext).digest("base64"); return { iv: encodeBase64(iv), - ciphertext: ciphertext, + ciphertext: ciphertext.toString("base64"), mac: hmac, }; } @@ -75,7 +85,7 @@ async function encryptNode(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptNode(data, key, name) { +async function decryptNode(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const crypto = getCrypto(); if (!crypto) { throw new Error("No usable crypto implementation"); @@ -84,7 +94,8 @@ async function decryptNode(data, key, name) { const [aesKey, hmacKey] = deriveKeysNode(key, name); const hmac = crypto.createHmac("sha256", hmacKey) - .update(data.ciphertext, "base64").digest("base64").replace(/=+$/g, ''); + .update(Buffer.from(data.ciphertext, "base64")) + .digest("base64").replace(/=+$/g, ''); if (hmac !== data.mac.replace(/=+$/g, '')) { throw new Error(`Error decrypting secret ${name}: bad MAC`); @@ -93,21 +104,20 @@ async function decryptNode(data, key, name) { const decipher = crypto.createDecipheriv( "aes-256-ctr", aesKey, decodeBase64(data.iv), ); - return decipher.update(data.ciphertext, "base64", "utf-8") - + decipher.final("utf-8"); + return decipher.update(data.ciphertext, "base64", "utf8") + + decipher.final("utf8"); } -function deriveKeysNode(key, name) { +function deriveKeysNode(key: BinaryLike, name: string): [Buffer, Buffer] { const crypto = getCrypto(); - const prk = crypto.createHmac("sha256", zerosalt) - .update(key).digest(); + const prk = crypto.createHmac("sha256", zeroSalt).update(key).digest(); const b = Buffer.alloc(1, 1); const aesKey = crypto.createHmac("sha256", prk) - .update(name, "utf-8").update(b).digest(); + .update(name, "utf8").update(b).digest(); b[0] = 2; const hmacKey = crypto.createHmac("sha256", prk) - .update(aesKey).update(name, "utf-8").update(b).digest(); + .update(aesKey).update(name, "utf8").update(b).digest(); return [aesKey, hmacKey]; } @@ -120,7 +130,7 @@ function deriveKeysNode(key, name) { * @param {string} name the name of the secret * @param {string} ivStr the initialization vector to use */ -async function encryptBrowser(data, key, name, ivStr) { +async function encryptBrowser(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { let iv; if (ivStr) { iv = decodeBase64(ivStr); @@ -170,7 +180,7 @@ async function encryptBrowser(data, key, name, ivStr) { * @param {Uint8Array} key the encryption key to use * @param {string} name the name of the secret */ -async function decryptBrowser(data, key, name) { +async function decryptBrowser(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { const [aesKey, hmacKey] = await deriveKeysBrowser(key, name); const ciphertext = decodeBase64(data.ciphertext); @@ -197,7 +207,7 @@ async function decryptBrowser(data, key, name) { return new TextDecoder().decode(new Uint8Array(plaintext)); } -async function deriveKeysBrowser(key, name) { +async function deriveKeysBrowser(key: Uint8Array, name: string): Promise<[CryptoKey, CryptoKey]> { const hkdfkey = await subtleCrypto.importKey( 'raw', key, @@ -208,7 +218,9 @@ async function deriveKeysBrowser(key, name) { const keybits = await subtleCrypto.deriveBits( { name: "HKDF", - salt: zerosalt, + salt: zeroSalt, + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore: https://github.com/microsoft/TypeScript-DOM-lib-generator/pull/879 info: (new TextEncoder().encode(name)), hash: "SHA-256", }, @@ -241,12 +253,12 @@ async function deriveKeysBrowser(key, name) { return await Promise.all([aesProm, hmacProm]); } -export function encryptAES(...args) { - return subtleCrypto ? encryptBrowser(...args) : encryptNode(...args); +export function encryptAES(data: string, key: Uint8Array, name: string, ivStr?: string): Promise { + return subtleCrypto ? encryptBrowser(data, key, name, ivStr) : encryptNode(data, key, name, ivStr); } -export function decryptAES(...args) { - return subtleCrypto ? decryptBrowser(...args) : decryptNode(...args); +export function decryptAES(data: IEncryptedPayload, key: Uint8Array, name: string): Promise { + return subtleCrypto ? decryptBrowser(data, key, name) : decryptNode(data, key, name); } // string of zeroes, for calculating the key check diff --git a/src/crypto/algorithms/base.js b/src/crypto/algorithms/base.ts similarity index 61% rename from src/crypto/algorithms/base.js rename to src/crypto/algorithms/base.ts index 87b8a82c0..7f687774b 100644 --- a/src/crypto/algorithms/base.js +++ b/src/crypto/algorithms/base.ts @@ -1,5 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd +Copyright 2016 - 2021 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. @@ -20,13 +20,22 @@ limitations under the License. * @module */ +import { MatrixClient } from "../../client"; +import { Room } from "../../models/room"; +import { OlmDevice } from "../OlmDevice"; +import { MatrixEvent, RoomMember } from "../.."; +import { Crypto, IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from ".."; +import { DeviceInfo } from "../deviceinfo"; + /** * map of registered encryption algorithm classes. A map from string to {@link * module:crypto/algorithms/base.EncryptionAlgorithm|EncryptionAlgorithm} class * * @type {Object.} */ -export const ENCRYPTION_CLASSES = {}; +export const ENCRYPTION_CLASSES: Record EncryptionAlgorithm> = {}; + +type DecryptionClassParams = Omit; /** * map of registered encryption algorithm classes. Map from string to {@link @@ -34,7 +43,17 @@ export const ENCRYPTION_CLASSES = {}; * * @type {Object.} */ -export const DECRYPTION_CLASSES = {}; +export const DECRYPTION_CLASSES: Record DecryptionAlgorithm> = {}; + +interface IParams { + userId: string; + deviceId: string; + crypto: Crypto; + olmDevice: OlmDevice; + baseApis: MatrixClient; + roomId: string; + config: object; +} /** * base type for encryption implementations @@ -50,14 +69,21 @@ export const DECRYPTION_CLASSES = {}; * @param {string} params.roomId The ID of the room we will be sending to * @param {object} params.config The body of the m.room.encryption event */ -export class EncryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._deviceId = params.deviceId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class EncryptionAlgorithm { + protected readonly userId: string; + protected readonly deviceId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; + + constructor(params: IParams) { + this.userId = params.userId; + this.deviceId = params.deviceId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -66,21 +92,22 @@ export class EncryptionAlgorithm { * * @param {module:models/room} room the room the event is in */ - prepareToEncrypt(room) { - } + public prepareToEncrypt(room: Room): void {} /** * Encrypt a message event * * @method module:crypto/algorithms/base.EncryptionAlgorithm.encryptMessage + * @public * @abstract * * @param {module:models/room} room * @param {string} eventType - * @param {object} plaintext event content + * @param {object} content event content * * @return {Promise} Promise which resolves to the new event body */ + public abstract encryptMessage(room: Room, eventType: string, content: object): Promise; /** * Called when the membership of a member of the room changes. @@ -89,9 +116,18 @@ export class EncryptionAlgorithm { * @param {module:models/room-member} member user whose membership changed * @param {string=} oldMembership previous membership * @public + * @abstract */ - onRoomMembership(event, member, oldMembership) { - } + public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {} + + public reshareKeyWithDevice?( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise; + + public forceDiscardSession?(): void; } /** @@ -106,13 +142,19 @@ export class EncryptionAlgorithm { * @param {string=} params.roomId The ID of the room we will be receiving * from. Null for to-device events. */ -export class DecryptionAlgorithm { - constructor(params) { - this._userId = params.userId; - this._crypto = params.crypto; - this._olmDevice = params.olmDevice; - this._baseApis = params.baseApis; - this._roomId = params.roomId; +export abstract class DecryptionAlgorithm { + protected readonly userId: string; + protected readonly crypto: Crypto; + protected readonly olmDevice: OlmDevice; + protected readonly baseApis: MatrixClient; + protected readonly roomId: string; + + constructor(params: DecryptionClassParams) { + this.userId = params.userId; + this.crypto = params.crypto; + this.olmDevice = params.olmDevice; + this.baseApis = params.baseApis; + this.roomId = params.roomId; } /** @@ -127,6 +169,7 @@ export class DecryptionAlgorithm { * resolves once we have finished decrypting. Rejects with an * `algorithms.DecryptionError` if there is a problem decrypting the event. */ + public abstract decryptEvent(event: MatrixEvent): Promise; /** * Handle a key event @@ -135,7 +178,7 @@ export class DecryptionAlgorithm { * * @param {module:models/event.MatrixEvent} params event key event */ - onRoomKeyEvent(params) { + public onRoomKeyEvent(params: MatrixEvent): void { // ignore by default } @@ -143,8 +186,9 @@ export class DecryptionAlgorithm { * Import a room key * * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} opts object */ - importRoomKey(session) { + public async importRoomKey(session: IMegolmSessionData, opts: object): Promise { // ignore by default } @@ -155,7 +199,7 @@ export class DecryptionAlgorithm { * @return {Promise} true if we have the keys and could (theoretically) share * them; else false. */ - hasKeysForKeyRequest(keyRequest) { + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { return Promise.resolve(false); } @@ -164,7 +208,7 @@ export class DecryptionAlgorithm { * * @param {module:crypto~IncomingRoomKeyRequest} keyRequest */ - shareKeysWithDevice(keyRequest) { + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { throw new Error("shareKeysWithDevice not supported for this DecryptionAlgorithm"); } @@ -174,9 +218,13 @@ export class DecryptionAlgorithm { * * @param {string} senderKey the sender's key */ - async retryDecryptionFromSender(senderKey) { + public async retryDecryptionFromSender(senderKey: string): Promise { // ignore by default + return false; } + + public onRoomKeyWithheldEvent?(event: MatrixEvent): Promise; + public sendSharedHistoryInboundSessions?(devicesByUser: Record): Promise; } /** @@ -191,22 +239,21 @@ export class DecryptionAlgorithm { * @extends Error */ export class DecryptionError extends Error { - constructor(code, msg, details) { + public readonly detailedString: string; + + constructor(public readonly code: string, msg: string, details?: Record) { super(msg); this.code = code; this.name = 'DecryptionError'; - this.detailedString = _detailedStringForDecryptionError(this, details); + this.detailedString = detailedStringForDecryptionError(this, details); } } -function _detailedStringForDecryptionError(err, details) { +function detailedStringForDecryptionError(err: DecryptionError, details?: Record): string { let result = err.name + '[msg: ' + err.message; if (details) { - result += ', ' + - Object.keys(details).map( - (k) => k + ': ' + details[k], - ).join(', '); + result += ', ' + Object.keys(details).map((k) => k + ': ' + details[k]).join(', '); } result += ']'; @@ -224,7 +271,7 @@ function _detailedStringForDecryptionError(err, details) { * @extends Error */ export class UnknownDeviceError extends Error { - constructor(msg, devices) { + constructor(msg: string, public readonly devices: Record>) { super(msg); this.name = "UnknownDeviceError"; this.devices = devices; @@ -244,7 +291,11 @@ export class UnknownDeviceError extends Error { * module:crypto/algorithms/base.DecryptionAlgorithm|DecryptionAlgorithm} * implementation */ -export function registerAlgorithm(algorithm, encryptor, decryptor) { +export function registerAlgorithm( + algorithm: string, + encryptor: new (params: IParams) => EncryptionAlgorithm, + decryptor: new (params: Omit) => DecryptionAlgorithm, +): void { ENCRYPTION_CLASSES[algorithm] = encryptor; DECRYPTION_CLASSES[algorithm] = decryptor; } diff --git a/src/crypto/algorithms/index.js b/src/crypto/algorithms/index.ts similarity index 88% rename from src/crypto/algorithms/index.js rename to src/crypto/algorithms/index.ts index 0fb646cfe..3dd1158a0 100644 --- a/src/crypto/algorithms/index.js +++ b/src/crypto/algorithms/index.ts @@ -1,6 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 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. diff --git a/src/crypto/algorithms/megolm.js b/src/crypto/algorithms/megolm.js deleted file mode 100644 index 4986bbfff..000000000 --- a/src/crypto/algorithms/megolm.js +++ /dev/null @@ -1,1788 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018 New Vector Ltd -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. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/megolm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, - UnknownDeviceError, -} from "./base"; - -import { WITHHELD_MESSAGES } from '../OlmDevice'; - -// determine whether the key can be shared with invitees -function isRoomSharedHistory(room) { - const visibilityEvent = room.currentState && - room.currentState.getStateEvents("m.room.history_visibility", ""); - // NOTE: if the room visibility is unset, it would normally default to - // "world_readable". - // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) - // But we will be paranoid here, and treat it as a situation where the room - // is not shared-history - const visibility = visibilityEvent && visibilityEvent.getContent() && - visibilityEvent.getContent().history_visibility; - return ["world_readable", "shared"].includes(visibility); -} - -/** - * @private - * @constructor - * - * @param {string} sessionId - * @param {boolean} sharedHistory whether the session can be freely shared with - * other group members, according to the room history visibility settings - * - * @property {string} sessionId - * @property {Number} useCount number of times this session has been used - * @property {Number} creationTime when the session was created (ms since the epoch) - * - * @property {object} sharedWithDevices - * devices with which we have shared the session key - * userId -> {deviceId -> msgindex} - */ -function OutboundSessionInfo(sessionId, sharedHistory = false) { - this.sessionId = sessionId; - this.useCount = 0; - this.creationTime = new Date().getTime(); - this.sharedWithDevices = {}; - this.blockedDevicesNotified = {}; - this.sharedHistory = sharedHistory; -} - -/** - * Check if it's time to rotate the session - * - * @param {Number} rotationPeriodMsgs - * @param {Number} rotationPeriodMs - * @return {Boolean} - */ -OutboundSessionInfo.prototype.needsRotation = function( - rotationPeriodMsgs, rotationPeriodMs, -) { - const sessionLifetime = new Date().getTime() - this.creationTime; - - if (this.useCount >= rotationPeriodMsgs || - sessionLifetime >= rotationPeriodMs - ) { - logger.log( - "Rotating megolm session after " + this.useCount + - " messages, " + sessionLifetime + "ms", - ); - return true; - } - - return false; -}; - -OutboundSessionInfo.prototype.markSharedWithDevice = function( - userId, deviceId, chainIndex, -) { - if (!this.sharedWithDevices[userId]) { - this.sharedWithDevices[userId] = {}; - } - this.sharedWithDevices[userId][deviceId] = chainIndex; -}; - -OutboundSessionInfo.prototype.markNotifiedBlockedDevice = function( - userId, deviceId, -) { - if (!this.blockedDevicesNotified[userId]) { - this.blockedDevicesNotified[userId] = {}; - } - this.blockedDevicesNotified[userId][deviceId] = true; -}; - -/** - * Determine if this session has been shared with devices which it shouldn't - * have been. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - * - * @return {Boolean} true if we have shared the session with devices which aren't - * in devicesInRoom. - */ -OutboundSessionInfo.prototype.sharedWithTooManyDevices = function( - devicesInRoom, -) { - for (const userId in this.sharedWithDevices) { - if (!this.sharedWithDevices.hasOwnProperty(userId)) { - continue; - } - - if (!devicesInRoom.hasOwnProperty(userId)) { - logger.log("Starting new megolm session because we shared with " + userId); - return true; - } - - for (const deviceId in this.sharedWithDevices[userId]) { - if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { - continue; - } - - if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { - logger.log( - "Starting new megolm session because we shared with " + - userId + ":" + deviceId, - ); - return true; - } - } - } -}; - -/** - * Megolm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function MegolmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - - // the most recent attempt to set up a session. This is used to serialise - // the session setups, so that we have a race-free view of which session we - // are using, and which devices we have shared the keys with. It resolves - // with an OutboundSessionInfo (or undefined, for the first message in the - // room). - this._setupPromise = Promise.resolve(); - - // Map of outbound sessions by sessions ID. Used if we need a particular - // session (the session we're currently using to send is always obtained - // using _setupPromise). - this._outboundSessions = {}; - - // default rotation periods - this._sessionRotationPeriodMsgs = 100; - this._sessionRotationPeriodMs = 7 * 24 * 3600 * 1000; - - if (params.config.rotation_period_ms !== undefined) { - this._sessionRotationPeriodMs = params.config.rotation_period_ms; - } - - if (params.config.rotation_period_msgs !== undefined) { - this._sessionRotationPeriodMsgs = params.config.rotation_period_msgs; - } -} -utils.inherits(MegolmEncryption, EncryptionAlgorithm); - -/** - * @private - * - * @param {module:models/room} room - * @param {Object} devicesInRoom The devices in this room, indexed by user ID - * @param {Object} blocked The devices that are blocked, indexed by user ID - * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm - * session creation - * - * @return {Promise} Promise which resolves to the - * OutboundSessionInfo when setup is complete. - */ -MegolmEncryption.prototype._ensureOutboundSession = async function( - room, devicesInRoom, blocked, singleOlmCreationPhase, -) { - let session; - - // takes the previous OutboundSessionInfo, and considers whether to create - // a new one. Also shares the key with any (new) devices in the room. - // Updates `session` to hold the final OutboundSessionInfo. - // - // returns a promise which resolves once the keyshare is successful. - const prepareSession = async (oldSession) => { - session = oldSession; - - const sharedHistory = isRoomSharedHistory(room); - - // history visibility changed - if (session && sharedHistory !== session.sharedHistory) { - session = null; - } - - // need to make a brand new session? - if (session && session.needsRotation(this._sessionRotationPeriodMsgs, - this._sessionRotationPeriodMs) - ) { - logger.log("Starting new megolm session because we need to rotate."); - session = null; - } - - // determine if we have shared with anyone we shouldn't have - if (session && session.sharedWithTooManyDevices(devicesInRoom)) { - session = null; - } - - if (!session) { - logger.log(`Starting new megolm session for room ${this._roomId}`); - session = await this._prepareNewSession(sharedHistory); - logger.log(`Started new megolm session ${session.sessionId} ` + - `for room ${this._roomId}`); - this._outboundSessions[session.sessionId] = session; - } - - // now check if we need to share with any devices - const shareMap = {}; - - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - - if ( - !session.sharedWithDevices[userId] || - session.sharedWithDevices[userId][deviceId] === undefined - ) { - shareMap[userId] = shareMap[userId] || []; - shareMap[userId].push(deviceInfo); - } - } - } - - const key = this._olmDevice.getOutboundGroupSessionKey(session.sessionId); - const payload = { - type: "m.room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": session.sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "org.matrix.msc3061.shared_history": sharedHistory, - }, - }; - const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( - this._olmDevice, this._baseApis, shareMap, - ); - - await Promise.all([ - (async () => { - // share keys with devices that we already have a session for - logger.debug(`Sharing keys with existing Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions( - session, key, payload, olmSessions, - ); - logger.debug(`Shared keys with existing Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this._roomId}`); - const errorDevices = []; - - // meanwhile, establish olm sessions for devices that we don't - // already have a session for, and share keys with them. If - // we're doing two phases of olm session creation, use a - // shorter timeout when fetching one-time keys for the first - // phase. - const start = Date.now(); - const failedServers = []; - await this._shareKeyWithDevices( - session, key, payload, devicesWithoutSession, errorDevices, - singleOlmCreationPhase ? 10000 : 2000, failedServers, - ); - logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this._roomId}`); - - if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { - // perform the second phase of olm session creation if requested, - // and if the first phase didn't take too long - (async () => { - // Retry sending keys to devices that we were unable to establish - // an olm session for. This time, we use a longer timeout, but we - // do this in the background and don't block anything else while we - // do this. We only need to retry users from servers that didn't - // respond the first time. - const retryDevices = {}; - const failedServerMap = new Set; - for (const server of failedServers) { - failedServerMap.add(server); - } - const failedDevices = []; - for (const { userId, deviceInfo } of errorDevices) { - const userHS = userId.slice(userId.indexOf(":") + 1); - if (failedServerMap.has(userHS)) { - retryDevices[userId] = retryDevices[userId] || []; - retryDevices[userId].push(deviceInfo); - } else { - // if we aren't going to retry, then handle it - // as a failed device - failedDevices.push({ userId, deviceInfo }); - } - } - - logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this._roomId}`); - await this._shareKeyWithDevices( - session, key, payload, retryDevices, failedDevices, 30000, - ); - logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this._roomId}`); - - await this._notifyFailedOlmDevices(session, key, failedDevices); - })(); - } else { - await this._notifyFailedOlmDevices(session, key, errorDevices); - } - logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this._roomId}`); - })(), - (async () => { - logger.debug(`Notifying blocked devices in ${this._roomId}`); - // also, notify blocked devices that they're blocked - const blockedMap = {}; - let blockedCount = 0; - for (const [userId, userBlockedDevices] of Object.entries(blocked)) { - for (const [deviceId, device] of Object.entries(userBlockedDevices)) { - if ( - !session.blockedDevicesNotified[userId] || - session.blockedDevicesNotified[userId][deviceId] === undefined - ) { - blockedMap[userId] = blockedMap[userId] || {}; - blockedMap[userId][deviceId] = { device }; - blockedCount++; - } - } - } - - await this._notifyBlockedDevices(session, blockedMap); - logger.debug(`Notified ${blockedCount} blocked devices in ${this._roomId}`); - })(), - ]); - }; - - // helper which returns the session prepared by prepareSession - function returnSession() { - return session; - } - - // first wait for the previous share to complete - const prom = this._setupPromise.then(prepareSession); - - // Ensure any failures are logged for debugging - prom.catch(e => { - logger.error(`Failed to ensure outbound session in ${this._roomId}`, e); - }); - - // _setupPromise resolves to `session` whether or not the share succeeds - this._setupPromise = prom.then(returnSession, returnSession); - - // but we return a promise which only resolves if the share was successful. - return prom.then(returnSession); -}; - -/** - * @private - * - * @param {boolean} sharedHistory - * - * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session - */ -MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) { - const sessionId = this._olmDevice.createOutboundGroupSession(); - const key = this._olmDevice.getOutboundGroupSessionKey(sessionId); - - await this._olmDevice.addInboundGroupSession( - this._roomId, this._olmDevice.deviceCurve25519Key, [], sessionId, - key.key, { ed25519: this._olmDevice.deviceEd25519Key }, false, - { sharedHistory: sharedHistory }, - ); - - // don't wait for it to complete - this._crypto._backupManager.backupGroupSession( - this._olmDevice.deviceCurve25519Key, sessionId, - ); - - return new OutboundSessionInfo(sessionId, sharedHistory); -}; - -/** - * Determines what devices in devicesByUser don't have an olm session as given - * in devicemap. - * - * @private - * - * @param {object} devicemap the devices that have olm sessions, as returned by - * olmlib.ensureOlmSessionsForDevices. - * @param {object} devicesByUser a map of user IDs to array of deviceInfo - * @param {array} [noOlmDevices] an array to fill with devices that don't have - * olm sessions - * - * @return {array} an array of devices that don't have olm sessions. If - * noOlmDevices is specified, then noOlmDevices will be returned. - */ -MegolmEncryption.prototype._getDevicesWithoutSessions = function( - devicemap, devicesByUser, noOlmDevices, -) { - noOlmDevices = noOlmDevices || []; - - for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { - const sessionResults = devicemap[userId]; - - for (const deviceInfo of devicesToShareWith) { - const deviceId = deviceInfo.deviceId; - - const sessionResult = sessionResults[deviceId]; - if (!sessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - - noOlmDevices.push({ userId, deviceInfo }); - delete sessionResults[deviceId]; - - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - continue; - } - } - } - - return noOlmDevices; -}; - -/** - * Splits the user device map into multiple chunks to reduce the number of - * devices we encrypt to per API call. - * - * @private - * - * @param {object} devicesByUser map from userid to list of devices - * - * @return {array>} the blocked devices, split into chunks - */ -MegolmEncryption.prototype._splitDevices = function(devicesByUser) { - const maxDevicesPerRequest = 20; - - // use an array where the slices of a content map gets stored - let currentSlice = []; - const mapSlices = [currentSlice]; - - for (const [userId, userDevices] of Object.entries(devicesByUser)) { - for (const deviceInfo of Object.values(userDevices)) { - currentSlice.push({ - userId: userId, - deviceInfo: deviceInfo.device, - }); - } - - // We do this in the per-user loop as we prefer that all messages to the - // same user end up in the same API call to make it easier for the - // server (e.g. only have to send one EDU if a remote user, etc). This - // does mean that if a user has many devices we may go over the desired - // limit, but its not a hard limit so that is fine. - if (currentSlice.length > maxDevicesPerRequest) { - // the current slice is filled up. Start inserting into the next slice - currentSlice = []; - mapSlices.push(currentSlice); - } - } - if (currentSlice.length === 0) { - mapSlices.pop(); - } - return mapSlices; -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {number} chainIndex current chain index - * - * @param {object} userDeviceMap - * mapping from userId to deviceInfo - * - * @param {object} payload fields to include in the encrypted payload - * - * @return {Promise} Promise which resolves once the key sharing - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._encryptAndSendKeysToDevices = function( - session, chainIndex, userDeviceMap, payload, -) { - const contentMap = {}; - - const promises = []; - for (let i = 0; i < userDeviceMap.length; i++) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - const val = userDeviceMap[i]; - const userId = val.userId; - const deviceInfo = val.deviceInfo; - const deviceId = deviceInfo.deviceId; - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = encryptedContent; - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - - return Promise.all(promises).then(() => { - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - return this._baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markSharedWithDevice( - userId, deviceId, chainIndex, - ); - } - } - }); - }); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {array} userDeviceMap list of blocked devices to notify - * - * @param {object} payload fields to include in the notification payload - * - * @return {Promise} Promise which resolves once the notifications - * for the given userDeviceMap is generated and has been sent. - */ -MegolmEncryption.prototype._sendBlockedNotificationsToDevices = async function( - session, userDeviceMap, payload, -) { - const contentMap = {}; - - for (const val of userDeviceMap) { - const userId = val.userId; - const blockedInfo = val.deviceInfo; - const deviceInfo = blockedInfo.deviceInfo; - const deviceId = deviceInfo.deviceId; - - const message = Object.assign({}, payload); - message.code = blockedInfo.code; - message.reason = blockedInfo.reason; - if (message.code === "m.no_olm") { - delete message.room_id; - delete message.session_id; - } - - if (!contentMap[userId]) { - contentMap[userId] = {}; - } - contentMap[userId][deviceId] = message; - } - - await this._baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); - - // store that we successfully uploaded the keys of the current slice - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - session.markNotifiedBlockedDevice(userId, deviceId); - } - } -}; - -/** - * Re-shares a megolm session key with devices if the key has already been - * sent to them. - * - * @param {string} senderKey The key of the originating device for the session - * @param {string} sessionId ID of the outbound session to share - * @param {string} userId ID of the user who owns the target device - * @param {module:crypto/deviceinfo} device The target device - */ -MegolmEncryption.prototype.reshareKeyWithDevice = async function( - senderKey, sessionId, userId, device, -) { - const obSessionInfo = this._outboundSessions[sessionId]; - if (!obSessionInfo) { - logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); - return; - } - - // The chain index of the key we previously sent this device - if (obSessionInfo.sharedWithDevices[userId] === undefined) { - logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); - return; - } - const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; - if (sentChainIndex === undefined) { - logger.debug( - "megolm session ID " + sessionId + " never shared with device " + - userId + ":" + device.deviceId, - ); - return; - } - - // get the key from the inbound session: the outbound one will already - // have been ratcheted to the next chain index. - const key = await this._olmDevice.getInboundGroupSessionKey( - this._roomId, senderKey, sessionId, sentChainIndex, - ); - - if (!key) { - logger.warn( - `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, - ); - return; - } - - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [device], - }, - ); - - const payload = { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": this._roomId, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - device, - payload, - ); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [userId]: { - [device.deviceId]: encryptedContent, - }, - }); - logger.debug(`Re-shared key for megolm session ${sessionId} ` + - `with ${userId}:${device.deviceId}`); -}; - -/** - * @private - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key the session key as returned by - * OlmDevice.getOutboundGroupSessionKey - * - * @param {object} payload the base to-device message payload for sharing keys - * - * @param {object} devicesByUser - * map from userid to list of devices - * - * @param {array} errorDevices - * array that will be populated with the devices that we can't get an - * olm session for - * - * @param {Number} [otkTimeout] The timeout in milliseconds when requesting - * one-time keys for establishing new olm sessions. - * - * @param {Array} [failedServers] An array to fill with remote servers that - * failed to respond to one-time-key requests. - */ -MegolmEncryption.prototype._shareKeyWithDevices = async function( - session, key, payload, devicesByUser, errorDevices, otkTimeout, failedServers, -) { - logger.debug(`Ensuring Olm sessions for devices in ${this._roomId}`); - const devicemap = await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, otkTimeout, failedServers, - logger.withPrefix(`[${this._roomId}]`), - ); - logger.debug(`Ensured Olm sessions for devices in ${this._roomId}`); - - this._getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); - - logger.debug(`Sharing keys with Olm sessions in ${this._roomId}`); - await this._shareKeyWithOlmSessions(session, key, payload, devicemap); - logger.debug(`Shared keys with Olm sessions in ${this._roomId}`); -}; - -MegolmEncryption.prototype._shareKeyWithOlmSessions = async function( - session, key, payload, devicemap, -) { - const userDeviceMaps = this._splitDevices(devicemap); - - for (let i = 0; i < userDeviceMaps.length; i++) { - const taskDetail = - `megolm keys for ${session.sessionId} ` + - `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`; - try { - logger.debug(`Sharing ${taskDetail}`); - await this._encryptAndSendKeysToDevices( - session, key.chain_index, userDeviceMaps[i], payload, - ); - logger.debug(`Shared ${taskDetail}`); - } catch (e) { - logger.error(`Failed to share ${taskDetail}`); - throw e; - } - } -}; - -/** - * Notify devices that we weren't able to create olm sessions. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} key - * - * @param {Array} failedDevices the devices that we were unable to - * create olm sessions for, as returned by _shareKeyWithDevices - */ -MegolmEncryption.prototype._notifyFailedOlmDevices = async function( - session, key, failedDevices, -) { - logger.debug( - `Notifying ${failedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); - - // mark the devices that failed as "handled" because we don't want to try - // to claim a one-time-key for dead devices on every message. - for (const { userId, deviceInfo } of failedDevices) { - const deviceId = deviceInfo.deviceId; - - session.markSharedWithDevice( - userId, deviceId, key.chain_index, - ); - } - - const filteredFailedDevices = - await this._olmDevice.filterOutNotifiedErrorDevices( - failedDevices, - ); - logger.debug( - `Filtered down to ${filteredFailedDevices.length} error devices ` + - `in ${this._roomId}`, - ); - const blockedMap = {}; - for (const { userId, deviceInfo } of filteredFailedDevices) { - blockedMap[userId] = blockedMap[userId] || {}; - // we use a similar format to what - // olmlib.ensureOlmSessionsForDevices returns, so that - // we can use the same function to split - blockedMap[userId][deviceInfo.deviceId] = { - device: { - code: "m.no_olm", - reason: WITHHELD_MESSAGES["m.no_olm"], - deviceInfo, - }, - }; - } - - // send the notifications - await this._notifyBlockedDevices(session, blockedMap); - logger.debug( - `Notified ${filteredFailedDevices.length} devices we failed to ` + - `create Olm sessions in ${this._roomId}`, - ); -}; - -/** - * Notify blocked devices that they have been blocked. - * - * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session - * - * @param {object} devicesByUser - * map from userid to device ID to blocked data - */ -MegolmEncryption.prototype._notifyBlockedDevices = async function( - session, devicesByUser, -) { - const payload = { - room_id: this._roomId, - session_id: session.sessionId, - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - }; - - const userDeviceMaps = this._splitDevices(devicesByUser); - - for (let i = 0; i < userDeviceMaps.length; i++) { - try { - await this._sendBlockedNotificationsToDevices( - session, userDeviceMaps[i], payload, - ); - logger.log(`Completed blacklist notification for ${session.sessionId} ` - + `in ${this._roomId} (slice ${i + 1}/${userDeviceMaps.length})`); - } catch (e) { - logger.log(`blacklist notification for ${session.sessionId} in ` - + `${this._roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); - - throw e; - } - } -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -MegolmEncryption.prototype.prepareToEncrypt = function(room) { - if (this.encryptionPreparation) { - // We're already preparing something, so don't do anything else. - // FIXME: check if we need to restart - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; - logger.debug( - `Already started preparing to encrypt for ${this._roomId} ` + - `${elapsedTime} ms ago, skipping`, - ); - return; - } - - logger.debug(`Preparing to encrypt events for ${this._roomId}`); - - this.encryptionPreparationMetadata = { - startTime: Date.now(), - }; - this.encryptionPreparation = (async () => { - try { - logger.debug(`Getting devices in ${this._roomId}`); - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - // Drop unknown devices for now. When the message gets sent, we'll - // throw an error, but we'll still be prepared to send to the known - // devices. - this._removeUnknownDevices(devicesInRoom); - } - - logger.debug(`Ensuring outbound session in ${this._roomId}`); - await this._ensureOutboundSession(room, devicesInRoom, blocked, true); - - logger.debug(`Ready to encrypt events for ${this._roomId}`); - } catch (e) { - logger.error(`Failed to prepare to encrypt events for ${this._roomId}`, e); - } finally { - delete this.encryptionPreparationMetadata; - delete this.encryptionPreparation; - } - })(); -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -MegolmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - logger.log(`Starting to encrypt event for ${this._roomId}`); - - if (this.encryptionPreparation) { - // If we started sending keys, wait for it to be done. - // FIXME: check if we need to cancel - // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) - try { - await this.encryptionPreparation; - } catch (e) { - // ignore any errors -- if the preparation failed, we'll just - // restart everything here - } - } - - const [devicesInRoom, blocked] = await this._getDevicesInRoom(room); - - // check if any of these devices are not yet known to the user. - // if so, warn the user so they can verify or ignore. - if (this._crypto.getGlobalErrorOnUnknownDevices()) { - this._checkForUnknownDevices(devicesInRoom); - } - - const session = await this._ensureOutboundSession(room, devicesInRoom, blocked); - const payloadJson = { - room_id: this._roomId, - type: eventType, - content: content, - }; - - const ciphertext = this._olmDevice.encryptGroupMessage( - session.sessionId, JSON.stringify(payloadJson), - ); - const encryptedContent = { - algorithm: olmlib.MEGOLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: ciphertext, - session_id: session.sessionId, - // Include our device ID so that recipients can send us a - // m.new_device message if they don't have our session key. - // XXX: Do we still need this now that m.new_device messages - // no longer exist since #483? - device_id: this._deviceId, - }; - - session.useCount++; - return encryptedContent; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * This should not normally be necessary. - */ -MegolmEncryption.prototype.forceDiscardSession = function() { - this._setupPromise = this._setupPromise.then(() => null); -}; - -/** - * Checks the devices we're about to send to and see if any are entirely - * unknown to the user. If so, warn the user, and mark them as known to - * give the user a chance to go verify them before re-sending this message. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._checkForUnknownDevices = function(devicesInRoom) { - const unknownDevices = {}; - - Object.keys(devicesInRoom).forEach((userId)=>{ - Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ - const device = devicesInRoom[userId][deviceId]; - if (device.isUnverified() && !device.isKnown()) { - if (!unknownDevices[userId]) { - unknownDevices[userId] = {}; - } - unknownDevices[userId][deviceId] = device; - } - }); - }); - - if (Object.keys(unknownDevices).length) { - // it'd be kind to pass unknownDevices up to the user in this error - throw new UnknownDeviceError( - "This room contains unknown devices which have not been verified. " + - "We strongly recommend you verify them before continuing.", unknownDevices); - } -}; - -/** - * Remove unknown devices from a set of devices. The devicesInRoom parameter - * will be modified. - * - * @param {Object} devicesInRoom userId -> {deviceId -> object} - * devices we should shared the session with. - */ -MegolmEncryption.prototype._removeUnknownDevices = function(devicesInRoom) { - for (const [userId, userDevices] of Object.entries(devicesInRoom)) { - for (const [deviceId, device] of Object.entries(userDevices)) { - if (device.isUnverified() && !device.isKnown()) { - delete userDevices[deviceId]; - } - } - - if (Object.keys(userDevices).length === 0) { - delete devicesInRoom[userId]; - } - } -}; - -/** - * Get the list of unblocked devices for all users in the room - * - * @param {module:models/room} room - * - * @return {Promise} Promise which resolves to an array whose - * first element is a map from userId to deviceId to deviceInfo indicating - * the devices that messages should be encrypted to, and whose second - * element is a map from userId to deviceId to data indicating the devices - * that are in the room but that have been blocked - */ -MegolmEncryption.prototype._getDevicesInRoom = async function(room) { - const members = await room.getEncryptionTargetMembers(); - const roomMembers = members.map(function(u) { - return u.userId; - }); - - // The global value is treated as a default for when rooms don't specify a value. - let isBlacklisting = this._crypto.getGlobalBlacklistUnverifiedDevices(); - if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { - isBlacklisting = room.getBlacklistUnverifiedDevices(); - } - - // We are happy to use a cached version here: we assume that if we already - // have a list of the user's devices, then we already share an e2e room - // with them, which means that they will have announced any new devices via - // device_lists in their /sync response. This cache should then be maintained - // using all the device_lists changes and left fields. - // See https://github.com/vector-im/element-web/issues/2305 for details. - const devices = await this._crypto.downloadKeys(roomMembers, false); - const blocked = {}; - // remove any blocked devices - for (const userId in devices) { - if (!devices.hasOwnProperty(userId)) { - continue; - } - - const userDevices = devices[userId]; - for (const deviceId in userDevices) { - if (!userDevices.hasOwnProperty(deviceId)) { - continue; - } - - const deviceTrust = this._crypto.checkDeviceTrust(userId, deviceId); - - if (userDevices[deviceId].isBlocked() || - (!deviceTrust.isVerified() && isBlacklisting) - ) { - if (!blocked[userId]) { - blocked[userId] = {}; - } - const blockedInfo = userDevices[deviceId].isBlocked() - ? { - code: "m.blacklisted", - reason: WITHHELD_MESSAGES["m.blacklisted"], - } - : { - code: "m.unverified", - reason: WITHHELD_MESSAGES["m.unverified"], - }; - blockedInfo.deviceInfo = userDevices[deviceId]; - blocked[userId][deviceId] = blockedInfo; - delete userDevices[deviceId]; - } - } - } - - return [devices, blocked]; -}; - -/** - * Megolm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function MegolmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); - - // events which we couldn't decrypt due to unknown sessions / indexes: map from - // senderKey|sessionId to Set of MatrixEvents - this._pendingEvents = {}; - - // this gets stubbed out by the unit tests. - this.olmlib = olmlib; -} -utils.inherits(MegolmDecryption, DecryptionAlgorithm); - -const PROBLEM_DESCRIPTIONS = { - no_olm: "The sender was unable to establish a secure channel.", - unknown: "The secure channel with the sender was corrupted.", -}; - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting, or rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -MegolmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - - if (!content.sender_key || !content.session_id || - !content.ciphertext - ) { - throw new DecryptionError( - "MEGOLM_MISSING_FIELDS", - "Missing fields in input", - ); - } - - // we add the event to the pending list *before* we start decryption. - // - // then, if the key turns up while decryption is in progress (and - // decryption fails), we will schedule a retry. - // (fixes https://github.com/vector-im/element-web/issues/5001) - this._addEventToPendingList(event); - - let res; - try { - res = await this._olmDevice.decryptGroupMessage( - event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, - event.getId(), event.getTs(), - ); - } catch (e) { - if (e.name === "DecryptionError") { - // re-throw decryption errors as-is - throw e; - } - - let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; - - if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { - this._requestKeysForEvent(event); - - errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; - } - - throw new DecryptionError( - errorCode, - e ? e.toString() : "Unknown Error: Error is undefined", { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - if (res === null) { - // We've got a message for a session we don't have. - // - // (XXX: We might actually have received this key since we started - // decrypting, in which case we'll have scheduled a retry, and this - // request will be redundant. We could probably check to see if the - // event is still in the pending list; if not, a retry will have been - // scheduled, so we needn't send out the request here.) - this._requestKeysForEvent(event); - - // See if there was a problem with the olm session at the time the - // event was sent. Use a fuzz factor of 2 minutes. - const problem = await this._olmDevice.sessionMayHaveProblems( - content.sender_key, event.getTs() - 120000, - ); - if (problem) { - let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] - || PROBLEM_DESCRIPTIONS.unknown; - if (problem.fixed) { - problemDescription += - " Trying to create a new secure channel and re-requesting the keys."; - } - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - problemDescription, - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - throw new DecryptionError( - "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", - "The sender's device has not sent us the keys for this message.", - { - session: content.sender_key + '|' + content.session_id, - }, - ); - } - - // success. We can remove the event from the pending list, if that hasn't - // already happened. - this._removeEventFromPendingList(event); - - const payload = JSON.parse(res.result); - - // belt-and-braces check that the room id matches that indicated by the HS - // (this is somewhat redundant, since the megolm session is scoped to the - // room, so neither the sender nor a MITM can lie about the room_id). - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "MEGOLM_BAD_ROOM", - "Message intended for room " + payload.room_id, - ); - } - - return { - clearEvent: payload, - senderCurve25519Key: res.senderKey, - claimedEd25519Key: res.keysClaimed.ed25519, - forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, - untrusted: res.untrusted, - }; -}; - -MegolmDecryption.prototype._requestKeysForEvent = function(event) { - const wireContent = event.getWireContent(); - - const recipients = event.getKeyRequestRecipients(this._userId); - - this._crypto.requestRoomKey({ - room_id: event.getRoomId(), - algorithm: wireContent.algorithm, - sender_key: wireContent.sender_key, - session_id: wireContent.session_id, - }, recipients); -}; - -/** - * Add an event to the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._addEventToPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - if (!this._pendingEvents[senderKey]) { - this._pendingEvents[senderKey] = new Map(); - } - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents.has(sessionId)) { - senderPendingEvents.set(sessionId, new Set()); - } - senderPendingEvents.get(sessionId).add(event); -}; - -/** - * Remove an event from the list of those awaiting their session keys. - * - * @private - * - * @param {module:models/event.MatrixEvent} event - */ -MegolmDecryption.prototype._removeEventFromPendingList = function(event) { - const content = event.getWireContent(); - const senderKey = content.sender_key; - const sessionId = content.session_id; - const senderPendingEvents = this._pendingEvents[senderKey]; - const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); - if (!pendingEvents) { - return; - } - - pendingEvents.delete(event); - if (pendingEvents.size === 0) { - senderPendingEvents.delete(senderKey); - } - if (senderPendingEvents.size === 0) { - delete this._pendingEvents[senderKey]; - } -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyEvent = function(event) { - const content = event.getContent(); - const sessionId = content.session_id; - let senderKey = event.getSenderKey(); - let forwardingKeyChain = []; - let exportFormat = false; - let keysClaimed; - - if (!content.room_id || - !sessionId || - !content.session_key - ) { - logger.error("key event is missing fields"); - return; - } - - if (!senderKey) { - logger.error("key event has no sender key (not encrypted?)"); - return; - } - - if (event.getType() == "m.forwarded_room_key") { - exportFormat = true; - forwardingKeyChain = content.forwarding_curve25519_key_chain; - if (!Array.isArray(forwardingKeyChain)) { - forwardingKeyChain = []; - } - - // copy content before we modify it - forwardingKeyChain = forwardingKeyChain.slice(); - forwardingKeyChain.push(senderKey); - - senderKey = content.sender_key; - if (!senderKey) { - logger.error("forwarded_room_key event is missing sender_key field"); - return; - } - - const ed25519Key = content.sender_claimed_ed25519_key; - if (!ed25519Key) { - logger.error( - `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, - ); - return; - } - - keysClaimed = { - ed25519: ed25519Key, - }; - } else { - keysClaimed = event.getKeysClaimed(); - } - - const extraSessionData = {}; - if (content["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - content.room_id, senderKey, forwardingKeyChain, sessionId, - content.session_key, keysClaimed, - exportFormat, extraSessionData, - ).then(() => { - // have another go at decrypting events sent with this session. - this._retryDecryption(senderKey, sessionId) - .then((success) => { - // cancel any outstanding room key requests for this session. - // Only do this if we managed to decrypt every message in the - // session, because if we didn't, we leave the other key - // requests in the hopes that someone sends us a key that - // includes an earlier index. - if (success) { - this._crypto.cancelRoomKeyRequest({ - algorithm: content.algorithm, - room_id: content.room_id, - session_id: content.session_id, - sender_key: senderKey, - }); - } - }); - }).then(() => { - // don't wait for the keys to be backed up for the server - this._crypto._backupManager.backupGroupSession(senderKey, content.session_id); - }).catch((e) => { - logger.error(`Error handling m.room_key_event: ${e}`); - }); -}; - -/** - * @inheritdoc - * - * @param {module:models/event.MatrixEvent} event key event - */ -MegolmDecryption.prototype.onRoomKeyWithheldEvent = async function(event) { - const content = event.getContent(); - const senderKey = content.sender_key; - - if (content.code === "m.no_olm") { - const sender = event.getSender(); - logger.warn( - `${sender}:${senderKey} was unable to establish an olm session with us`, - ); - // if the sender says that they haven't been able to establish an olm - // session, let's proactively establish one - - // Note: after we record that the olm session has had a problem, we - // trigger retrying decryption for all the messages from the sender's - // key, so that we can update the error message to indicate the olm - // session problem. - - if (await this._olmDevice.getSessionIdForDevice(senderKey)) { - // a session has already been established, so we don't need to - // create a new one. - logger.debug("New session already created. Not creating a new one."); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - return; - } - let device = this._crypto._deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this._crypto.downloadKeys([sender], false); - device = this._crypto._deviceList.getDeviceByIdentityKey( - content.algorithm, senderKey, - ); - if (!device) { - logger.info( - "Couldn't find device for identity key " + senderKey + - ": not establishing session", - ); - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", false); - this.retryDecryptionFromSender(senderKey); - return; - } - } - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { [sender]: [device] }, false, - ); - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(senderKey, "no_olm", true); - this.retryDecryptionFromSender(senderKey); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - } else { - await this._olmDevice.addInboundGroupSessionWithheld( - content.room_id, senderKey, content.session_id, content.code, - content.reason, - ); - } -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.hasKeysForKeyRequest = function(keyRequest) { - const body = keyRequest.requestBody; - - return this._olmDevice.hasInboundSessionKeys( - body.room_id, - body.sender_key, - body.session_id, - // TODO: ratchet index - ); -}; - -/** - * @inheritdoc - */ -MegolmDecryption.prototype.shareKeysWithDevice = function(keyRequest) { - const userId = keyRequest.userId; - const deviceId = keyRequest.deviceId; - const deviceInfo = this._crypto.getStoredDevice(userId, deviceId); - const body = keyRequest.requestBody; - - this.olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, { - [userId]: [deviceInfo], - }, - ).then((devicemap) => { - const olmSessionResult = devicemap[userId][deviceId]; - if (!olmSessionResult.sessionId) { - // no session with this device, probably because there - // were no one-time keys. - // - // ensureOlmSessionsForUsers has already done the logging, - // so just skip it. - return null; - } - - logger.log( - "sharing keys for session " + body.sender_key + "|" - + body.session_id + " with device " - + userId + ":" + deviceId, - ); - - return this._buildKeyForwardingMessage( - body.room_id, body.sender_key, body.session_id, - ); - }).then((payload) => { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - return this.olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ).then(() => { - const contentMap = { - [userId]: { - [deviceId]: encryptedContent, - }, - }; - - // TODO: retries - return this._baseApis.sendToDevice("m.room.encrypted", contentMap); - }); - }); -}; - -MegolmDecryption.prototype._buildKeyForwardingMessage = async function( - roomId, senderKey, sessionId, -) { - const key = await this._olmDevice.getInboundGroupSessionKey( - roomId, senderKey, sessionId, - ); - - return { - type: "m.forwarded_room_key", - content: { - "algorithm": olmlib.MEGOLM_ALGORITHM, - "room_id": roomId, - "sender_key": senderKey, - "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, - "session_id": sessionId, - "session_key": key.key, - "chain_index": key.chain_index, - "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, - "org.matrix.msc3061.shared_history": key.shared_history || false, - }, - }; -}; - -/** - * @inheritdoc - * - * @param {module:crypto/OlmDevice.MegolmSessionData} session - * @param {object} [opts={}] options for the import - * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted - * @param {string} [opts.source] where the key came from - */ -MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) { - const extraSessionData = {}; - if (opts.untrusted || session.untrusted) { - extraSessionData.untrusted = true; - } - if (session["org.matrix.msc3061.shared_history"]) { - extraSessionData.sharedHistory = true; - } - return this._olmDevice.addInboundGroupSession( - session.room_id, - session.sender_key, - session.forwarding_curve25519_key_chain, - session.session_id, - session.session_key, - session.sender_claimed_keys, - true, - extraSessionData, - ).then(() => { - if (opts.source !== "backup") { - // don't wait for it to complete - this._crypto._backupManager.backupGroupSession( - session.sender_key, session.session_id, - ).catch((e) => { - // This throws if the upload failed, but this is fine - // since it will have written it to the db and will retry. - logger.log("Failed to back up megolm session", e); - }); - } - // have another go at decrypting events sent with this session. - this._retryDecryption(session.sender_key, session.session_id); - }); -}; - -/** - * Have another go at decrypting events after we receive a key. Resolves once - * decryption has been re-attempted on all events. - * - * @private - * @param {String} senderKey - * @param {String} sessionId - * - * @return {Boolean} whether all messages were successfully decrypted - */ -MegolmDecryption.prototype._retryDecryption = async function(senderKey, sessionId) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - const pending = senderPendingEvents.get(sessionId); - if (!pending) { - return true; - } - - logger.debug("Retrying decryption on events", [...pending]); - - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto, { isRetry: true }); - } catch (e) { - // don't die if something goes wrong - } - })); - - // If decrypted successfully, they'll have been removed from _pendingEvents - return !((this._pendingEvents[senderKey] || {})[sessionId]); -}; - -MegolmDecryption.prototype.retryDecryptionFromSender = async function(senderKey) { - const senderPendingEvents = this._pendingEvents[senderKey]; - if (!senderPendingEvents) { - return true; - } - - delete this._pendingEvents[senderKey]; - - await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { - await Promise.all([...pending].map(async (ev) => { - try { - await ev.attemptDecryption(this._crypto); - } catch (e) { - // don't die if something goes wrong - } - })); - })); - - return !this._pendingEvents[senderKey]; -}; - -MegolmDecryption.prototype.sendSharedHistoryInboundSessions = async function(devicesByUser) { - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); - - logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); - - const sharedHistorySessions = - await this._olmDevice.getSharedHistoryInboundGroupSessions( - this._roomId, - ); - logger.log("shared-history sessions", sharedHistorySessions); - for (const [senderKey, sessionId] of sharedHistorySessions) { - const payload = await this._buildKeyForwardingMessage( - this._roomId, senderKey, sessionId, - ); - - const promises = []; - const contentMap = {}; - for (const [userId, devices] of Object.entries(devicesByUser)) { - contentMap[userId] = {}; - for (const deviceInfo of devices) { - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - contentMap[userId][deviceInfo.deviceId] = encryptedContent; - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - userId, - deviceInfo, - payload, - ), - ); - } - } - await Promise.all(promises); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - for (const userId of Object.keys(contentMap)) { - for (const deviceId of Object.keys(contentMap[userId])) { - if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { - logger.log( - "No ciphertext for device " + - userId + ":" + deviceId + ": pruning", - ); - delete contentMap[userId][deviceId]; - } - } - // No devices left for that user? Strip that too. - if (Object.keys(contentMap[userId]).length === 0) { - logger.log("Pruned all devices for user " + userId); - delete contentMap[userId]; - } - } - - // Is there anything left? - if (Object.keys(contentMap).length === 0) { - logger.log("No users left to send to: aborting"); - return; - } - - await this._baseApis.sendToDevice("m.room.encrypted", contentMap); - } -}; - -registerAlgorithm( - olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption, -); diff --git a/src/crypto/algorithms/megolm.ts b/src/crypto/algorithms/megolm.ts new file mode 100644 index 000000000..2a1a2d8db --- /dev/null +++ b/src/crypto/algorithms/megolm.ts @@ -0,0 +1,1833 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/megolm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, + UnknownDeviceError, +} from "./base"; +import { WITHHELD_MESSAGES } from '../OlmDevice'; +import { Room } from '../../models/room'; +import { DeviceInfo } from "../deviceinfo"; +import { IOlmSessionResult } from "../olmlib"; +import { DeviceInfoMap } from "../DeviceList"; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult, IMegolmSessionData, IncomingRoomKeyRequest } from "../index"; + +// determine whether the key can be shared with invitees +export function isRoomSharedHistory(room: Room): boolean { + const visibilityEvent = room?.currentState?.getStateEvents("m.room.history_visibility", ""); + // NOTE: if the room visibility is unset, it would normally default to + // "world_readable". + // (https://spec.matrix.org/unstable/client-server-api/#server-behaviour-5) + // But we will be paranoid here, and treat it as a situation where the room + // is not shared-history + const visibility = visibilityEvent?.getContent()?.history_visibility; + return ["world_readable", "shared"].includes(visibility); +} + +interface IBlockedDevice { + code: string; + reason: string; + deviceInfo: DeviceInfo; +} + +interface IBlockedMap { + [userId: string]: { + [deviceId: string]: IBlockedDevice; + }; +} + +interface IOlmDevice { + userId: string; + deviceInfo: T; +} + +/* eslint-disable camelcase */ +interface IOutboundGroupSessionKey { + chain_index: number; + key: string; +} + +interface IMessage { + type: string; + content: { + algorithm: string; + room_id: string; + sender_key?: string; + sender_claimed_ed25519_key?: string; + session_id: string; + session_key: string; + chain_index: number; + forwarding_curve25519_key_chain?: string[]; + "org.matrix.msc3061.shared_history": boolean; + }; +} + +interface IKeyForwardingMessage extends IMessage { + type: "m.forwarded_room_key"; +} + +interface IPayload extends Partial { + code?: string; + reason?: string; + room_id?: string; + session_id?: string; + algorithm?: string; + sender_key?: string; +} +/* eslint-enable camelcase */ + +/** + * @private + * @constructor + * + * @param {string} sessionId + * @param {boolean} sharedHistory whether the session can be freely shared with + * other group members, according to the room history visibility settings + * + * @property {string} sessionId + * @property {Number} useCount number of times this session has been used + * @property {Number} creationTime when the session was created (ms since the epoch) + * + * @property {object} sharedWithDevices + * devices with which we have shared the session key + * userId -> {deviceId -> msgindex} + */ +class OutboundSessionInfo { + public useCount = 0; + public creationTime: number; + public sharedWithDevices: Record> = {}; + public blockedDevicesNotified: Record> = {}; + + constructor(public readonly sessionId: string, public readonly sharedHistory = false) { + this.creationTime = new Date().getTime(); + } + + /** + * Check if it's time to rotate the session + * + * @param {Number} rotationPeriodMsgs + * @param {Number} rotationPeriodMs + * @return {Boolean} + */ + public needsRotation(rotationPeriodMsgs: number, rotationPeriodMs: number): boolean { + const sessionLifetime = new Date().getTime() - this.creationTime; + + if (this.useCount >= rotationPeriodMsgs || + sessionLifetime >= rotationPeriodMs + ) { + logger.log( + "Rotating megolm session after " + this.useCount + + " messages, " + sessionLifetime + "ms", + ); + return true; + } + + return false; + } + + public markSharedWithDevice(userId: string, deviceId: string, chainIndex: number): void { + if (!this.sharedWithDevices[userId]) { + this.sharedWithDevices[userId] = {}; + } + this.sharedWithDevices[userId][deviceId] = chainIndex; + } + + public markNotifiedBlockedDevice(userId: string, deviceId: string): void { + if (!this.blockedDevicesNotified[userId]) { + this.blockedDevicesNotified[userId] = {}; + } + this.blockedDevicesNotified[userId][deviceId] = true; + } + + /** + * Determine if this session has been shared with devices which it shouldn't + * have been. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + * + * @return {Boolean} true if we have shared the session with devices which aren't + * in devicesInRoom. + */ + public sharedWithTooManyDevices(devicesInRoom: Record>): boolean { + for (const userId in this.sharedWithDevices) { + if (!this.sharedWithDevices.hasOwnProperty(userId)) { + continue; + } + + if (!devicesInRoom.hasOwnProperty(userId)) { + logger.log("Starting new megolm session because we shared with " + userId); + return true; + } + + for (const deviceId in this.sharedWithDevices[userId]) { + if (!this.sharedWithDevices[userId].hasOwnProperty(deviceId)) { + continue; + } + + if (!devicesInRoom[userId].hasOwnProperty(deviceId)) { + logger.log( + "Starting new megolm session because we shared with " + + userId + ":" + deviceId, + ); + return true; + } + } + } + } +} + +/** + * Megolm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class MegolmEncryption extends EncryptionAlgorithm { + // the most recent attempt to set up a session. This is used to serialise + // the session setups, so that we have a race-free view of which session we + // are using, and which devices we have shared the keys with. It resolves + // with an OutboundSessionInfo (or undefined, for the first message in the + // room). + private setupPromise = Promise.resolve(undefined); + + // Map of outbound sessions by sessions ID. Used if we need a particular + // session (the session we're currently using to send is always obtained + // using setupPromise). + private outboundSessions: Record = {}; + + private readonly sessionRotationPeriodMsgs: number; + private readonly sessionRotationPeriodMs: number; + private encryptionPreparation: Promise; + private encryptionPreparationMetadata: { + startTime: number; + }; + + constructor(params) { + super(params); + + this.sessionRotationPeriodMsgs = params.config?.rotation_period_msgs ?? 100; + this.sessionRotationPeriodMs = params.config?.rotation_period_ms ?? 7 * 24 * 3600 * 1000; + } + + /** + * @private + * + * @param {module:models/room} room + * @param {Object} devicesInRoom The devices in this room, indexed by user ID + * @param {Object} blocked The devices that are blocked, indexed by user ID + * @param {boolean} [singleOlmCreationPhase] Only perform one round of olm + * session creation + * + * @return {Promise} Promise which resolves to the + * OutboundSessionInfo when setup is complete. + */ + private async ensureOutboundSession( + room: Room, + devicesInRoom: DeviceInfoMap, + blocked: IBlockedMap, + singleOlmCreationPhase = false, + ): Promise { + let session; + + // takes the previous OutboundSessionInfo, and considers whether to create + // a new one. Also shares the key with any (new) devices in the room. + // Updates `session` to hold the final OutboundSessionInfo. + // + // returns a promise which resolves once the keyshare is successful. + const prepareSession = async (oldSession: OutboundSessionInfo) => { + session = oldSession; + + const sharedHistory = isRoomSharedHistory(room); + + // history visibility changed + if (session && sharedHistory !== session.sharedHistory) { + session = null; + } + + // need to make a brand new session? + if (session && session.needsRotation(this.sessionRotationPeriodMsgs, + this.sessionRotationPeriodMs) + ) { + logger.log("Starting new megolm session because we need to rotate."); + session = null; + } + + // determine if we have shared with anyone we shouldn't have + if (session && session.sharedWithTooManyDevices(devicesInRoom)) { + session = null; + } + + if (!session) { + logger.log(`Starting new megolm session for room ${this.roomId}`); + session = await this.prepareNewSession(sharedHistory); + logger.log(`Started new megolm session ${session.sessionId} ` + + `for room ${this.roomId}`); + this.outboundSessions[session.sessionId] = session; + } + + // now check if we need to share with any devices + const shareMap = {}; + + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, deviceInfo] of Object.entries(userDevices)) { + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + + if ( + !session.sharedWithDevices[userId] || + session.sharedWithDevices[userId][deviceId] === undefined + ) { + shareMap[userId] = shareMap[userId] || []; + shareMap[userId].push(deviceInfo); + } + } + } + + const key = this.olmDevice.getOutboundGroupSessionKey(session.sessionId); + const payload: IPayload = { + type: "m.room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": session.sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "org.matrix.msc3061.shared_history": sharedHistory, + }, + }; + const [devicesWithoutSession, olmSessions] = await olmlib.getExistingOlmSessions( + this.olmDevice, this.baseApis, shareMap, + ); + + await Promise.all([ + (async () => { + // share keys with devices that we already have a session for + logger.debug(`Sharing keys with existing Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, olmSessions); + logger.debug(`Shared keys with existing Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Sharing keys (start phase 1) with new Olm sessions in ${this.roomId}`); + const errorDevices = []; + + // meanwhile, establish olm sessions for devices that we don't + // already have a session for, and share keys with them. If + // we're doing two phases of olm session creation, use a + // shorter timeout when fetching one-time keys for the first + // phase. + const start = Date.now(); + const failedServers = []; + await this.shareKeyWithDevices( + session, key, payload, devicesWithoutSession, errorDevices, + singleOlmCreationPhase ? 10000 : 2000, failedServers, + ); + logger.debug(`Shared keys (end phase 1) with new Olm sessions in ${this.roomId}`); + + if (!singleOlmCreationPhase && (Date.now() - start < 10000)) { + // perform the second phase of olm session creation if requested, + // and if the first phase didn't take too long + (async () => { + // Retry sending keys to devices that we were unable to establish + // an olm session for. This time, we use a longer timeout, but we + // do this in the background and don't block anything else while we + // do this. We only need to retry users from servers that didn't + // respond the first time. + const retryDevices = {}; + const failedServerMap = new Set; + for (const server of failedServers) { + failedServerMap.add(server); + } + const failedDevices = []; + for (const { userId, deviceInfo } of errorDevices) { + const userHS = userId.slice(userId.indexOf(":") + 1); + if (failedServerMap.has(userHS)) { + retryDevices[userId] = retryDevices[userId] || []; + retryDevices[userId].push(deviceInfo); + } else { + // if we aren't going to retry, then handle it + // as a failed device + failedDevices.push({ userId, deviceInfo }); + } + } + + logger.debug(`Sharing keys (start phase 2) with new Olm sessions in ${this.roomId}`); + await this.shareKeyWithDevices( + session, key, payload, retryDevices, failedDevices, 30000, + ); + logger.debug(`Shared keys (end phase 2) with new Olm sessions in ${this.roomId}`); + + await this.notifyFailedOlmDevices(session, key, failedDevices); + })(); + } else { + await this.notifyFailedOlmDevices(session, key, errorDevices); + } + logger.debug(`Shared keys (all phases done) with new Olm sessions in ${this.roomId}`); + })(), + (async () => { + logger.debug(`Notifying blocked devices in ${this.roomId}`); + // also, notify blocked devices that they're blocked + const blockedMap: Record> = {}; + let blockedCount = 0; + for (const [userId, userBlockedDevices] of Object.entries(blocked)) { + for (const [deviceId, device] of Object.entries(userBlockedDevices)) { + if ( + !session.blockedDevicesNotified[userId] || + session.blockedDevicesNotified[userId][deviceId] === undefined + ) { + blockedMap[userId] = blockedMap[userId] || {}; + blockedMap[userId][deviceId] = { device }; + blockedCount++; + } + } + } + + await this.notifyBlockedDevices(session, blockedMap); + logger.debug(`Notified ${blockedCount} blocked devices in ${this.roomId}`); + })(), + ]); + }; + + // helper which returns the session prepared by prepareSession + function returnSession() { + return session; + } + + // first wait for the previous share to complete + const prom = this.setupPromise.then(prepareSession); + + // Ensure any failures are logged for debugging + prom.catch(e => { + logger.error(`Failed to ensure outbound session in ${this.roomId}`, e); + }); + + // setupPromise resolves to `session` whether or not the share succeeds + this.setupPromise = prom.then(returnSession, returnSession); + + // but we return a promise which only resolves if the share was successful. + return prom.then(returnSession); + } + + /** + * @private + * + * @param {boolean} sharedHistory + * + * @return {module:crypto/algorithms/megolm.OutboundSessionInfo} session + */ + private async prepareNewSession(sharedHistory: boolean): Promise { + const sessionId = this.olmDevice.createOutboundGroupSession(); + const key = this.olmDevice.getOutboundGroupSessionKey(sessionId); + + await this.olmDevice.addInboundGroupSession( + this.roomId, this.olmDevice.deviceCurve25519Key, [], sessionId, + key.key, { ed25519: this.olmDevice.deviceEd25519Key }, false, + { sharedHistory }, + ); + + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession(this.olmDevice.deviceCurve25519Key, sessionId); + + return new OutboundSessionInfo(sessionId, sharedHistory); + } + + /** + * Determines what devices in devicesByUser don't have an olm session as given + * in devicemap. + * + * @private + * + * @param {object} devicemap the devices that have olm sessions, as returned by + * olmlib.ensureOlmSessionsForDevices. + * @param {object} devicesByUser a map of user IDs to array of deviceInfo + * @param {array} [noOlmDevices] an array to fill with devices that don't have + * olm sessions + * + * @return {array} an array of devices that don't have olm sessions. If + * noOlmDevices is specified, then noOlmDevices will be returned. + */ + private getDevicesWithoutSessions( + devicemap: Record>, + devicesByUser: Record, + noOlmDevices: IOlmDevice[] = [], + ): IOlmDevice[] { + for (const [userId, devicesToShareWith] of Object.entries(devicesByUser)) { + const sessionResults = devicemap[userId]; + + for (const deviceInfo of devicesToShareWith) { + const deviceId = deviceInfo.deviceId; + + const sessionResult = sessionResults[deviceId]; + if (!sessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + + noOlmDevices.push({ userId, deviceInfo }); + delete sessionResults[deviceId]; + + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + continue; + } + } + } + + return noOlmDevices; + } + + /** + * Splits the user device map into multiple chunks to reduce the number of + * devices we encrypt to per API call. + * + * @private + * + * @param {object} devicesByUser map from userid to list of devices + * + * @return {array>} the blocked devices, split into chunks + */ + private splitDevices( + devicesByUser: Record>, + ): IOlmDevice[][] { + const maxDevicesPerRequest = 20; + + // use an array where the slices of a content map gets stored + let currentSlice: IOlmDevice[] = []; + const mapSlices = [currentSlice]; + + for (const [userId, userDevices] of Object.entries(devicesByUser)) { + for (const deviceInfo of Object.values(userDevices)) { + currentSlice.push({ + userId: userId, + deviceInfo: deviceInfo.device, + }); + } + + // We do this in the per-user loop as we prefer that all messages to the + // same user end up in the same API call to make it easier for the + // server (e.g. only have to send one EDU if a remote user, etc). This + // does mean that if a user has many devices we may go over the desired + // limit, but its not a hard limit so that is fine. + if (currentSlice.length > maxDevicesPerRequest) { + // the current slice is filled up. Start inserting into the next slice + currentSlice = []; + mapSlices.push(currentSlice); + } + } + if (currentSlice.length === 0) { + mapSlices.pop(); + } + return mapSlices; + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {number} chainIndex current chain index + * + * @param {object} userDeviceMap + * mapping from userId to deviceInfo + * + * @param {object} payload fields to include in the encrypted payload + * + * @return {Promise} Promise which resolves once the key sharing + * for the given userDeviceMap is generated and has been sent. + */ + private encryptAndSendKeysToDevices( + session: OutboundSessionInfo, + chainIndex: number, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + const promises = []; + for (let i = 0; i < userDeviceMap.length; i++) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + const val = userDeviceMap[i]; + const userId = val.userId; + const deviceInfo = val.deviceInfo; + const deviceId = deviceInfo.deviceId; + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = encryptedContent; + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + + return Promise.all(promises).then(() => { + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + return this.baseApis.sendToDevice("m.room.encrypted", contentMap).then(() => { + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markSharedWithDevice( + userId, deviceId, chainIndex, + ); + } + } + }); + }); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {array} userDeviceMap list of blocked devices to notify + * + * @param {object} payload fields to include in the notification payload + * + * @return {Promise} Promise which resolves once the notifications + * for the given userDeviceMap is generated and has been sent. + */ + private async sendBlockedNotificationsToDevices( + session: OutboundSessionInfo, + userDeviceMap: IOlmDevice[], + payload: IPayload, + ): Promise { + const contentMap = {}; + + for (const val of userDeviceMap) { + const userId = val.userId; + const blockedInfo = val.deviceInfo; + const deviceInfo = blockedInfo.deviceInfo; + const deviceId = deviceInfo.deviceId; + + const message = Object.assign({}, payload); + message.code = blockedInfo.code; + message.reason = blockedInfo.reason; + if (message.code === "m.no_olm") { + delete message.room_id; + delete message.session_id; + } + + if (!contentMap[userId]) { + contentMap[userId] = {}; + } + contentMap[userId][deviceId] = message; + } + + await this.baseApis.sendToDevice("org.matrix.room_key.withheld", contentMap); + + // store that we successfully uploaded the keys of the current slice + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + session.markNotifiedBlockedDevice(userId, deviceId); + } + } + } + + /** + * Re-shares a megolm session key with devices if the key has already been + * sent to them. + * + * @param {string} senderKey The key of the originating device for the session + * @param {string} sessionId ID of the outbound session to share + * @param {string} userId ID of the user who owns the target device + * @param {module:crypto/deviceinfo} device The target device + */ + public async reshareKeyWithDevice( + senderKey: string, + sessionId: string, + userId: string, + device: DeviceInfo, + ): Promise { + const obSessionInfo = this.outboundSessions[sessionId]; + if (!obSessionInfo) { + logger.debug(`megolm session ${sessionId} not found: not re-sharing keys`); + return; + } + + // The chain index of the key we previously sent this device + if (obSessionInfo.sharedWithDevices[userId] === undefined) { + logger.debug(`megolm session ${sessionId} never shared with user ${userId}`); + return; + } + const sentChainIndex = obSessionInfo.sharedWithDevices[userId][device.deviceId]; + if (sentChainIndex === undefined) { + logger.debug( + "megolm session ID " + sessionId + " never shared with device " + + userId + ":" + device.deviceId, + ); + return; + } + + // get the key from the inbound session: the outbound one will already + // have been ratcheted to the next chain index. + const key = await this.olmDevice.getInboundGroupSessionKey( + this.roomId, senderKey, sessionId, sentChainIndex, + ); + + if (!key) { + logger.warn( + `No inbound session key found for megolm ${sessionId}: not re-sharing keys`, + ); + return; + } + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [device], + }, + ); + + const payload = { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": this.roomId, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + device, + payload, + ); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [userId]: { + [device.deviceId]: encryptedContent, + }, + }); + logger.debug(`Re-shared key for megolm session ${sessionId} with ${userId}:${device.deviceId}`); + } + + /** + * @private + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key the session key as returned by + * OlmDevice.getOutboundGroupSessionKey + * + * @param {object} payload the base to-device message payload for sharing keys + * + * @param {object} devicesByUser + * map from userid to list of devices + * + * @param {array} errorDevices + * array that will be populated with the devices that we can't get an + * olm session for + * + * @param {Number} [otkTimeout] The timeout in milliseconds when requesting + * one-time keys for establishing new olm sessions. + * + * @param {Array} [failedServers] An array to fill with remote servers that + * failed to respond to one-time-key requests. + */ + private async shareKeyWithDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicesByUser: Record, + errorDevices: IOlmDevice[], + otkTimeout: number, + failedServers?: string[], + ) { + logger.debug(`Ensuring Olm sessions for devices in ${this.roomId}`); + const devicemap = await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, devicesByUser, false, otkTimeout, failedServers, + logger.withPrefix(`[${this.roomId}]`), + ); + logger.debug(`Ensured Olm sessions for devices in ${this.roomId}`); + + this.getDevicesWithoutSessions(devicemap, devicesByUser, errorDevices); + + logger.debug(`Sharing keys with Olm sessions in ${this.roomId}`); + await this.shareKeyWithOlmSessions(session, key, payload, devicemap); + logger.debug(`Shared keys with Olm sessions in ${this.roomId}`); + } + + private async shareKeyWithOlmSessions( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + payload: IPayload, + devicemap: Record>, + ): Promise { + const userDeviceMaps = this.splitDevices(devicemap); + + for (let i = 0; i < userDeviceMaps.length; i++) { + const taskDetail = + `megolm keys for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`; + try { + logger.debug(`Sharing ${taskDetail}`); + await this.encryptAndSendKeysToDevices( + session, key.chain_index, userDeviceMaps[i], payload, + ); + logger.debug(`Shared ${taskDetail}`); + } catch (e) { + logger.error(`Failed to share ${taskDetail}`); + throw e; + } + } + } + + /** + * Notify devices that we weren't able to create olm sessions. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} key + * + * @param {Array} failedDevices the devices that we were unable to + * create olm sessions for, as returned by shareKeyWithDevices + */ + private async notifyFailedOlmDevices( + session: OutboundSessionInfo, + key: IOutboundGroupSessionKey, + failedDevices: IOlmDevice[], + ): Promise { + logger.debug( + `Notifying ${failedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + + // mark the devices that failed as "handled" because we don't want to try + // to claim a one-time-key for dead devices on every message. + for (const { userId, deviceInfo } of failedDevices) { + const deviceId = deviceInfo.deviceId; + + session.markSharedWithDevice( + userId, deviceId, key.chain_index, + ); + } + + const filteredFailedDevices = + await this.olmDevice.filterOutNotifiedErrorDevices( + failedDevices, + ); + logger.debug( + `Filtered down to ${filteredFailedDevices.length} error devices ` + + `in ${this.roomId}`, + ); + const blockedMap: Record> = {}; + for (const { userId, deviceInfo } of filteredFailedDevices) { + blockedMap[userId] = blockedMap[userId] || {}; + // we use a similar format to what + // olmlib.ensureOlmSessionsForDevices returns, so that + // we can use the same function to split + blockedMap[userId][deviceInfo.deviceId] = { + device: { + code: "m.no_olm", + reason: WITHHELD_MESSAGES["m.no_olm"], + deviceInfo, + }, + }; + } + + // send the notifications + await this.notifyBlockedDevices(session, blockedMap); + logger.debug( + `Notified ${filteredFailedDevices.length} devices we failed to ` + + `create Olm sessions in ${this.roomId}`, + ); + } + + /** + * Notify blocked devices that they have been blocked. + * + * @param {module:crypto/algorithms/megolm.OutboundSessionInfo} session + * + * @param {object} devicesByUser + * map from userid to device ID to blocked data + */ + private async notifyBlockedDevices( + session: OutboundSessionInfo, + devicesByUser: Record>, + ): Promise { + const payload: IPayload = { + room_id: this.roomId, + session_id: session.sessionId, + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + }; + + const userDeviceMaps = this.splitDevices(devicesByUser); + + for (let i = 0; i < userDeviceMaps.length; i++) { + try { + await this.sendBlockedNotificationsToDevices(session, userDeviceMaps[i], payload); + logger.log(`Completed blacklist notification for ${session.sessionId} ` + + `in ${this.roomId} (slice ${i + 1}/${userDeviceMaps.length})`); + } catch (e) { + logger.log(`blacklist notification for ${session.sessionId} in ` + + `${this.roomId} (slice ${i + 1}/${userDeviceMaps.length}) failed`); + + throw e; + } + } + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + if (this.encryptionPreparation) { + // We're already preparing something, so don't do anything else. + // FIXME: check if we need to restart + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + const elapsedTime = Date.now() - this.encryptionPreparationMetadata.startTime; + logger.debug( + `Already started preparing to encrypt for ${this.roomId} ` + + `${elapsedTime} ms ago, skipping`, + ); + return; + } + + logger.debug(`Preparing to encrypt events for ${this.roomId}`); + + this.encryptionPreparationMetadata = { + startTime: Date.now(), + }; + this.encryptionPreparation = (async () => { + try { + logger.debug(`Getting devices in ${this.roomId}`); + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + // Drop unknown devices for now. When the message gets sent, we'll + // throw an error, but we'll still be prepared to send to the known + // devices. + this.removeUnknownDevices(devicesInRoom); + } + + logger.debug(`Ensuring outbound session in ${this.roomId}`); + await this.ensureOutboundSession(room, devicesInRoom, blocked, true); + + logger.debug(`Ready to encrypt events for ${this.roomId}`); + } catch (e) { + logger.error(`Failed to prepare to encrypt events for ${this.roomId}`, e); + } finally { + delete this.encryptionPreparationMetadata; + delete this.encryptionPreparation; + } + })(); + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + logger.log(`Starting to encrypt event for ${this.roomId}`); + + if (this.encryptionPreparation) { + // If we started sending keys, wait for it to be done. + // FIXME: check if we need to cancel + // (https://github.com/matrix-org/matrix-js-sdk/issues/1255) + try { + await this.encryptionPreparation; + } catch (e) { + // ignore any errors -- if the preparation failed, we'll just + // restart everything here + } + } + + const [devicesInRoom, blocked] = await this.getDevicesInRoom(room); + + // check if any of these devices are not yet known to the user. + // if so, warn the user so they can verify or ignore. + if (this.crypto.getGlobalErrorOnUnknownDevices()) { + this.checkForUnknownDevices(devicesInRoom); + } + + const session = await this.ensureOutboundSession(room, devicesInRoom, blocked); + const payloadJson = { + room_id: this.roomId, + type: eventType, + content: content, + }; + + const ciphertext = this.olmDevice.encryptGroupMessage( + session.sessionId, JSON.stringify(payloadJson), + ); + const encryptedContent = { + algorithm: olmlib.MEGOLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: ciphertext, + session_id: session.sessionId, + // Include our device ID so that recipients can send us a + // m.new_device message if they don't have our session key. + // XXX: Do we still need this now that m.new_device messages + // no longer exist since #483? + device_id: this.deviceId, + }; + + session.useCount++; + return encryptedContent; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * This should not normally be necessary. + */ + public forceDiscardSession(): void { + this.setupPromise = this.setupPromise.then(() => null); + } + + /** + * Checks the devices we're about to send to and see if any are entirely + * unknown to the user. If so, warn the user, and mark them as known to + * give the user a chance to go verify them before re-sending this message. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private checkForUnknownDevices(devicesInRoom: DeviceInfoMap): void { + const unknownDevices = {}; + + Object.keys(devicesInRoom).forEach((userId)=>{ + Object.keys(devicesInRoom[userId]).forEach((deviceId)=>{ + const device = devicesInRoom[userId][deviceId]; + if (device.isUnverified() && !device.isKnown()) { + if (!unknownDevices[userId]) { + unknownDevices[userId] = {}; + } + unknownDevices[userId][deviceId] = device; + } + }); + }); + + if (Object.keys(unknownDevices).length) { + // it'd be kind to pass unknownDevices up to the user in this error + throw new UnknownDeviceError( + "This room contains unknown devices which have not been verified. " + + "We strongly recommend you verify them before continuing.", unknownDevices); + } + } + + /** + * Remove unknown devices from a set of devices. The devicesInRoom parameter + * will be modified. + * + * @param {Object} devicesInRoom userId -> {deviceId -> object} + * devices we should shared the session with. + */ + private removeUnknownDevices(devicesInRoom: DeviceInfoMap): void { + for (const [userId, userDevices] of Object.entries(devicesInRoom)) { + for (const [deviceId, device] of Object.entries(userDevices)) { + if (device.isUnverified() && !device.isKnown()) { + delete userDevices[deviceId]; + } + } + + if (Object.keys(userDevices).length === 0) { + delete devicesInRoom[userId]; + } + } + } + + /** + * Get the list of unblocked devices for all users in the room + * + * @param {module:models/room} room + * + * @return {Promise} Promise which resolves to an array whose + * first element is a map from userId to deviceId to deviceInfo indicating + * the devices that messages should be encrypted to, and whose second + * element is a map from userId to deviceId to data indicating the devices + * that are in the room but that have been blocked + */ + private async getDevicesInRoom(room: Room): Promise<[DeviceInfoMap, IBlockedMap]> { + const members = await room.getEncryptionTargetMembers(); + const roomMembers = members.map(function(u) { + return u.userId; + }); + + // The global value is treated as a default for when rooms don't specify a value. + let isBlacklisting = this.crypto.getGlobalBlacklistUnverifiedDevices(); + if (typeof room.getBlacklistUnverifiedDevices() === 'boolean') { + isBlacklisting = room.getBlacklistUnverifiedDevices(); + } + + // We are happy to use a cached version here: we assume that if we already + // have a list of the user's devices, then we already share an e2e room + // with them, which means that they will have announced any new devices via + // device_lists in their /sync response. This cache should then be maintained + // using all the device_lists changes and left fields. + // See https://github.com/vector-im/element-web/issues/2305 for details. + const devices = await this.crypto.downloadKeys(roomMembers, false); + const blocked: IBlockedMap = {}; + // remove any blocked devices + for (const userId in devices) { + if (!devices.hasOwnProperty(userId)) { + continue; + } + + const userDevices = devices[userId]; + for (const deviceId in userDevices) { + if (!userDevices.hasOwnProperty(deviceId)) { + continue; + } + + const deviceTrust = this.crypto.checkDeviceTrust(userId, deviceId); + + if (userDevices[deviceId].isBlocked() || + (!deviceTrust.isVerified() && isBlacklisting) + ) { + if (!blocked[userId]) { + blocked[userId] = {}; + } + const isBlocked = userDevices[deviceId].isBlocked(); + blocked[userId][deviceId] = { + code: isBlocked ? "m.blacklisted" : "m.unverified", + reason: WITHHELD_MESSAGES[isBlocked ? "m.blacklisted" : "m.unverified"], + deviceInfo: userDevices[deviceId], + }; + delete userDevices[deviceId]; + } + } + } + + return [devices, blocked]; + } +} + +/** + * Megolm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class MegolmDecryption extends DecryptionAlgorithm { + // events which we couldn't decrypt due to unknown sessions / indexes: map from + // senderKey|sessionId to Set of MatrixEvents + private pendingEvents: Record>> = {}; + + // this gets stubbed out by the unit tests. + private olmlib = olmlib; + + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting, or rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + + if (!content.sender_key || !content.session_id || + !content.ciphertext + ) { + throw new DecryptionError( + "MEGOLM_MISSING_FIELDS", + "Missing fields in input", + ); + } + + // we add the event to the pending list *before* we start decryption. + // + // then, if the key turns up while decryption is in progress (and + // decryption fails), we will schedule a retry. + // (fixes https://github.com/vector-im/element-web/issues/5001) + this.addEventToPendingList(event); + + let res; + try { + res = await this.olmDevice.decryptGroupMessage( + event.getRoomId(), content.sender_key, content.session_id, content.ciphertext, + event.getId(), event.getTs(), + ); + } catch (e) { + if (e.name === "DecryptionError") { + // re-throw decryption errors as-is + throw e; + } + + let errorCode = "OLM_DECRYPT_GROUP_MESSAGE_ERROR"; + + if (e && e.message === 'OLM.UNKNOWN_MESSAGE_INDEX') { + this.requestKeysForEvent(event); + + errorCode = 'OLM_UNKNOWN_MESSAGE_INDEX'; + } + + throw new DecryptionError( + errorCode, + e ? e.toString() : "Unknown Error: Error is undefined", { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + if (res === null) { + // We've got a message for a session we don't have. + // + // (XXX: We might actually have received this key since we started + // decrypting, in which case we'll have scheduled a retry, and this + // request will be redundant. We could probably check to see if the + // event is still in the pending list; if not, a retry will have been + // scheduled, so we needn't send out the request here.) + this.requestKeysForEvent(event); + + // See if there was a problem with the olm session at the time the + // event was sent. Use a fuzz factor of 2 minutes. + const problem = await this.olmDevice.sessionMayHaveProblems( + content.sender_key, event.getTs() - 120000, + ); + if (problem) { + let problemDescription = PROBLEM_DESCRIPTIONS[problem.type] + || PROBLEM_DESCRIPTIONS.unknown; + if (problem.fixed) { + problemDescription += + " Trying to create a new secure channel and re-requesting the keys."; + } + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + problemDescription, + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + throw new DecryptionError( + "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + "The sender's device has not sent us the keys for this message.", + { + session: content.sender_key + '|' + content.session_id, + }, + ); + } + + // success. We can remove the event from the pending list, if that hasn't + // already happened. + this.removeEventFromPendingList(event); + + const payload = JSON.parse(res.result); + + // belt-and-braces check that the room id matches that indicated by the HS + // (this is somewhat redundant, since the megolm session is scoped to the + // room, so neither the sender nor a MITM can lie about the room_id). + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "MEGOLM_BAD_ROOM", + "Message intended for room " + payload.room_id, + ); + } + + return { + clearEvent: payload, + senderCurve25519Key: res.senderKey, + claimedEd25519Key: res.keysClaimed.ed25519, + forwardingCurve25519KeyChain: res.forwardingCurve25519KeyChain, + untrusted: res.untrusted, + }; + } + + private requestKeysForEvent(event: MatrixEvent): void { + const wireContent = event.getWireContent(); + + const recipients = event.getKeyRequestRecipients(this.userId); + + this.crypto.requestRoomKey({ + room_id: event.getRoomId(), + algorithm: wireContent.algorithm, + sender_key: wireContent.sender_key, + session_id: wireContent.session_id, + }, recipients); + } + + /** + * Add an event to the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private addEventToPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + if (!this.pendingEvents[senderKey]) { + this.pendingEvents[senderKey] = new Map(); + } + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents.has(sessionId)) { + senderPendingEvents.set(sessionId, new Set()); + } + senderPendingEvents.get(sessionId).add(event); + } + + /** + * Remove an event from the list of those awaiting their session keys. + * + * @private + * + * @param {module:models/event.MatrixEvent} event + */ + private removeEventFromPendingList(event: MatrixEvent): void { + const content = event.getWireContent(); + const senderKey = content.sender_key; + const sessionId = content.session_id; + const senderPendingEvents = this.pendingEvents[senderKey]; + const pendingEvents = senderPendingEvents && senderPendingEvents.get(sessionId); + if (!pendingEvents) { + return; + } + + pendingEvents.delete(event); + if (pendingEvents.size === 0) { + senderPendingEvents.delete(senderKey); + } + if (senderPendingEvents.size === 0) { + delete this.pendingEvents[senderKey]; + } + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + const sessionId = content.session_id; + let senderKey = event.getSenderKey(); + let forwardingKeyChain = []; + let exportFormat = false; + let keysClaimed; + + if (!content.room_id || + !sessionId || + !content.session_key + ) { + logger.error("key event is missing fields"); + return; + } + + if (!senderKey) { + logger.error("key event has no sender key (not encrypted?)"); + return; + } + + if (event.getType() == "m.forwarded_room_key") { + exportFormat = true; + forwardingKeyChain = content.forwarding_curve25519_key_chain; + if (!Array.isArray(forwardingKeyChain)) { + forwardingKeyChain = []; + } + + // copy content before we modify it + forwardingKeyChain = forwardingKeyChain.slice(); + forwardingKeyChain.push(senderKey); + + senderKey = content.sender_key; + if (!senderKey) { + logger.error("forwarded_room_key event is missing sender_key field"); + return; + } + + const ed25519Key = content.sender_claimed_ed25519_key; + if (!ed25519Key) { + logger.error( + `forwarded_room_key_event is missing sender_claimed_ed25519_key field`, + ); + return; + } + + keysClaimed = { + ed25519: ed25519Key, + }; + } else { + keysClaimed = event.getKeysClaimed(); + } + + const extraSessionData: any = {}; + if (content["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + content.room_id, senderKey, forwardingKeyChain, sessionId, + content.session_key, keysClaimed, + exportFormat, extraSessionData, + ).then(() => { + // have another go at decrypting events sent with this session. + this.retryDecryption(senderKey, sessionId) + .then((success) => { + // cancel any outstanding room key requests for this session. + // Only do this if we managed to decrypt every message in the + // session, because if we didn't, we leave the other key + // requests in the hopes that someone sends us a key that + // includes an earlier index. + if (success) { + this.crypto.cancelRoomKeyRequest({ + algorithm: content.algorithm, + room_id: content.room_id, + session_id: content.session_id, + sender_key: senderKey, + }); + } + }); + }).then(() => { + // don't wait for the keys to be backed up for the server + this.crypto.backupManager.backupGroupSession(senderKey, content.session_id); + }).catch((e) => { + logger.error(`Error handling m.room_key_event: ${e}`); + }); + } + + /** + * @inheritdoc + * + * @param {module:models/event.MatrixEvent} event key event + */ + public async onRoomKeyWithheldEvent(event: MatrixEvent): Promise { + const content = event.getContent(); + const senderKey = content.sender_key; + + if (content.code === "m.no_olm") { + const sender = event.getSender(); + logger.warn( + `${sender}:${senderKey} was unable to establish an olm session with us`, + ); + // if the sender says that they haven't been able to establish an olm + // session, let's proactively establish one + + // Note: after we record that the olm session has had a problem, we + // trigger retrying decryption for all the messages from the sender's + // key, so that we can update the error message to indicate the olm + // session problem. + + if (await this.olmDevice.getSessionIdForDevice(senderKey)) { + // a session has already been established, so we don't need to + // create a new one. + logger.debug("New session already created. Not creating a new one."); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + return; + } + let device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.crypto.downloadKeys([sender], false); + device = this.crypto.deviceList.getDeviceByIdentityKey( + content.algorithm, senderKey, + ); + if (!device) { + logger.info( + "Couldn't find device for identity key " + senderKey + + ": not establishing session", + ); + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", false); + this.retryDecryptionFromSender(senderKey); + return; + } + } + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { [sender]: [device] }, false, + ); + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(senderKey, "no_olm", true); + this.retryDecryptionFromSender(senderKey); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + } else { + await this.olmDevice.addInboundGroupSessionWithheld( + content.room_id, senderKey, content.session_id, content.code, + content.reason, + ); + } + } + + /** + * @inheritdoc + */ + public hasKeysForKeyRequest(keyRequest: IncomingRoomKeyRequest): Promise { + const body = keyRequest.requestBody; + + return this.olmDevice.hasInboundSessionKeys( + body.room_id, + body.sender_key, + body.session_id, + // TODO: ratchet index + ); + } + + /** + * @inheritdoc + */ + public shareKeysWithDevice(keyRequest: IncomingRoomKeyRequest): void { + const userId = keyRequest.userId; + const deviceId = keyRequest.deviceId; + const deviceInfo = this.crypto.getStoredDevice(userId, deviceId); + const body = keyRequest.requestBody; + + this.olmlib.ensureOlmSessionsForDevices( + this.olmDevice, this.baseApis, { + [userId]: [deviceInfo], + }, + ).then((devicemap) => { + const olmSessionResult = devicemap[userId][deviceId]; + if (!olmSessionResult.sessionId) { + // no session with this device, probably because there + // were no one-time keys. + // + // ensureOlmSessionsForUsers has already done the logging, + // so just skip it. + return null; + } + + logger.log( + "sharing keys for session " + body.sender_key + "|" + + body.session_id + " with device " + + userId + ":" + deviceId, + ); + + return this.buildKeyForwardingMessage( + body.room_id, body.sender_key, body.session_id, + ); + }).then((payload) => { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + return this.olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload, + ).then(() => { + const contentMap = { + [userId]: { + [deviceId]: encryptedContent, + }, + }; + + // TODO: retries + return this.baseApis.sendToDevice("m.room.encrypted", contentMap); + }); + }); + } + + private async buildKeyForwardingMessage( + roomId: string, + senderKey: string, + sessionId: string, + ): Promise { + const key = await this.olmDevice.getInboundGroupSessionKey(roomId, senderKey, sessionId); + + return { + type: "m.forwarded_room_key", + content: { + "algorithm": olmlib.MEGOLM_ALGORITHM, + "room_id": roomId, + "sender_key": senderKey, + "sender_claimed_ed25519_key": key.sender_claimed_ed25519_key, + "session_id": sessionId, + "session_key": key.key, + "chain_index": key.chain_index, + "forwarding_curve25519_key_chain": key.forwarding_curve25519_key_chain, + "org.matrix.msc3061.shared_history": key.shared_history || false, + }, + }; + } + + /** + * @inheritdoc + * + * @param {module:crypto/OlmDevice.MegolmSessionData} session + * @param {object} [opts={}] options for the import + * @param {boolean} [opts.untrusted] whether the key should be considered as untrusted + * @param {string} [opts.source] where the key came from + */ + public importRoomKey(session: IMegolmSessionData, opts: any = {}): Promise { + const extraSessionData: any = {}; + if (opts.untrusted || session.untrusted) { + extraSessionData.untrusted = true; + } + if (session["org.matrix.msc3061.shared_history"]) { + extraSessionData.sharedHistory = true; + } + return this.olmDevice.addInboundGroupSession( + session.room_id, + session.sender_key, + session.forwarding_curve25519_key_chain, + session.session_id, + session.session_key, + session.sender_claimed_keys, + true, + extraSessionData, + ).then(() => { + if (opts.source !== "backup") { + // don't wait for it to complete + this.crypto.backupManager.backupGroupSession( + session.sender_key, session.session_id, + ).catch((e) => { + // This throws if the upload failed, but this is fine + // since it will have written it to the db and will retry. + logger.log("Failed to back up megolm session", e); + }); + } + // have another go at decrypting events sent with this session. + this.retryDecryption(session.sender_key, session.session_id); + }); + } + + /** + * Have another go at decrypting events after we receive a key. Resolves once + * decryption has been re-attempted on all events. + * + * @private + * @param {String} senderKey + * @param {String} sessionId + * + * @return {Boolean} whether all messages were successfully decrypted + */ + private async retryDecryption(senderKey: string, sessionId: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + const pending = senderPendingEvents.get(sessionId); + if (!pending) { + return true; + } + + logger.debug("Retrying decryption on events", [...pending]); + + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto, { isRetry: true }); + } catch (e) { + // don't die if something goes wrong + } + })); + + // If decrypted successfully, they'll have been removed from pendingEvents + return !((this.pendingEvents[senderKey] || {})[sessionId]); + } + + public async retryDecryptionFromSender(senderKey: string): Promise { + const senderPendingEvents = this.pendingEvents[senderKey]; + if (!senderPendingEvents) { + return true; + } + + delete this.pendingEvents[senderKey]; + + await Promise.all([...senderPendingEvents].map(async ([_sessionId, pending]) => { + await Promise.all([...pending].map(async (ev) => { + try { + await ev.attemptDecryption(this.crypto); + } catch (e) { + // don't die if something goes wrong + } + })); + })); + + return !this.pendingEvents[senderKey]; + } + + public async sendSharedHistoryInboundSessions(devicesByUser: Record): Promise { + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + + logger.log("sendSharedHistoryInboundSessions to users", Object.keys(devicesByUser)); + + const sharedHistorySessions = await this.olmDevice.getSharedHistoryInboundGroupSessions(this.roomId); + logger.log("shared-history sessions", sharedHistorySessions); + for (const [senderKey, sessionId] of sharedHistorySessions) { + const payload = await this.buildKeyForwardingMessage(this.roomId, senderKey, sessionId); + + const promises = []; + const contentMap = {}; + for (const [userId, devices] of Object.entries(devicesByUser)) { + contentMap[userId] = {}; + for (const deviceInfo of devices) { + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + contentMap[userId][deviceInfo.deviceId] = encryptedContent; + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + undefined, + this.olmDevice, + userId, + deviceInfo, + payload, + ), + ); + } + } + await Promise.all(promises); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + for (const userId of Object.keys(contentMap)) { + for (const deviceId of Object.keys(contentMap[userId])) { + if (Object.keys(contentMap[userId][deviceId].ciphertext).length === 0) { + logger.log( + "No ciphertext for device " + + userId + ":" + deviceId + ": pruning", + ); + delete contentMap[userId][deviceId]; + } + } + // No devices left for that user? Strip that too. + if (Object.keys(contentMap[userId]).length === 0) { + logger.log("Pruned all devices for user " + userId); + delete contentMap[userId]; + } + } + + // Is there anything left? + if (Object.keys(contentMap).length === 0) { + logger.log("No users left to send to: aborting"); + return; + } + + await this.baseApis.sendToDevice("m.room.encrypted", contentMap); + } + } +} + +const PROBLEM_DESCRIPTIONS = { + no_olm: "The sender was unable to establish a secure channel.", + unknown: "The secure channel with the sender was corrupted.", +}; + +registerAlgorithm(olmlib.MEGOLM_ALGORITHM, MegolmEncryption, MegolmDecryption); diff --git a/src/crypto/algorithms/olm.js b/src/crypto/algorithms/olm.js deleted file mode 100644 index 74444b75a..000000000 --- a/src/crypto/algorithms/olm.js +++ /dev/null @@ -1,361 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -/** - * Defines m.olm encryption/decryption - * - * @module crypto/algorithms/olm - */ - -import { logger } from '../../logger'; -import * as utils from "../../utils"; -import { polyfillSuper } from "../../utils"; -import * as olmlib from "../olmlib"; -import { DeviceInfo } from "../deviceinfo"; -import { - DecryptionAlgorithm, - DecryptionError, - EncryptionAlgorithm, - registerAlgorithm, -} from "./base"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -/** - * Olm encryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/EncryptionAlgorithm} - * - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/EncryptionAlgorithm} - */ -function OlmEncryption(params) { - polyfillSuper(this, EncryptionAlgorithm, params); - this._sessionPrepared = false; - this._prepPromise = null; -} -utils.inherits(OlmEncryption, EncryptionAlgorithm); - -/** - * @private - - * @param {string[]} roomMembers list of currently-joined users in the room - * @return {Promise} Promise which resolves when setup is complete - */ -OlmEncryption.prototype._ensureSession = function(roomMembers) { - if (this._prepPromise) { - // prep already in progress - return this._prepPromise; - } - - if (this._sessionPrepared) { - // prep already done - return Promise.resolve(); - } - - const self = this; - this._prepPromise = self._crypto.downloadKeys(roomMembers).then(function(res) { - return self._crypto.ensureOlmSessionsForUsers(roomMembers); - }).then(function() { - self._sessionPrepared = true; - }).finally(function() { - self._prepPromise = null; - }); - return this._prepPromise; -}; - -/** - * @inheritdoc - * - * @param {module:models/room} room - * @param {string} eventType - * @param {object} content plaintext event content - * - * @return {Promise} Promise which resolves to the new event body - */ -OlmEncryption.prototype.encryptMessage = async function(room, eventType, content) { - // pick the list of recipients based on the membership list. - // - // TODO: there is a race condition here! What if a new user turns up - // just as you are sending a secret message? - - const members = await room.getEncryptionTargetMembers(); - - const users = members.map(function(u) { - return u.userId; - }); - - const self = this; - await this._ensureSession(users); - - const payloadFields = { - room_id: room.roomId, - type: eventType, - content: content, - }; - - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: self._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - - const promises = []; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - const devices = self._crypto.getStoredDevicesForUser(userId); - - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - const key = deviceInfo.getIdentityKey(); - if (key == self._olmDevice.deviceCurve25519Key) { - // don't bother sending to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - promises.push( - olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - self._userId, self._deviceId, self._olmDevice, - userId, deviceInfo, payloadFields, - ), - ); - } - } - - return await Promise.all(promises).then(() => encryptedContent); -}; - -/** - * Olm decryption implementation - * - * @constructor - * @extends {module:crypto/algorithms/DecryptionAlgorithm} - * @param {object} params parameters, as per - * {@link module:crypto/algorithms/DecryptionAlgorithm} - */ -function OlmDecryption(params) { - polyfillSuper(this, DecryptionAlgorithm, params); -} -utils.inherits(OlmDecryption, DecryptionAlgorithm); - -/** - * @inheritdoc - * - * @param {MatrixEvent} event - * - * returns a promise which resolves to a - * {@link module:crypto~EventDecryptionResult} once we have finished - * decrypting. Rejects with an `algorithms.DecryptionError` if there is a - * problem decrypting the event. - */ -OlmDecryption.prototype.decryptEvent = async function(event) { - const content = event.getWireContent(); - const deviceKey = content.sender_key; - const ciphertext = content.ciphertext; - - if (!ciphertext) { - throw new DecryptionError( - "OLM_MISSING_CIPHERTEXT", - "Missing ciphertext", - ); - } - - if (!(this._olmDevice.deviceCurve25519Key in ciphertext)) { - throw new DecryptionError( - "OLM_NOT_INCLUDED_IN_RECIPIENTS", - "Not included in recipients", - ); - } - const message = ciphertext[this._olmDevice.deviceCurve25519Key]; - let payloadString; - - try { - payloadString = await this._decryptMessage(deviceKey, message); - } catch (e) { - throw new DecryptionError( - "OLM_BAD_ENCRYPTED_MESSAGE", - "Bad Encrypted Message", { - sender: deviceKey, - err: e, - }, - ); - } - - const payload = JSON.parse(payloadString); - - // check that we were the intended recipient, to avoid unknown-key attack - // https://github.com/vector-im/vector-web/issues/2483 - if (payload.recipient != this._userId) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT", - "Message was intented for " + payload.recipient, - ); - } - - if (payload.recipient_keys.ed25519 != this._olmDevice.deviceEd25519Key) { - throw new DecryptionError( - "OLM_BAD_RECIPIENT_KEY", - "Message not intended for this device", { - intended: payload.recipient_keys.ed25519, - our_key: this._olmDevice.deviceEd25519Key, - }, - ); - } - - // check that the original sender matches what the homeserver told us, to - // avoid people masquerading as others. - // (this check is also provided via the sender's embedded ed25519 key, - // which is checked elsewhere). - if (payload.sender != event.getSender()) { - throw new DecryptionError( - "OLM_FORWARDED_MESSAGE", - "Message forwarded from " + payload.sender, { - reported_sender: event.getSender(), - }, - ); - } - - // Olm events intended for a room have a room_id. - if (payload.room_id !== event.getRoomId()) { - throw new DecryptionError( - "OLM_BAD_ROOM", - "Message intended for room " + payload.room_id, { - reported_room: event.room_id, - }, - ); - } - - const claimedKeys = payload.keys || {}; - - return { - clearEvent: payload, - senderCurve25519Key: deviceKey, - claimedEd25519Key: claimedKeys.ed25519 || null, - }; -}; - -/** - * Attempt to decrypt an Olm message - * - * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender - * @param {object} message message object, with 'type' and 'body' fields - * - * @return {string} payload, if decrypted successfully. - */ -OlmDecryption.prototype._decryptMessage = async function( - theirDeviceIdentityKey, message, -) { - // This is a wrapper that serialises decryptions of prekey messages, because - // otherwise we race between deciding we have no active sessions for the message - // and creating a new one, which we can only do once because it removes the OTK. - if (message.type !== 0) { - // not a prekey message: we can safely just try & decrypt it - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - } else { - const myPromise = this._olmDevice._olmPrekeyPromise.then(() => { - return this._reallyDecryptMessage(theirDeviceIdentityKey, message); - }); - // we want the error, but don't propagate it to the next decryption - this._olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); - return await myPromise; - } -}; - -OlmDecryption.prototype._reallyDecryptMessage = async function( - theirDeviceIdentityKey, message, -) { - const sessionIds = await this._olmDevice.getSessionIdsForDevice( - theirDeviceIdentityKey, - ); - - // try each session in turn. - const decryptionErrors = {}; - for (let i = 0; i < sessionIds.length; i++) { - const sessionId = sessionIds[i]; - try { - const payload = await this._olmDevice.decryptMessage( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - logger.log( - "Decrypted Olm message from " + theirDeviceIdentityKey + - " with session " + sessionId, - ); - return payload; - } catch (e) { - const foundSession = await this._olmDevice.matchesSession( - theirDeviceIdentityKey, sessionId, message.type, message.body, - ); - - if (foundSession) { - // decryption failed, but it was a prekey message matching this - // session, so it should have worked. - throw new Error( - "Error decrypting prekey message with existing session id " + - sessionId + ": " + e.message, - ); - } - - // otherwise it's probably a message for another session; carry on, but - // keep a record of the error - decryptionErrors[sessionId] = e.message; - } - } - - if (message.type !== 0) { - // not a prekey message, so it should have matched an existing session, but it - // didn't work. - - if (sessionIds.length === 0) { - throw new Error("No existing sessions"); - } - - throw new Error( - "Error decrypting non-prekey message with existing sessions: " + - JSON.stringify(decryptionErrors), - ); - } - - // prekey message which doesn't match any existing sessions: make a new - // session. - - let res; - try { - res = await this._olmDevice.createInboundSession( - theirDeviceIdentityKey, message.type, message.body, - ); - } catch (e) { - decryptionErrors["(new)"] = e.message; - throw new Error( - "Error decrypting prekey message: " + - JSON.stringify(decryptionErrors), - ); - } - - logger.log( - "created new inbound Olm session ID " + - res.session_id + " with " + theirDeviceIdentityKey, - ); - return res.payload; -}; - -registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/algorithms/olm.ts b/src/crypto/algorithms/olm.ts new file mode 100644 index 000000000..d45365ba9 --- /dev/null +++ b/src/crypto/algorithms/olm.ts @@ -0,0 +1,355 @@ +/* +Copyright 2016 - 2021 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. +*/ + +/** + * Defines m.olm encryption/decryption + * + * @module crypto/algorithms/olm + */ + +import { logger } from '../../logger'; +import * as olmlib from "../olmlib"; +import { DeviceInfo } from "../deviceinfo"; +import { + DecryptionAlgorithm, + DecryptionError, + EncryptionAlgorithm, + registerAlgorithm, +} from "./base"; +import { Room } from '../../models/room'; +import { MatrixEvent } from "../.."; +import { IEventDecryptionResult } from "../index"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +interface IMessage { + type: number | string; + body: string; +} + +/** + * Olm encryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/EncryptionAlgorithm} + * + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/EncryptionAlgorithm} + */ +class OlmEncryption extends EncryptionAlgorithm { + private sessionPrepared = false; + private prepPromise: Promise = null; + + /** + * @private + + * @param {string[]} roomMembers list of currently-joined users in the room + * @return {Promise} Promise which resolves when setup is complete + */ + private ensureSession(roomMembers: string[]): Promise { + if (this.prepPromise) { + // prep already in progress + return this.prepPromise; + } + + if (this.sessionPrepared) { + // prep already done + return Promise.resolve(); + } + + this.prepPromise = this.crypto.downloadKeys(roomMembers).then((res) => { + return this.crypto.ensureOlmSessionsForUsers(roomMembers); + }).then(() => { + this.sessionPrepared = true; + }).finally(() => { + this.prepPromise = null; + }); + + return this.prepPromise; + } + + /** + * @inheritdoc + * + * @param {module:models/room} room + * @param {string} eventType + * @param {object} content plaintext event content + * + * @return {Promise} Promise which resolves to the new event body + */ + public async encryptMessage(room: Room, eventType: string, content: object): Promise { + // pick the list of recipients based on the membership list. + // + // TODO: there is a race condition here! What if a new user turns up + // just as you are sending a secret message? + + const members = await room.getEncryptionTargetMembers(); + + const users = members.map(function(u) { + return u.userId; + }); + + await this.ensureSession(users); + + const payloadFields = { + room_id: room.roomId, + type: eventType, + content: content, + }; + + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + + const promises = []; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + const devices = this.crypto.getStoredDevicesForUser(userId); + + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother sending to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + promises.push( + olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, this.deviceId, this.olmDevice, + userId, deviceInfo, payloadFields, + ), + ); + } + } + + return await Promise.all(promises).then(() => encryptedContent); + } +} + +/** + * Olm decryption implementation + * + * @constructor + * @extends {module:crypto/algorithms/DecryptionAlgorithm} + * @param {object} params parameters, as per + * {@link module:crypto/algorithms/DecryptionAlgorithm} + */ +class OlmDecryption extends DecryptionAlgorithm { + /** + * @inheritdoc + * + * @param {MatrixEvent} event + * + * returns a promise which resolves to a + * {@link module:crypto~EventDecryptionResult} once we have finished + * decrypting. Rejects with an `algorithms.DecryptionError` if there is a + * problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const deviceKey = content.sender_key; + const ciphertext = content.ciphertext; + + if (!ciphertext) { + throw new DecryptionError( + "OLM_MISSING_CIPHERTEXT", + "Missing ciphertext", + ); + } + + if (!(this.olmDevice.deviceCurve25519Key in ciphertext)) { + throw new DecryptionError( + "OLM_NOT_INCLUDED_IN_RECIPIENTS", + "Not included in recipients", + ); + } + const message = ciphertext[this.olmDevice.deviceCurve25519Key]; + let payloadString; + + try { + payloadString = await this.decryptMessage(deviceKey, message); + } catch (e) { + throw new DecryptionError( + "OLM_BAD_ENCRYPTED_MESSAGE", + "Bad Encrypted Message", { + sender: deviceKey, + err: e, + }, + ); + } + + const payload = JSON.parse(payloadString); + + // check that we were the intended recipient, to avoid unknown-key attack + // https://github.com/vector-im/vector-web/issues/2483 + if (payload.recipient != this.userId) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT", + "Message was intented for " + payload.recipient, + ); + } + + if (payload.recipient_keys.ed25519 != this.olmDevice.deviceEd25519Key) { + throw new DecryptionError( + "OLM_BAD_RECIPIENT_KEY", + "Message not intended for this device", { + intended: payload.recipient_keys.ed25519, + our_key: this.olmDevice.deviceEd25519Key, + }, + ); + } + + // check that the original sender matches what the homeserver told us, to + // avoid people masquerading as others. + // (this check is also provided via the sender's embedded ed25519 key, + // which is checked elsewhere). + if (payload.sender != event.getSender()) { + throw new DecryptionError( + "OLM_FORWARDED_MESSAGE", + "Message forwarded from " + payload.sender, { + reported_sender: event.getSender(), + }, + ); + } + + // Olm events intended for a room have a room_id. + if (payload.room_id !== event.getRoomId()) { + throw new DecryptionError( + "OLM_BAD_ROOM", + "Message intended for room " + payload.room_id, { + reported_room: event.getRoomId(), + }, + ); + } + + const claimedKeys = payload.keys || {}; + + return { + clearEvent: payload, + senderCurve25519Key: deviceKey, + claimedEd25519Key: claimedKeys.ed25519 || null, + }; + } + + /** + * Attempt to decrypt an Olm message + * + * @param {string} theirDeviceIdentityKey Curve25519 identity key of the sender + * @param {object} message message object, with 'type' and 'body' fields + * + * @return {string} payload, if decrypted successfully. + */ + private async decryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + // This is a wrapper that serialises decryptions of prekey messages, because + // otherwise we race between deciding we have no active sessions for the message + // and creating a new one, which we can only do once because it removes the OTK. + if (message.type !== 0) { + // not a prekey message: we can safely just try & decrypt it + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + } else { + const myPromise = this.olmDevice._olmPrekeyPromise.then(() => { + return this.reallyDecryptMessage(theirDeviceIdentityKey, message); + }); + // we want the error, but don't propagate it to the next decryption + this.olmDevice._olmPrekeyPromise = myPromise.catch(() => {}); + return await myPromise; + } + } + + private async reallyDecryptMessage(theirDeviceIdentityKey: string, message: IMessage): Promise { + const sessionIds = await this.olmDevice.getSessionIdsForDevice(theirDeviceIdentityKey); + + // try each session in turn. + const decryptionErrors = {}; + for (let i = 0; i < sessionIds.length; i++) { + const sessionId = sessionIds[i]; + try { + const payload = await this.olmDevice.decryptMessage( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + logger.log( + "Decrypted Olm message from " + theirDeviceIdentityKey + + " with session " + sessionId, + ); + return payload; + } catch (e) { + const foundSession = await this.olmDevice.matchesSession( + theirDeviceIdentityKey, sessionId, message.type, message.body, + ); + + if (foundSession) { + // decryption failed, but it was a prekey message matching this + // session, so it should have worked. + throw new Error( + "Error decrypting prekey message with existing session id " + + sessionId + ": " + e.message, + ); + } + + // otherwise it's probably a message for another session; carry on, but + // keep a record of the error + decryptionErrors[sessionId] = e.message; + } + } + + if (message.type !== 0) { + // not a prekey message, so it should have matched an existing session, but it + // didn't work. + + if (sessionIds.length === 0) { + throw new Error("No existing sessions"); + } + + throw new Error( + "Error decrypting non-prekey message with existing sessions: " + + JSON.stringify(decryptionErrors), + ); + } + + // prekey message which doesn't match any existing sessions: make a new + // session. + + let res; + try { + res = await this.olmDevice.createInboundSession( + theirDeviceIdentityKey, message.type, message.body, + ); + } catch (e) { + decryptionErrors["(new)"] = e.message; + throw new Error( + "Error decrypting prekey message: " + + JSON.stringify(decryptionErrors), + ); + } + + logger.log( + "created new inbound Olm session ID " + + res.session_id + " with " + theirDeviceIdentityKey, + ); + return res.payload; + } +} + +registerAlgorithm(olmlib.OLM_ALGORITHM, OlmEncryption, OlmDecryption); diff --git a/src/crypto/api.ts b/src/crypto/api.ts index 24528486e..8daafb6d7 100644 --- a/src/crypto/api.ts +++ b/src/crypto/api.ts @@ -15,7 +15,7 @@ limitations under the License. */ import { DeviceInfo } from "./deviceinfo"; -import { IKeyBackupVersion } from "./keybackup"; +import { IKeyBackupInfo } from "./keybackup"; import { ISecretStorageKeyInfo } from "../matrix"; // TODO: Merge this with crypto.js once converted @@ -60,7 +60,7 @@ export interface IEncryptedEventInfo { export interface IRecoveryKey { keyInfo: { - pubkey: Uint8Array; + pubkey: string; passphrase?: { algorithm: string; iterations: number; @@ -85,7 +85,7 @@ export interface ICreateSecretStorageOpts { * The current key backup object. If passed, * the passphrase and recovery key from this backup will be used. */ - keyBackupInfo?: IKeyBackupVersion; + keyBackupInfo?: IKeyBackupInfo; /** * If true, a new key backup version will be diff --git a/src/crypto/backup.ts b/src/crypto/backup.ts index 5f2b96da2..505409af2 100644 --- a/src/crypto/backup.ts +++ b/src/crypto/backup.ts @@ -31,33 +31,42 @@ import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; import { encodeRecoveryKey } from './recoverykey'; import { encryptAES, decryptAES, calculateKeyCheck } from './aes'; import { getCrypto } from '../utils'; +import { IKeyBackupInfo } from "./keybackup"; const KEY_BACKUP_KEYS_PER_REQUEST = 200; -type AuthData = Record; - -type BackupInfo = { - algorithm: string, - auth_data: AuthData, // eslint-disable-line camelcase - [properties: string]: any, -}; +type AuthData = IKeyBackupInfo["auth_data"]; type SigInfo = { - deviceId: string, - valid?: boolean | null, // true: valid, false: invalid, null: cannot attempt validation - device?: DeviceInfo | null, - crossSigningId?: boolean, - deviceTrust?: DeviceTrustLevel, + deviceId: string; + valid?: boolean | null; // true: valid, false: invalid, null: cannot attempt validation + device?: DeviceInfo | null; + crossSigningId?: boolean; + deviceTrust?: DeviceTrustLevel; }; -type TrustInfo = { - usable: boolean, // is the backup trusted, true iff there is a sig that is valid & from a trusted device - sigs: SigInfo[], +export type TrustInfo = { + usable: boolean; // is the backup trusted, true iff there is a sig that is valid & from a trusted device + sigs: SigInfo[]; }; +export interface IKeyBackupCheck { + backupInfo: IKeyBackupInfo; + trustInfo: TrustInfo; +} + +/* eslint-disable camelcase */ +export interface IPreparedKeyBackupVersion { + algorithm: string; + auth_data: AuthData; + recovery_key: string; + privateKey: Uint8Array; +} +/* eslint-enable camelcase */ + /** A function used to get the secret key for a backup. */ -type GetKey = () => Promise; +type GetKey = () => Promise>; interface BackupAlgorithmClass { algorithmName: string; @@ -77,7 +86,7 @@ interface BackupAlgorithm { encryptSession(data: Record): Promise; decryptSessions(ciphertexts: Record): Promise[]>; authData: AuthData; - keyMatches(key: Uint8Array): Promise; + keyMatches(key: ArrayLike): Promise; free(): void; } @@ -86,7 +95,7 @@ interface BackupAlgorithm { */ export class BackupManager { private algorithm: BackupAlgorithm | undefined; - private backupInfo: BackupInfo | undefined; // The info dict from /room_keys/version + public backupInfo: IKeyBackupInfo | undefined; // The info dict from /room_keys/version public checkedForBackup: boolean; // Have we checked the server for a backup we can use? private sendingBackups: boolean; // Are we currently sending backups? constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) { @@ -116,7 +125,7 @@ export class BackupManager { return Algorithm.checkBackupVersion(info); } - public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise { + public static async makeAlgorithm(info: IKeyBackupInfo, getKey: GetKey): Promise { const Algorithm = algorithmsByName[info.algorithm]; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -124,7 +133,7 @@ export class BackupManager { return await Algorithm.init(info.auth_data, getKey); } - public async enableKeyBackup(info: BackupInfo): Promise { + public async enableKeyBackup(info: IKeyBackupInfo): Promise { this.backupInfo = info; if (this.algorithm) { this.algorithm.free(); @@ -163,7 +172,8 @@ export class BackupManager { public async prepareKeyBackupVersion( key?: string | Uint8Array | null, algorithm?: string | undefined, - ): Promise { + // eslint-disable-next-line camelcase + ): Promise { const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm; if (!Algorithm) { throw new Error("Unknown backup algorithm"); @@ -179,7 +189,7 @@ export class BackupManager { }; } - public async createKeyBackupVersion(info: BackupInfo): Promise { + public async createKeyBackupVersion(info: IKeyBackupInfo): Promise { this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey); } @@ -189,14 +199,14 @@ export class BackupManager { * one of the user's verified devices, start backing up * to it. */ - public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + public async checkAndStart(): Promise { logger.log("Checking key backup status..."); if (this.baseApis.isGuest()) { logger.log("Skipping key backup check since user is guest"); this.checkedForBackup = true; return null; } - let backupInfo: BackupInfo; + let backupInfo: IKeyBackupInfo; try { backupInfo = await this.baseApis.getKeyBackupVersion(); } catch (e) { @@ -255,7 +265,7 @@ export class BackupManager { * trust information (as returned by isKeyBackupTrusted) * in trustInfo. */ - public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> { + public async checkKeyBackup(): Promise { this.checkedForBackup = false; return this.checkAndStart(); } @@ -273,7 +283,7 @@ export class BackupManager { * ] * } */ - public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise { + public async isKeyBackupTrusted(backupInfo: IKeyBackupInfo): Promise { const ret = { usable: false, trusted_locally: false, @@ -290,7 +300,7 @@ export class BackupManager { return ret; } - const trustedPubkey = this.baseApis.crypto._sessionStore.getLocalTrustedBackupPubKey(); + const trustedPubkey = this.baseApis.crypto.sessionStore.getLocalTrustedBackupPubKey(); if (backupInfo.auth_data.public_key === trustedPubkey) { logger.info("Backup public key " + trustedPubkey + " is trusted locally"); @@ -310,12 +320,12 @@ export class BackupManager { const sigInfo: SigInfo = { deviceId: keyIdParts[1] }; // first check to see if it's from our cross-signing key - const crossSigningId = this.baseApis.crypto._crossSigningInfo.getId(); + const crossSigningId = this.baseApis.crypto.crossSigningInfo.getId(); if (crossSigningId === sigInfo.deviceId) { sigInfo.crossSigningId = true; try { await verifySignature( - this.baseApis.crypto._olmDevice, + this.baseApis.crypto.olmDevice, backupInfo.auth_data, this.baseApis.getUserId(), sigInfo.deviceId, @@ -335,7 +345,7 @@ export class BackupManager { // Now look for a sig from a device // At some point this can probably go away and we'll just support // it being signed by the cross-signing master key - const device = this.baseApis.crypto._deviceList.getStoredDevice( + const device = this.baseApis.crypto.deviceList.getStoredDevice( this.baseApis.getUserId(), sigInfo.deviceId, ); if (device) { @@ -345,7 +355,7 @@ export class BackupManager { ); try { await verifySignature( - this.baseApis.crypto._olmDevice, + this.baseApis.crypto.olmDevice, backupInfo.auth_data, this.baseApis.getUserId(), device.deviceId, @@ -445,12 +455,12 @@ export class BackupManager { * @returns {integer} Number of sessions backed up */ private async backupPendingKeys(limit: number): Promise { - const sessions = await this.baseApis.crypto._cryptoStore.getSessionsNeedingBackup(limit); + const sessions = await this.baseApis.crypto.cryptoStore.getSessionsNeedingBackup(limit); if (!sessions.length) { return 0; } - let remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + let remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); const data = {}; @@ -460,7 +470,7 @@ export class BackupManager { data[roomId] = { sessions: {} }; } - const sessionData = await this.baseApis.crypto._olmDevice.exportInboundGroupSession( + const sessionData = await this.baseApis.crypto.olmDevice.exportInboundGroupSession( session.senderKey, session.sessionId, session.sessionData, ); sessionData.algorithm = MEGOLM_ALGORITHM; @@ -468,13 +478,13 @@ export class BackupManager { const forwardedCount = (sessionData.forwarding_curve25519_key_chain || []).length; - const userId = this.baseApis.crypto._deviceList.getUserByIdentityKey( + const userId = this.baseApis.crypto.deviceList.getUserByIdentityKey( MEGOLM_ALGORITHM, session.senderKey, ); - const device = this.baseApis.crypto._deviceList.getDeviceByIdentityKey( + const device = this.baseApis.crypto.deviceList.getDeviceByIdentityKey( MEGOLM_ALGORITHM, session.senderKey, ); - const verified = this.baseApis.crypto._checkDeviceInfoTrust(userId, device).isVerified(); + const verified = this.baseApis.crypto.checkDeviceInfoTrust(userId, device).isVerified(); data[roomId]['sessions'][session.sessionId] = { first_message_index: sessionData.first_known_index, @@ -489,8 +499,8 @@ export class BackupManager { { rooms: data }, ); - await this.baseApis.crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions); - remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + await this.baseApis.crypto.cryptoStore.unmarkSessionsNeedingBackup(sessions); + remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining); return sessions.length; @@ -499,7 +509,7 @@ export class BackupManager { public async backupGroupSession( senderKey: string, sessionId: string, ): Promise { - await this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([{ + await this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([{ senderKey: senderKey, sessionId: sessionId, }]); @@ -531,22 +541,22 @@ export class BackupManager { * (which will be equal to the number of sessions in the store). */ public async flagAllGroupSessionsForBackup(): Promise { - await this.baseApis.crypto._cryptoStore.doTxn( + await this.baseApis.crypto.cryptoStore.doTxn( 'readwrite', [ IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, IndexedDBCryptoStore.STORE_BACKUP, ], (txn) => { - this.baseApis.crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { + this.baseApis.crypto.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => { if (session !== null) { - this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([session], txn); + this.baseApis.crypto.cryptoStore.markSessionsNeedingBackup([session], txn); } }); }, ); - const remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + const remaining = await this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining); return remaining; } @@ -556,7 +566,7 @@ export class BackupManager { * @returns {Promise} Resolves to the number of sessions requiring backup */ public countSessionsNeedingBackup(): Promise { - return this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup(); + return this.baseApis.crypto.cryptoStore.countSessionsNeedingBackup(); } } @@ -586,7 +596,7 @@ export class Curve25519 implements BackupAlgorithm { ): Promise<[Uint8Array, AuthData]> { const decryption = new global.Olm.PkDecryption(); try { - const authData: AuthData = {}; + const authData: Partial = {}; if (!key) { authData.public_key = decryption.generate_key(); } else if (key instanceof Uint8Array) { @@ -602,7 +612,7 @@ export class Curve25519 implements BackupAlgorithm { return [ decryption.get_private_key(), - authData, + authData as AuthData, ]; } finally { decryption.free(); diff --git a/src/crypto/dehydration.ts b/src/crypto/dehydration.ts index f32daaeb7..95fb77752 100644 --- a/src/crypto/dehydration.ts +++ b/src/crypto/dehydration.ts @@ -44,7 +44,7 @@ interface DeviceKeys { signatures?: Signatures; } -interface OneTimeKey { +export interface OneTimeKey { key: string; fallback?: boolean; signatures?: Signatures; @@ -64,16 +64,16 @@ export class DehydrationManager { this.getDehydrationKeyFromCache(); } async getDehydrationKeyFromCache(): Promise { - return await this.crypto._cryptoStore.doTxn( + return await this.crypto.cryptoStore.doTxn( 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.getSecretStorePrivateKey( + this.crypto.cryptoStore.getSecretStorePrivateKey( txn, async (result) => { if (result) { const { key, keyInfo, deviceDisplayName, time } = result; - const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); + const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey); const decrypted = await decryptAES(key, pickleKey, DEHYDRATION_ALGORITHM); this.key = decodeBase64(decrypted); this.keyInfo = keyInfo; @@ -114,11 +114,11 @@ export class DehydrationManager { this.timeoutId = undefined; } // clear storage - await this.crypto._cryptoStore.doTxn( + await this.crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.storeSecretStorePrivateKey( + this.crypto.cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", null, ); }, @@ -158,15 +158,15 @@ export class DehydrationManager { this.timeoutId = undefined; } try { - const pickleKey = Buffer.from(this.crypto._olmDevice._pickleKey); + const pickleKey = Buffer.from(this.crypto.olmDevice._pickleKey); // update the crypto store with the timestamp const key = await encryptAES(encodeBase64(this.key), pickleKey, DEHYDRATION_ALGORITHM); - await this.crypto._cryptoStore.doTxn( + await this.crypto.cryptoStore.doTxn( 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => { - this.crypto._cryptoStore.storeSecretStorePrivateKey( + this.crypto.cryptoStore.storeSecretStorePrivateKey( txn, "dehydration", { keyInfo: this.keyInfo, @@ -205,7 +205,7 @@ export class DehydrationManager { } logger.log("Uploading account to server"); - const dehydrateResult = await this.crypto._baseApis.http.authedRequest( + const dehydrateResult = await this.crypto.baseApis.http.authedRequest( undefined, "PUT", "/dehydrated_device", @@ -223,9 +223,9 @@ export class DehydrationManager { const deviceId = dehydrateResult.device_id; logger.log("Preparing device keys", deviceId); const deviceKeys: DeviceKeys = { - algorithms: this.crypto._supportedAlgorithms, + algorithms: this.crypto.supportedAlgorithms, device_id: deviceId, - user_id: this.crypto._userId, + user_id: this.crypto.userId, keys: { [`ed25519:${deviceId}`]: e2eKeys.ed25519, [`curve25519:${deviceId}`]: e2eKeys.curve25519, @@ -233,12 +233,12 @@ export class DehydrationManager { }; const deviceSignature = account.sign(anotherjson.stringify(deviceKeys)); deviceKeys.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: deviceSignature, }, }; - if (this.crypto._crossSigningInfo.getId("self_signing")) { - await this.crypto._crossSigningInfo.signObject(deviceKeys, "self_signing"); + if (this.crypto.crossSigningInfo.getId("self_signing")) { + await this.crypto.crossSigningInfo.signObject(deviceKeys, "self_signing"); } logger.log("Preparing one-time keys"); @@ -247,7 +247,7 @@ export class DehydrationManager { const k: OneTimeKey = { key }; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: signature, }, }; @@ -260,7 +260,7 @@ export class DehydrationManager { const k: OneTimeKey = { key, fallback: true }; const signature = account.sign(anotherjson.stringify(k)); k.signatures = { - [this.crypto._userId]: { + [this.crypto.userId]: { [`ed25519:${deviceId}`]: signature, }, }; @@ -268,7 +268,7 @@ export class DehydrationManager { } logger.log("Uploading keys to server"); - await this.crypto._baseApis.http.authedRequest( + await this.crypto.baseApis.http.authedRequest( undefined, "POST", "/keys/upload/" + encodeURI(deviceId), @@ -292,7 +292,7 @@ export class DehydrationManager { } } - private stop() { + public stop() { if (this.timeoutId) { global.clearTimeout(this.timeoutId); this.timeoutId = undefined; diff --git a/src/crypto/deviceinfo.js b/src/crypto/deviceinfo.js deleted file mode 100644 index 379d72c63..000000000 --- a/src/crypto/deviceinfo.js +++ /dev/null @@ -1,168 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module crypto/deviceinfo - */ - -/** - * Information about a user's device - * - * @constructor - * @alias module:crypto/deviceinfo - * - * @property {string} deviceId the ID of this device - * - * @property {string[]} algorithms list of algorithms supported by this device - * - * @property {Object.} keys a map from - * <key type>:<id> -> <base64-encoded key>> - * - * @property {module:crypto/deviceinfo.DeviceVerification} verified - * whether the device has been verified/blocked by the user - * - * @property {boolean} known - * whether the user knows of this device's existence (useful when warning - * the user that a user has added new devices) - * - * @property {Object} unsigned additional data from the homeserver - * - * @param {string} deviceId id of the device - */ -export function DeviceInfo(deviceId) { - // you can't change the deviceId - Object.defineProperty(this, 'deviceId', { - enumerable: true, - value: deviceId, - }); - - this.algorithms = []; - this.keys = {}; - this.verified = DeviceVerification.UNVERIFIED; - this.known = false; - this.unsigned = {}; - this.signatures = {}; -} - -/** - * rehydrate a DeviceInfo from the session store - * - * @param {object} obj raw object from session store - * @param {string} deviceId id of the device - * - * @return {module:crypto~DeviceInfo} new DeviceInfo - */ -DeviceInfo.fromStorage = function(obj, deviceId) { - const res = new DeviceInfo(deviceId); - for (const prop in obj) { - if (obj.hasOwnProperty(prop)) { - res[prop] = obj[prop]; - } - } - return res; -}; - -/** - * Prepare a DeviceInfo for JSON serialisation in the session store - * - * @return {object} deviceinfo with non-serialised members removed - */ -DeviceInfo.prototype.toStorage = function() { - return { - algorithms: this.algorithms, - keys: this.keys, - verified: this.verified, - known: this.known, - unsigned: this.unsigned, - signatures: this.signatures, - }; -}; - -/** - * Get the fingerprint for this device (ie, the Ed25519 key) - * - * @return {string} base64-encoded fingerprint of this device - */ -DeviceInfo.prototype.getFingerprint = function() { - return this.keys["ed25519:" + this.deviceId]; -}; - -/** - * Get the identity key for this device (ie, the Curve25519 key) - * - * @return {string} base64-encoded identity key of this device - */ -DeviceInfo.prototype.getIdentityKey = function() { - return this.keys["curve25519:" + this.deviceId]; -}; - -/** - * Get the configured display name for this device, if any - * - * @return {string?} displayname - */ -DeviceInfo.prototype.getDisplayName = function() { - return this.unsigned.device_display_name || null; -}; - -/** - * Returns true if this device is blocked - * - * @return {Boolean} true if blocked - */ -DeviceInfo.prototype.isBlocked = function() { - return this.verified == DeviceVerification.BLOCKED; -}; - -/** - * Returns true if this device is verified - * - * @return {Boolean} true if verified - */ -DeviceInfo.prototype.isVerified = function() { - return this.verified == DeviceVerification.VERIFIED; -}; - -/** - * Returns true if this device is unverified - * - * @return {Boolean} true if unverified - */ -DeviceInfo.prototype.isUnverified = function() { - return this.verified == DeviceVerification.UNVERIFIED; -}; - -/** - * Returns true if the user knows about this device's existence - * - * @return {Boolean} true if known - */ -DeviceInfo.prototype.isKnown = function() { - return this.known == true; -}; - -/** - * @enum - */ -DeviceInfo.DeviceVerification = { - VERIFIED: 1, - UNVERIFIED: 0, - BLOCKED: -1, -}; - -const DeviceVerification = DeviceInfo.DeviceVerification; - diff --git a/src/crypto/deviceinfo.ts b/src/crypto/deviceinfo.ts new file mode 100644 index 000000000..870899349 --- /dev/null +++ b/src/crypto/deviceinfo.ts @@ -0,0 +1,177 @@ +/* +Copyright 2016 - 2021 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 { ISignatures } from "../@types/signed"; + +/** + * @module crypto/deviceinfo + */ + +export interface IDevice { + keys: Record; + algorithms: string[]; + verified: DeviceVerification; + known: boolean; + unsigned?: Record; + signatures?: ISignatures; +} + +enum DeviceVerification { + Blocked = -1, + Unverified = 0, + Verified = 1, +} + +/** + * Information about a user's device + * + * @constructor + * @alias module:crypto/deviceinfo + * + * @property {string} deviceId the ID of this device + * + * @property {string[]} algorithms list of algorithms supported by this device + * + * @property {Object.} keys a map from + * <key type>:<id> -> <base64-encoded key>> + * + * @property {module:crypto/deviceinfo.DeviceVerification} verified + * whether the device has been verified/blocked by the user + * + * @property {boolean} known + * whether the user knows of this device's existence (useful when warning + * the user that a user has added new devices) + * + * @property {Object} unsigned additional data from the homeserver + * + * @param {string} deviceId id of the device + */ +export class DeviceInfo { + /** + * rehydrate a DeviceInfo from the session store + * + * @param {object} obj raw object from session store + * @param {string} deviceId id of the device + * + * @return {module:crypto~DeviceInfo} new DeviceInfo + */ + public static fromStorage(obj: IDevice, deviceId: string): DeviceInfo { + const res = new DeviceInfo(deviceId); + for (const prop in obj) { + if (obj.hasOwnProperty(prop)) { + res[prop] = obj[prop]; + } + } + return res; + } + + /** + * @enum + */ + public static DeviceVerification = { + VERIFIED: DeviceVerification.Verified, + UNVERIFIED: DeviceVerification.Unverified, + BLOCKED: DeviceVerification.Blocked, + }; + + public algorithms: string[]; + public keys: Record = {}; + public verified = DeviceVerification.Unverified; + public known = false; + public unsigned: Record = {}; + public signatures: ISignatures = {}; + + constructor(public readonly deviceId: string) {} + + /** + * Prepare a DeviceInfo for JSON serialisation in the session store + * + * @return {object} deviceinfo with non-serialised members removed + */ + public toStorage(): IDevice { + return { + algorithms: this.algorithms, + keys: this.keys, + verified: this.verified, + known: this.known, + unsigned: this.unsigned, + signatures: this.signatures, + }; + } + + /** + * Get the fingerprint for this device (ie, the Ed25519 key) + * + * @return {string} base64-encoded fingerprint of this device + */ + public getFingerprint(): string { + return this.keys["ed25519:" + this.deviceId]; + } + + /** + * Get the identity key for this device (ie, the Curve25519 key) + * + * @return {string} base64-encoded identity key of this device + */ + public getIdentityKey(): string { + return this.keys["curve25519:" + this.deviceId]; + } + + /** + * Get the configured display name for this device, if any + * + * @return {string?} displayname + */ + public getDisplayName(): string | null { + return this.unsigned.device_display_name || null; + } + + /** + * Returns true if this device is blocked + * + * @return {Boolean} true if blocked + */ + public isBlocked(): boolean { + return this.verified == DeviceVerification.Blocked; + } + + /** + * Returns true if this device is verified + * + * @return {Boolean} true if verified + */ + public isVerified(): boolean { + return this.verified == DeviceVerification.Verified; + } + + /** + * Returns true if this device is unverified + * + * @return {Boolean} true if unverified + */ + public isUnverified(): boolean { + return this.verified == DeviceVerification.Unverified; + } + + /** + * Returns true if the user knows about this device's existence + * + * @return {Boolean} true if known + */ + public isKnown(): boolean { + return this.known === true; + } +} diff --git a/src/crypto/index.js b/src/crypto/index.js deleted file mode 100644 index 3a8c844b3..000000000 --- a/src/crypto/index.js +++ /dev/null @@ -1,3651 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018-2019 New Vector Ltd -Copyright 2019-2021 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. -*/ - -/** - * @module crypto - */ - -import anotherjson from "another-json"; -import { EventEmitter } from 'events'; -import { ReEmitter } from '../ReEmitter'; -import { logger } from '../logger'; -import * as utils from "../utils"; -import { OlmDevice } from "./OlmDevice"; -import * as olmlib from "./olmlib"; -import { DeviceList } from "./DeviceList"; -import { DeviceInfo } from "./deviceinfo"; -import * as algorithms from "./algorithms"; -import { - CrossSigningInfo, - DeviceTrustLevel, - UserTrustLevel, - createCryptoStoreCacheCallbacks, -} from './CrossSigning'; -import { EncryptionSetupBuilder } from "./EncryptionSetup"; -import { SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage } from './SecretStorage'; -import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; -import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; -import { - ReciprocateQRCode, - SCAN_QR_CODE_METHOD, - SHOW_QR_CODE_METHOD, -} from './verification/QRCode'; -import { SAS } from './verification/SAS'; -import { keyFromPassphrase } from './key_passphrase'; -import { encodeRecoveryKey, decodeRecoveryKey } from './recoverykey'; -import { VerificationRequest } from "./verification/request/VerificationRequest"; -import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; -import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; -import { IllegalMethod } from "./verification/IllegalMethod"; -import { KeySignatureUploadError } from "../errors"; -import { decryptAES, encryptAES, calculateKeyCheck } from './aes'; -import { DehydrationManager } from './dehydration'; -import { MatrixEvent } from "../models/event"; -import { BackupManager } from "./backup"; - -const DeviceVerification = DeviceInfo.DeviceVerification; - -const defaultVerificationMethods = { - [ReciprocateQRCode.NAME]: ReciprocateQRCode, - [SAS.NAME]: SAS, - - // These two can't be used for actual verification, but we do - // need to be able to define them here for the verification flows - // to start. - [SHOW_QR_CODE_METHOD]: IllegalMethod, - [SCAN_QR_CODE_METHOD]: IllegalMethod, -}; - -/** - * verification method names - */ -export const verificationMethods = { - RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, - SAS: SAS.NAME, -}; - -export function isCryptoAvailable() { - return Boolean(global.Olm); -} - -const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; - -/** - * Cryptography bits - * - * This module is internal to the js-sdk; the public API is via MatrixClient. - * - * @constructor - * @alias module:crypto - * - * @internal - * - * @param {MatrixClient} baseApis base matrix api interface - * - * @param {module:store/session/webstorage~WebStorageSessionStore} sessionStore - * Store to be used for end-to-end crypto session data - * - * @param {string} userId The user ID for the local user - * - * @param {string} deviceId The identifier for this device. - * - * @param {Object} clientStore the MatrixClient data store. - * - * @param {module:crypto/store/base~CryptoStore} cryptoStore - * storage for the crypto layer. - * - * @param {RoomList} roomList An initialised RoomList object - * - * @param {Array} verificationMethods Array of verification methods to use. - * Each element can either be a string from MatrixClient.verificationMethods - * or a class that implements a verification method. - */ -export function Crypto(baseApis, sessionStore, userId, deviceId, - clientStore, cryptoStore, roomList, verificationMethods) { - this._onDeviceListUserCrossSigningUpdated = - this._onDeviceListUserCrossSigningUpdated.bind(this); - - this._trustCrossSignedDevices = true; - - this._reEmitter = new ReEmitter(this); - this._baseApis = baseApis; - this._sessionStore = sessionStore; - this._userId = userId; - this._deviceId = deviceId; - this._clientStore = clientStore; - this._cryptoStore = cryptoStore; - this._roomList = roomList; - if (verificationMethods) { - this._verificationMethods = new Map(); - for (const method of verificationMethods) { - if (typeof method === "string") { - if (defaultVerificationMethods[method]) { - this._verificationMethods.set( - method, - defaultVerificationMethods[method], - ); - } - } else if (method.NAME) { - this._verificationMethods.set( - method.NAME, - method, - ); - } else { - logger.warn(`Excluding unknown verification method ${method}`); - } - } - } else { - this._verificationMethods = defaultVerificationMethods; - } - - this._backupManager = new BackupManager(baseApis, async (algorithm) => { - // try to get key from cache - const cachedKey = await this.getSessionBackupPrivateKey(); - if (cachedKey) { - return cachedKey; - } - - // try to get key from secret storage - const storedKey = await this.getSecret("m.megolm_backup.v1"); - - if (storedKey) { - // ensure that the key is in the right format. If not, fix the key and - // store the fixed version - const fixedKey = fixBackupKey(storedKey); - if (fixedKey) { - const [keyId] = await this._crypto.getSecretStorageKey(); - await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]); - } - - return olmlib.decodeBase64(fixedKey || storedKey); - } - - // try to get key from app - if (this._baseApis._cryptoCallbacks && this._baseApis._cryptoCallbacks.getBackupKey) { - return await this._baseApis._cryptoCallbacks.getBackupKey(algorithm); - } - - throw new Error("Unable to get private key"); - }); - - this._olmDevice = new OlmDevice(cryptoStore); - this._deviceList = new DeviceList( - baseApis, cryptoStore, this._olmDevice, - ); - // XXX: This isn't removed at any point, but then none of the event listeners - // this class sets seem to be removed at any point... :/ - this._deviceList.on( - 'userCrossSigningUpdated', this._onDeviceListUserCrossSigningUpdated, - ); - this._reEmitter.reEmit(this._deviceList, [ - "crypto.devicesUpdated", "crypto.willUpdateDevices", - ]); - - // the last time we did a check for the number of one-time-keys on the - // server. - this._lastOneTimeKeyCheck = null; - this._oneTimeKeyCheckInProgress = false; - - // EncryptionAlgorithm instance for each room - this._roomEncryptors = {}; - - // map from algorithm to DecryptionAlgorithm instance, for each room - this._roomDecryptors = {}; - - this._supportedAlgorithms = Object.keys( - algorithms.DECRYPTION_CLASSES, - ); - - this._deviceKeys = {}; - - this._globalBlacklistUnverifiedDevices = false; - this._globalErrorOnUnknownDevices = true; - - this._outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( - baseApis, this._deviceId, this._cryptoStore, - ); - - // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations - // we received in the current sync. - this._receivedRoomKeyRequests = []; - this._receivedRoomKeyRequestCancellations = []; - // true if we are currently processing received room key requests - this._processingRoomKeyRequests = false; - // controls whether device tracking is delayed - // until calling encryptEvent or trackRoomDevices, - // or done immediately upon enabling room encryption. - this._lazyLoadMembers = false; - // in case _lazyLoadMembers is true, - // track if an initial tracking of all the room members - // has happened for a given room. This is delayed - // to avoid loading room members as long as possible. - this._roomDeviceTrackingState = {}; - - // The timestamp of the last time we forced establishment - // of a new session for each device, in milliseconds. - // { - // userId: { - // deviceId: 1234567890000, - // }, - // } - this._lastNewSessionForced = {}; - - this._toDeviceVerificationRequests = new ToDeviceRequests(); - this._inRoomVerificationRequests = new InRoomRequests(); - - // This flag will be unset whilst the client processes a sync response - // so that we don't start requesting keys until we've actually finished - // processing the response. - this._sendKeyRequestsImmediately = false; - - const cryptoCallbacks = this._baseApis.cryptoCallbacks || {}; - const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this._olmDevice); - - this._crossSigningInfo = new CrossSigningInfo( - userId, - cryptoCallbacks, - cacheCallbacks, - ); - - this._secretStorage = new SecretStorage( - baseApis, cryptoCallbacks, - ); - - this._dehydrationManager = new DehydrationManager(this); - - // Assuming no app-supplied callback, default to getting from SSSS. - if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { - cryptoCallbacks.getCrossSigningKey = async (type) => { - return CrossSigningInfo.getFromSecretStorage(type, this._secretStorage); - }; - } -} -utils.inherits(Crypto, EventEmitter); - -/** - * Initialise the crypto module so that it is ready for use - * - * Returns a promise which resolves once the crypto module is ready for use. - * - * @param {Object} opts keyword arguments. - * @param {string} opts.exportedOlmDevice (Optional) data from exported device - * that must be re-created. - */ -Crypto.prototype.init = async function(opts) { - const { - exportedOlmDevice, - pickleKey, - } = opts || {}; - - logger.log("Crypto: initialising Olm..."); - await global.Olm.init(); - logger.log( - exportedOlmDevice - ? "Crypto: initialising Olm device from exported device..." - : "Crypto: initialising Olm device...", - ); - await this._olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); - logger.log("Crypto: loading device list..."); - await this._deviceList.load(); - - // build our device keys: these will later be uploaded - this._deviceKeys["ed25519:" + this._deviceId] = - this._olmDevice.deviceEd25519Key; - this._deviceKeys["curve25519:" + this._deviceId] = - this._olmDevice.deviceCurve25519Key; - - logger.log("Crypto: fetching own devices..."); - let myDevices = this._deviceList.getRawStoredDevicesForUser( - this._userId, - ); - - if (!myDevices) { - myDevices = {}; - } - - if (!myDevices[this._deviceId]) { - // add our own deviceinfo to the cryptoStore - logger.log("Crypto: adding this device to the store..."); - const deviceInfo = { - keys: this._deviceKeys, - algorithms: this._supportedAlgorithms, - verified: DeviceVerification.VERIFIED, - known: true, - }; - - myDevices[this._deviceId] = deviceInfo; - this._deviceList.storeDevicesForUser( - this._userId, myDevices, - ); - this._deviceList.saveIfDirty(); - } - - await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.getCrossSigningKeys(txn, (keys) => { - // can be an empty object after resetting cross-signing keys, see _storeTrustedSelfKeys - if (keys && Object.keys(keys).length !== 0) { - logger.log("Loaded cross-signing public keys from crypto store"); - this._crossSigningInfo.setKeys(keys); - } - }); - }, - ); - // make sure we are keeping track of our own devices - // (this is important for key backups & things) - this._deviceList.startTrackingDeviceList(this._userId); - - logger.log("Crypto: checking for key backup..."); - this._backupManager.checkAndStart(); -}; - -/** - * Whether to trust a others users signatures of their devices. - * If false, devices will only be considered 'verified' if we have - * verified that device individually (effectively disabling cross-signing). - * - * Default: true - * - * @return {bool} True if trusting cross-signed devices - */ -Crypto.prototype.getCryptoTrustCrossSignedDevices = function() { - return this._trustCrossSignedDevices; -}; - -/** - * See getCryptoTrustCrossSignedDevices - - * This may be set before initCrypto() is called to ensure no races occur. - * - * @param {bool} val True to trust cross-signed devices - */ -Crypto.prototype.setCryptoTrustCrossSignedDevices = function(val) { - this._trustCrossSignedDevices = val; - - for (const userId of this._deviceList.getKnownUserIds()) { - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - for (const deviceId of Object.keys(devices)) { - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - // If the device is locally verified then isVerified() is always true, - // so this will only have caused the value to change if the device is - // cross-signing verified but not locally verified - if ( - !deviceTrust.isLocallyVerified() && - deviceTrust.isCrossSigningVerified() - ) { - const deviceObj = this._deviceList.getStoredDevice(userId, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); - } - } - } -}; - -/** - * Create a recovery key from a user-supplied passphrase. - * - * @param {string} password Passphrase string that can be entered by the user - * when restoring the backup as an alternative to entering the recovery key. - * Optional. - * @returns {Promise} Object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - */ -Crypto.prototype.createRecoveryKeyFromPassphrase = async function(password) { - const decryption = new global.Olm.PkDecryption(); - try { - const keyInfo = {}; - if (password) { - const derivation = await keyFromPassphrase(password); - keyInfo.passphrase = { - algorithm: "m.pbkdf2", - iterations: derivation.iterations, - salt: derivation.salt, - }; - keyInfo.pubkey = decryption.init_with_private_key(derivation.key); - } else { - keyInfo.pubkey = decryption.generate_key(); - } - const privateKey = decryption.get_private_key(); - const encodedPrivateKey = encodeRecoveryKey(privateKey); - return { keyInfo, encodedPrivateKey, privateKey }; - } finally { - if (decryption) decryption.free(); - } -}; - -/** - * Checks whether cross signing: - * - is enabled on this account and trusted by this device - * - has private keys either cached locally or stored in secret storage - * - * If this function returns false, bootstrapCrossSigning() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapCrossSigning() completes successfully, this function should - * return true. - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @return {bool} True if cross-signing is ready to be used on this device - */ -Crypto.prototype.isCrossSigningReady = async function() { - const publicKeysOnDevice = this._crossSigningInfo.getId(); - const privateKeysExistSomewhere = ( - await this._crossSigningInfo.isStoredInKeyCache() || - await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ) - ); - - return !!( - publicKeysOnDevice && - privateKeysExistSomewhere - ); -}; - -/** - * Checks whether secret storage: - * - is enabled on this account - * - is storing cross-signing private keys - * - is storing session backup key (if enabled) - * - * If this function returns false, bootstrapSecretStorage() can be used - * to fix things such that it returns true. That is to say, after - * bootstrapSecretStorage() completes successfully, this function should - * return true. - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @return {bool} True if secret storage is ready to be used on this device - */ -Crypto.prototype.isSecretStorageReady = async function() { - const secretStorageKeyInAccount = await this._secretStorage.hasKey(); - const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ); - const sessionBackupInStorage = ( - !this._backupManager.getKeyBackupEnabled() || - this._baseApis.isKeyBackupKeyStored() - ); - - return !!( - secretStorageKeyInAccount && - privateKeysInStorage && - sessionBackupInStorage - ); -}; - -/** - * Bootstrap cross-signing by creating keys if needed. If everything is already - * set up, then no changes are made, so this is safe to run to ensure - * cross-signing is ready for use. - * - * This function: - * - creates new cross-signing keys if they are not found locally cached nor in - * secret storage (if it has been setup) - * - * The cross-signing API is currently UNSTABLE and may change without notice. - * - * @param {function} opts.authUploadDeviceSigningKeys Function - * called to await an interactive auth flow when uploading device signing keys. - * @param {bool} [opts.setupNewCrossSigning] Optional. Reset even if keys - * already exist. - * Args: - * {function} A function that makes the request requiring auth. Receives the - * auth data as an object. Can be called multiple times, first with an empty - * authDict, to obtain the flows. - */ -Crypto.prototype.bootstrapCrossSigning = async function({ - authUploadDeviceSigningKeys, - setupNewCrossSigning, -} = {}) { - logger.log("Bootstrapping cross-signing"); - - const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder( - this._baseApis.store.accountData, - delegateCryptoCallbacks, - ); - const crossSigningInfo = new CrossSigningInfo( - this._userId, - builder.crossSigningCallbacks, - builder.crossSigningCallbacks, - ); - - // Reset the cross-signing keys - const resetCrossSigning = async () => { - crossSigningInfo.resetKeys(); - // Sign master key with device key - await this._signObject(crossSigningInfo.keys.master); - - // Store auth flow helper function, as we need to call it when uploading - // to ensure we handle auth errors properly. - builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); - - // Cross-sign own device - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const deviceSignature = await crossSigningInfo.signDevice(this._userId, device); - builder.addKeySignature(this._userId, this._deviceId, deviceSignature); - - // Sign message key backup with cross-signing master key - if (this._backupManager.backupInfo) { - await crossSigningInfo.signObject( - this._backupManager.backupInfo.auth_data, "master", - ); - builder.addSessionBackup(this._backupManager.backupInfo); - } - }; - - const publicKeysOnDevice = this._crossSigningInfo.getId(); - const privateKeysInCache = await this._crossSigningInfo.isStoredInKeyCache(); - const privateKeysInStorage = await this._crossSigningInfo.isStoredInSecretStorage( - this._secretStorage, - ); - const privateKeysExistSomewhere = ( - privateKeysInCache || - privateKeysInStorage - ); - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - setupNewCrossSigning, - publicKeysOnDevice, - privateKeysInCache, - privateKeysInStorage, - privateKeysExistSomewhere, - }); - - if (!privateKeysExistSomewhere || setupNewCrossSigning) { - logger.log( - "Cross-signing private keys not found locally or in secret storage, " + - "creating new keys", - ); - // If a user has multiple devices, it important to only call bootstrap - // as part of some UI flow (and not silently during startup), as they - // may have setup cross-signing on a platform which has not saved keys - // to secret storage, and this would reset them. In such a case, you - // should prompt the user to verify any existing devices first (and - // request private keys from those devices) before calling bootstrap. - await resetCrossSigning(); - } else if (publicKeysOnDevice && privateKeysInCache) { - logger.log( - "Cross-signing public keys trusted and private keys found locally", - ); - } else if (privateKeysInStorage) { - logger.log( - "Cross-signing private keys not found locally, but they are available " + - "in secret storage, reading storage and caching locally", - ); - await this.checkOwnCrossSigningTrust({ - allowPrivateKeyRequests: true, - }); - } - - // Assuming no app-supplied callback, default to storing new private keys in - // secret storage if it exists. If it does not, it is assumed this will be - // done as part of setting up secret storage later. - const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; - if ( - crossSigningPrivateKeys.size && - !this._baseApis.cryptoCallbacks.saveCrossSigningKeys - ) { - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks); - if (await secretStorage.hasKey()) { - logger.log("Storing new cross-signing private keys in secret storage"); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage( - crossSigningPrivateKeys, - secretStorage, - ); - } - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // This persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Cross-signing ready"); -}; - -/** - * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is - * already set up, then no changes are made, so this is safe to run to ensure secret - * storage is ready for use. - * - * This function - * - creates a new Secure Secret Storage key if no default key exists - * - if a key backup exists, it is migrated to store the key in the Secret - * Storage - * - creates a backup if none exists, and one is requested - * - migrates Secure Secret Storage to use the latest algorithm, if an outdated - * algorithm is found - * - * The Secure Secret Storage API is currently UNSTABLE and may change without notice. - * - * @param {function} [opts.createSecretStorageKey] Optional. Function - * called to await a secret storage key creation flow. - * Returns: - * {Promise} Object with public key metadata, encoded private - * recovery key which should be disposed of after displaying to the user, - * and raw private key to avoid round tripping if needed. - * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, - * the passphrase and recovery key from this backup will be used. - * @param {bool} [opts.setupNewKeyBackup] If true, a new key backup version will be - * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo - * is supplied. - * @param {bool} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. - * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's - * current key backup passphrase. Should return a promise that resolves with a Buffer - * containing the key, or rejects if the key cannot be obtained. - * Returns: - * {Promise} A promise which resolves to key creation data for - * SecretStorage#addKey: an object with `passphrase` etc fields. - */ -Crypto.prototype.bootstrapSecretStorage = async function({ - createSecretStorageKey = async () => ({ }), - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - getKeyBackupPassphrase, -} = {}) { - logger.log("Bootstrapping Secure Secret Storage"); - const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks; - const builder = new EncryptionSetupBuilder( - this._baseApis.store.accountData, - delegateCryptoCallbacks, - ); - const secretStorage = new SecretStorage( - builder.accountDataClientAdapter, - builder.ssssCryptoCallbacks, - ); - - // the ID of the new SSSS key, if we create one - let newKeyId = null; - - // create a new SSSS key and set it as default - const createSSSS = async (opts, privateKey) => { - opts = opts || {}; - if (privateKey) { - opts.key = privateKey; - } - - const { keyId, keyInfo } = await secretStorage.addKey( - SECRET_STORAGE_ALGORITHM_V1_AES, opts, - ); - - if (privateKey) { - // make the private key available to encrypt 4S secrets - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - } - - await secretStorage.setDefaultKeyId(keyId); - return keyId; - }; - - const ensureCanCheckPassphrase = async (keyId, keyInfo) => { - if (!keyInfo.mac) { - const key = await this._baseApis.cryptoCallbacks.getSecretStorageKey( - { keys: { [keyId]: keyInfo } }, "", - ); - if (key) { - const privateKey = key[1]; - builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); - const { iv, mac } = await calculateKeyCheck(privateKey); - keyInfo.iv = iv; - keyInfo.mac = mac; - - await builder.setAccountData( - `m.secret_storage.key.${keyId}`, keyInfo, - ); - } - } - }; - - const signKeyBackupWithCrossSigning = async (keyBackupAuthData) => { - if ( - this._crossSigningInfo.getId() && - await this._crossSigningInfo.isStoredInKeyCache("master") - ) { - try { - logger.log("Adding cross-signing signature to key backup"); - await this._crossSigningInfo.signObject(keyBackupAuthData, "master"); - } catch (e) { - // This step is not critical (just helpful), so we catch here - // and continue if it fails. - logger.error("Signing key backup with cross-signing keys failed", e); - } - } else { - logger.warn( - "Cross-signing keys not available, skipping signature on key backup", - ); - } - }; - - const oldSSSSKey = await this.getSecretStorageKey(); - const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; - const storageExists = ( - !setupNewSecretStorage && - oldKeyInfo && - oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES - ); - - // Log all relevant state for easier parsing of debug logs. - logger.log({ - keyBackupInfo, - setupNewKeyBackup, - setupNewSecretStorage, - storageExists, - oldKeyInfo, - }); - - if (!storageExists && !keyBackupInfo) { - // either we don't have anything, or we've been asked to restart - // from scratch - logger.log( - "Secret storage does not exist, creating new storage key", - ); - - // if we already have a usable default SSSS key and aren't resetting - // SSSS just use it. otherwise, create a new one - // Note: we leave the old SSSS key in place: there could be other - // secrets using it, in theory. We could move them to the new key but a) - // that would mean we'd need to prompt for the old passphrase, and b) - // it's not clear that would be the right thing to do anyway. - const { keyInfo, privateKey } = await createSecretStorageKey(); - newKeyId = await createSSSS(keyInfo, privateKey); - } else if (!storageExists && keyBackupInfo) { - // we have an existing backup, but no SSSS - logger.log("Secret storage does not exist, using key backup key"); - - // if we have the backup key already cached, use it; otherwise use the - // callback to prompt for the key - const backupKey = await this.getSessionBackupPrivateKey() || - await getKeyBackupPassphrase(); - - // create a new SSSS key and use the backup key as the new SSSS key - const opts = {}; - - if ( - keyBackupInfo.auth_data.private_key_salt && - keyBackupInfo.auth_data.private_key_iterations - ) { - // FIXME: ??? - opts.passphrase = { - algorithm: "m.pbkdf2", - iterations: keyBackupInfo.auth_data.private_key_iterations, - salt: keyBackupInfo.auth_data.private_key_salt, - bits: 256, - }; - } - - newKeyId = await createSSSS(opts, backupKey); - - // store the backup key in secret storage - await secretStorage.store( - "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], - ); - - // The backup is trusted because the user provided the private key. - // Sign the backup with the cross-signing key so the key backup can - // be trusted via cross-signing. - await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); - - builder.addSessionBackup(keyBackupInfo); - } else { - // 4S is already set up - logger.log("Secret storage exists"); - - if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { - // make sure that the default key has the information needed to - // check the passphrase - await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); - } - } - - // If we have cross-signing private keys cached, store them in secret - // storage if they are not there already. - if ( - !this._baseApis.cryptoCallbacks.saveCrossSigningKeys && - await this.isCrossSigningReady() && - (newKeyId || !await this._crossSigningInfo.isStoredInSecretStorage(secretStorage)) - ) { - logger.log("Copying cross-signing private keys from cache to secret storage"); - const crossSigningPrivateKeys = - await this._crossSigningInfo.getCrossSigningKeysFromCache(); - // This is writing to in-memory account data in - // builder.accountDataClientAdapter so won't fail - await CrossSigningInfo.storeInSecretStorage( - crossSigningPrivateKeys, - secretStorage, - ); - } - - if (setupNewKeyBackup && !keyBackupInfo) { - logger.log("Creating new message key backup version"); - const info = await this._baseApis.prepareKeyBackupVersion( - null /* random key */, - // don't write to secret storage, as it will write to this._secretStorage. - // Here, we want to capture all the side-effects of bootstrapping, - // and want to write to the local secretStorage object - { secureSecretStorage: false }, - ); - // write the key ourselves to 4S - const privateKey = decodeRecoveryKey(info.recovery_key); - await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); - - // create keyBackupInfo object to add to builder - const data = { - algorithm: info.algorithm, - auth_data: info.auth_data, - }; - - // Sign with cross-signing master key - await signKeyBackupWithCrossSigning(data.auth_data); - - // sign with the device fingerprint - await this._signObject(data.auth_data); - - builder.addSessionBackup(data); - } - - // Cache the session backup key - const sessionBackupKey = await secretStorage.get('m.megolm_backup.v1'); - if (sessionBackupKey) { - logger.info("Got session backup key from secret storage: caching"); - // fix up the backup key if it's in the wrong format, and replace - // in secret storage - const fixedBackupKey = fixBackupKey(sessionBackupKey); - if (fixedBackupKey) { - await secretStorage.store("m.megolm_backup.v1", - fixedBackupKey, [newKeyId || oldKeyId], - ); - } - const decodedBackupKey = new Uint8Array(olmlib.decodeBase64( - fixedBackupKey || sessionBackupKey, - )); - await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); - } - - const operation = builder.buildOperation(); - await operation.apply(this); - // this persists private keys and public keys as trusted, - // only do this if apply succeeded for now as retry isn't in place yet - await builder.persist(this); - - logger.log("Secure Secret Storage ready"); -}; - -/** - * Fix up the backup key, that may be in the wrong format due to a bug in a - * migration step. Some backup keys were stored as a comma-separated list of - * integers, rather than a base64-encoded byte array. If this function is - * passed a string that looks like a list of integers rather than a base64 - * string, it will attempt to convert it to the right format. - * - * @param {string} key the key to check - * @returns {null | string} If the key is in the wrong format, then the fixed - * key will be returned. Otherwise null will be returned. - * - */ -export function fixBackupKey(key) { - if (typeof key !== "string" || key.indexOf(",") < 0) { - return null; - } - const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); - return olmlib.encodeBase64(fixedKey); -} - -Crypto.prototype.addSecretStorageKey = function(algorithm, opts, keyID) { - return this._secretStorage.addKey(algorithm, opts, keyID); -}; - -Crypto.prototype.hasSecretStorageKey = function(keyID) { - return this._secretStorage.hasKey(keyID); -}; - -Crypto.prototype.getSecretStorageKey = function(keyID) { - return this._secretStorage.getKey(keyID); -}; - -Crypto.prototype.storeSecret = function(name, secret, keys) { - return this._secretStorage.store(name, secret, keys); -}; - -Crypto.prototype.getSecret = function(name) { - return this._secretStorage.get(name); -}; - -Crypto.prototype.isSecretStored = function(name, checkKey) { - return this._secretStorage.isStored(name, checkKey); -}; - -Crypto.prototype.requestSecret = function(name, devices) { - if (!devices) { - devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(this._userId)); - } - return this._secretStorage.request(name, devices); -}; - -Crypto.prototype.getDefaultSecretStorageKeyId = function() { - return this._secretStorage.getDefaultKeyId(); -}; - -Crypto.prototype.setDefaultSecretStorageKeyId = function(k) { - return this._secretStorage.setDefaultKeyId(k); -}; - -Crypto.prototype.checkSecretStorageKey = function(key, info) { - return this._secretStorage.checkKey(key, info); -}; - -/** - * Checks that a given secret storage private key matches a given public key. - * This can be used by the getSecretStorageKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param {Uint8Array} privateKey The private key - * @param {string} expectedPublicKey The public key - * @returns {boolean} true if the key matches, otherwise false - */ -Crypto.prototype.checkSecretStoragePrivateKey = function(privateKey, expectedPublicKey) { - let decryption = null; - try { - decryption = new global.Olm.PkDecryption(); - const gotPubkey = decryption.init_with_private_key(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - if (decryption) decryption.free(); - } -}; - -/** - * Fetches the backup private key, if cached - * @returns {Promise} the key, if any, or null - */ -Crypto.prototype.getSessionBackupPrivateKey = async function() { - let key = await new Promise((resolve) => { - this._cryptoStore.doTxn( - 'readonly', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.getSecretStorePrivateKey( - txn, - resolve, - "m.megolm_backup.v1", - ); - }, - ); - }); - - // make sure we have a Uint8Array, rather than a string - if (key && typeof key === "string") { - key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); - await this.storeSessionBackupPrivateKey(key); - } - if (key && key.ciphertext) { - const pickleKey = Buffer.from(this._olmDevice._pickleKey); - const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); - key = olmlib.decodeBase64(decrypted); - } - return key; -}; - -/** - * Stores the session backup key to the cache - * @param {Uint8Array} key the private key - * @returns {Promise} so you can catch failures - */ -Crypto.prototype.storeSessionBackupPrivateKey = async function(key) { - if (!(key instanceof Uint8Array)) { - throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); - } - const pickleKey = Buffer.from(this._olmDevice._pickleKey); - key = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); - return this._cryptoStore.doTxn( - 'readwrite', - [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", key); - }, - ); -}; - -/** - * Checks that a given cross-signing private key matches a given public key. - * This can be used by the getCrossSigningKey callback to verify that the - * private key it is about to supply is the one that was requested. - * - * @param {Uint8Array} privateKey The private key - * @param {string} expectedPublicKey The public key - * @returns {boolean} true if the key matches, otherwise false - */ -Crypto.prototype.checkCrossSigningPrivateKey = function(privateKey, expectedPublicKey) { - let signing = null; - try { - signing = new global.Olm.PkSigning(); - const gotPubkey = signing.init_with_seed(privateKey); - // make sure it agrees with the given pubkey - return gotPubkey === expectedPublicKey; - } finally { - if (signing) signing.free(); - } -}; - -/** - * Run various follow-up actions after cross-signing keys have changed locally - * (either by resetting the keys for the account or by getting them from secret - * storage), such as signing the current device, upgrading device - * verifications, etc. - */ -Crypto.prototype._afterCrossSigningLocalKeyChange = async function() { - logger.info("Starting cross-signing key change post-processing"); - - // sign the current device with the new key, and upload to the server - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const signedDevice = await this._crossSigningInfo.signDevice(this._userId, device); - logger.info(`Starting background key sig upload for ${this._deviceId}`); - - const upload = ({ shouldEmit }) => { - return this._baseApis.uploadKeySignatures({ - [this._userId]: { - [this._deviceId]: signedDevice, - }, - }).then((response) => { - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "_afterCrossSigningLocalKeyChange", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - logger.info(`Finished background key sig upload for ${this._deviceId}`); - }).catch(e => { - logger.error( - `Error during background key sig upload for ${this._deviceId}`, - e, - ); - }); - }; - upload({ shouldEmit: true }); - - const shouldUpgradeCb = ( - this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); - if (shouldUpgradeCb) { - logger.info("Starting device verification upgrade"); - - // Check all users for signatures if upgrade callback present - // FIXME: do this in batches - const users = {}; - for (const [userId, crossSigningInfo] - of Object.entries(this._deviceList._crossSigningInfo)) { - const upgradeInfo = await this._checkForDeviceVerificationUpgrade( - userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), - ); - if (upgradeInfo) { - users[userId] = upgradeInfo; - } - } - - if (Object.keys(users).length > 0) { - logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); - try { - const usersToUpgrade = await shouldUpgradeCb({ users: users }); - if (usersToUpgrade) { - for (const userId of usersToUpgrade) { - if (userId in users) { - await this._baseApis.setDeviceVerified( - userId, users[userId].crossSigningInfo.getId(), - ); - } - } - } - } catch (e) { - logger.log( - "shouldUpgradeDeviceVerifications threw an error: not upgrading", e, - ); - } - } - - logger.info("Finished device verification upgrade"); - } - - logger.info("Finished cross-signing key change post-processing"); -}; - -/** - * Check if a user's cross-signing key is a candidate for upgrading from device - * verification. - * - * @param {string} userId the user whose cross-signing information is to be checked - * @param {object} crossSigningInfo the cross-signing information to check - */ -Crypto.prototype._checkForDeviceVerificationUpgrade = async function( - userId, crossSigningInfo, -) { - // only upgrade if this is the first cross-signing key that we've seen for - // them, and if their cross-signing key isn't already verified - const trustLevel = this._crossSigningInfo.checkUserTrust(crossSigningInfo); - if (crossSigningInfo.firstUse && !trustLevel.verified) { - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - const deviceIds = await this._checkForValidDeviceSignature( - userId, crossSigningInfo.keys.master, devices, - ); - if (deviceIds.length) { - return { - devices: deviceIds.map( - deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), - ), - crossSigningInfo, - }; - } - } -}; - -/** - * Check if the cross-signing key is signed by a verified device. - * - * @param {string} userId the user ID whose key is being checked - * @param {object} key the key that is being checked - * @param {object} devices the user's devices. Should be a map from device ID - * to device info - */ -Crypto.prototype._checkForValidDeviceSignature = async function(userId, key, devices) { - const deviceIds = []; - if (devices && key.signatures && key.signatures[userId]) { - for (const signame of Object.keys(key.signatures[userId])) { - const [, deviceId] = signame.split(':', 2); - if (deviceId in devices - && devices[deviceId].verified === DeviceVerification.VERIFIED) { - try { - await olmlib.verifySignature( - this._olmDevice, - key, - userId, - deviceId, - devices[deviceId].keys[signame], - ); - deviceIds.push(deviceId); - } catch (e) {} - } - } - } - return deviceIds; -}; - -/** - * Get the user's cross-signing key ID. - * - * @param {string} [type=master] The type of key to get the ID of. One of - * "master", "self_signing", or "user_signing". Defaults to "master". - * - * @returns {string} the key ID - */ -Crypto.prototype.getCrossSigningId = function(type) { - return this._crossSigningInfo.getId(type); -}; - -/** - * Get the cross signing information for a given user. - * - * @param {string} userId the user ID to get the cross-signing info for. - * - * @returns {CrossSigningInfo} the cross signing informmation for the user. - */ -Crypto.prototype.getStoredCrossSigningForUser = function(userId) { - return this._deviceList.getStoredCrossSigningForUser(userId); -}; - -/** - * Check whether a given user is trusted. - * - * @param {string} userId The ID of the user to check. - * - * @returns {UserTrustLevel} - */ -Crypto.prototype.checkUserTrust = function(userId) { - const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (!userCrossSigning) { - return new UserTrustLevel(false, false, false); - } - return this._crossSigningInfo.checkUserTrust(userCrossSigning); -}; - -/** - * Check whether a given device is trusted. - * - * @param {string} userId The ID of the user whose devices is to be checked. - * @param {string} deviceId The ID of the device to check - * - * @returns {DeviceTrustLevel} - */ -Crypto.prototype.checkDeviceTrust = function(userId, deviceId) { - const device = this._deviceList.getStoredDevice(userId, deviceId); - return this._checkDeviceInfoTrust(userId, device); -}; - -/** - * Check whether a given deviceinfo is trusted. - * - * @param {string} userId The ID of the user whose devices is to be checked. - * @param {module:crypto/deviceinfo?} device The device info object to check - * - * @returns {DeviceTrustLevel} - */ -Crypto.prototype._checkDeviceInfoTrust = function(userId, device) { - const trustedLocally = !!(device && device.isVerified()); - - const userCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (device && userCrossSigning) { - // The _trustCrossSignedDevices only affects trust of other people's cross-signing - // signatures - const trustCrossSig = this._trustCrossSignedDevices || userId === this._userId; - return this._crossSigningInfo.checkDeviceTrust( - userCrossSigning, device, trustedLocally, trustCrossSig, - ); - } else { - return new DeviceTrustLevel(false, false, trustedLocally, false); - } -}; - -/* - * Event handler for DeviceList's userNewDevices event - */ -Crypto.prototype._onDeviceListUserCrossSigningUpdated = async function(userId) { - if (userId === this._userId) { - // An update to our own cross-signing key. - // Get the new key first: - const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; - const currentPubkey = this._crossSigningInfo.getId(); - const changed = currentPubkey !== seenPubkey; - - if (currentPubkey && seenPubkey && !changed) { - // If it's not changed, just make sure everything is up to date - await this.checkOwnCrossSigningTrust(); - } else { - // We'll now be in a state where cross-signing on the account is not trusted - // because our locally stored cross-signing keys will not match the ones - // on the server for our account. So we clear our own stored cross-signing keys, - // effectively disabling cross-signing until the user gets verified by the device - // that reset the keys - this._storeTrustedSelfKeys(null); - // emit cross-signing has been disabled - this.emit("crossSigning.keysChanged", {}); - // as the trust for our own user has changed, - // also emit an event for this - this.emit("userTrustStatusChanged", - this._userId, this.checkUserTrust(userId)); - } - } else { - await this._checkDeviceVerifications(userId); - - // Update verified before latch using the current state and save the new - // latch value in the device list store. - const crossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (crossSigning) { - crossSigning.updateCrossSigningVerifiedBefore( - this.checkUserTrust(userId).isCrossSigningVerified(), - ); - this._deviceList.setRawStoredCrossSigningForUser( - userId, crossSigning.toStorage(), - ); - } - - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); - } -}; - -/** - * Check the copy of our cross-signing key that we have in the device list and - * see if we can get the private key. If so, mark it as trusted. - */ -Crypto.prototype.checkOwnCrossSigningTrust = async function({ - allowPrivateKeyRequests = false, -} = {}) { - const userId = this._userId; - - // Before proceeding, ensure our cross-signing public keys have been - // downloaded via the device list. - await this.downloadKeys([this._userId]); - - // Also check which private keys are locally cached. - const crossSigningPrivateKeys = - await this._crossSigningInfo.getCrossSigningKeysFromCache(); - - // If we see an update to our own master key, check it against the master - // key we have and, if it matches, mark it as verified - - // First, get the new cross-signing info - const newCrossSigning = this._deviceList.getStoredCrossSigningForUser(userId); - if (!newCrossSigning) { - logger.error( - "Got cross-signing update event for user " + userId + - " but no new cross-signing information found!", - ); - return; - } - - const seenPubkey = newCrossSigning.getId(); - const masterChanged = this._crossSigningInfo.getId() !== seenPubkey; - const masterExistsNotLocallyCached = - newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); - if (masterChanged) { - logger.info("Got new master public key", seenPubkey); - } - if ( - allowPrivateKeyRequests && - (masterChanged || masterExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing master private key"); - let signing = null; - // It's important for control flow that we leave any errors alone for - // higher levels to handle so that e.g. cancelling access properly - // aborts any larger operation as well. - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - 'master', seenPubkey, - ); - signing = ret[1]; - logger.info("Got cross-signing master private key"); - } finally { - if (signing) signing.free(); - } - } - - const oldSelfSigningId = this._crossSigningInfo.getId("self_signing"); - const oldUserSigningId = this._crossSigningInfo.getId("user_signing"); - - // Update the version of our keys in our cross-signing object and the local store - this._storeTrustedSelfKeys(newCrossSigning.keys); - - const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); - const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); - - const selfSigningExistsNotLocallyCached = ( - newCrossSigning.getId("self_signing") && - !crossSigningPrivateKeys.has("self_signing") - ); - const userSigningExistsNotLocallyCached = ( - newCrossSigning.getId("user_signing") && - !crossSigningPrivateKeys.has("user_signing") - ); - - const keySignatures = {}; - - if (selfSigningChanged) { - logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); - } - if ( - allowPrivateKeyRequests && - (selfSigningChanged || selfSigningExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing self-signing private key"); - let signing = null; - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - "self_signing", newCrossSigning.getId("self_signing"), - ); - signing = ret[1]; - logger.info("Got cross-signing self-signing private key"); - } finally { - if (signing) signing.free(); - } - - const device = this._deviceList.getStoredDevice(this._userId, this._deviceId); - const signedDevice = await this._crossSigningInfo.signDevice( - this._userId, device, - ); - keySignatures[this._deviceId] = signedDevice; - } - if (userSigningChanged) { - logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); - } - if ( - allowPrivateKeyRequests && - (userSigningChanged || userSigningExistsNotLocallyCached) - ) { - logger.info("Attempting to retrieve cross-signing user-signing private key"); - let signing = null; - try { - const ret = await this._crossSigningInfo.getCrossSigningKey( - "user_signing", newCrossSigning.getId("user_signing"), - ); - signing = ret[1]; - logger.info("Got cross-signing user-signing private key"); - } finally { - if (signing) signing.free(); - } - } - - if (masterChanged) { - const masterKey = this._crossSigningInfo.keys.master; - await this._signObject(masterKey); - const deviceSig = masterKey.signatures[this._userId]["ed25519:" + this._deviceId]; - // Include only the _new_ device signature in the upload. - // We may have existing signatures from deleted devices, which will cause - // the entire upload to fail. - keySignatures[this._crossSigningInfo.getId()] = Object.assign( - {}, - masterKey, - { - signatures: { - [this._userId]: { - ["ed25519:" + this._deviceId]: deviceSig, - }, - }, - }, - ); - } - - const keysToUpload = Object.keys(keySignatures); - if (keysToUpload.length) { - const upload = ({ shouldEmit }) => { - logger.info(`Starting background key sig upload for ${keysToUpload}`); - return this._baseApis.uploadKeySignatures({ [this._userId]: keySignatures }) - .then((response) => { - const { failures } = response || {}; - logger.info(`Finished background key sig upload for ${keysToUpload}`); - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "checkOwnCrossSigningTrust", - upload, - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }).catch(e => { - logger.error( - `Error during background key sig upload for ${keysToUpload}`, - e, - ); - }); - }; - upload({ shouldEmit: true }); - } - - this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); - - if (masterChanged) { - this._baseApis.emit("crossSigning.keysChanged", {}); - await this._afterCrossSigningLocalKeyChange(); - } - - // Now we may be able to trust our key backup - await this._backupManager.checkKeyBackup(); - // FIXME: if we previously trusted the backup, should we automatically sign - // the backup with the new key (if not already signed)? -}; - -/** - * Store a set of keys as our own, trusted, cross-signing keys. - * - * @param {object} keys The new trusted set of keys - */ -Crypto.prototype._storeTrustedSelfKeys = async function(keys) { - if (keys) { - this._crossSigningInfo.setKeys(keys); - } else { - this._crossSigningInfo.clearKeys(); - } - await this._cryptoStore.doTxn( - 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], - (txn) => { - this._cryptoStore.storeCrossSigningKeys(txn, this._crossSigningInfo.keys); - }, - ); -}; - -/** - * Check if the master key is signed by a verified device, and if so, prompt - * the application to mark it as verified. - * - * @param {string} userId the user ID whose key should be checked - */ -Crypto.prototype._checkDeviceVerifications = async function(userId) { - const shouldUpgradeCb = ( - this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications - ); - if (!shouldUpgradeCb) { - // Upgrading skipped when callback is not present. - return; - } - logger.info(`Starting device verification upgrade for ${userId}`); - if (this._crossSigningInfo.keys.user_signing) { - const crossSigningInfo = this._deviceList.getStoredCrossSigningForUser(userId); - if (crossSigningInfo) { - const upgradeInfo = await this._checkForDeviceVerificationUpgrade( - userId, crossSigningInfo, - ); - if (upgradeInfo) { - const usersToUpgrade = await shouldUpgradeCb({ - users: { - [userId]: upgradeInfo, - }, - }); - if (usersToUpgrade.includes(userId)) { - await this._baseApis.setDeviceVerified( - userId, crossSigningInfo.getId(), - ); - } - } - } - } - logger.info(`Finished device verification upgrade for ${userId}`); -}; - -Crypto.prototype.setTrustedBackupPubKey = async function(trustedPubKey) { - // This should be redundant post cross-signing is a thing, so just - // plonk it in localStorage for now. - this._sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); - await this._backupManager.checkKeyBackup(); -}; - -/** - */ -Crypto.prototype.enableLazyLoading = function() { - this._lazyLoadMembers = true; -}; - -/** - * Tell the crypto module to register for MatrixClient events which it needs to - * listen for - * - * @param {external:EventEmitter} eventEmitter event source where we can register - * for event notifications - */ -Crypto.prototype.registerEventHandlers = function(eventEmitter) { - const crypto = this; - - eventEmitter.on("RoomMember.membership", function(event, member, oldMembership) { - try { - crypto._onRoomMembership(event, member, oldMembership); - } catch (e) { - logger.error("Error handling membership change:", e); - } - }); - - eventEmitter.on("toDeviceEvent", crypto._onToDeviceEvent.bind(crypto)); - - const timelineHandler = crypto._onTimelineEvent.bind(crypto); - - eventEmitter.on("Room.timeline", timelineHandler); - - eventEmitter.on("Event.decrypted", timelineHandler); -}; - -/** Start background processes related to crypto */ -Crypto.prototype.start = function() { - this._outgoingRoomKeyRequestManager.start(); -}; - -/** Stop background processes related to crypto */ -Crypto.prototype.stop = function() { - this._outgoingRoomKeyRequestManager.stop(); - this._deviceList.stop(); - this._dehydrationManager.stop(); -}; - -/** - * @return {string} The version of Olm. - */ -Crypto.getOlmVersion = function() { - return OlmDevice.getOlmVersion(); -}; - -/** - * Get the Ed25519 key for this device - * - * @return {string} base64-encoded ed25519 key. - */ -Crypto.prototype.getDeviceEd25519Key = function() { - return this._olmDevice.deviceEd25519Key; -}; - -/** - * Get the Curve25519 key for this device - * - * @return {string} base64-encoded curve25519 key. - */ -Crypto.prototype.getDeviceCurve25519Key = function() { - return this._olmDevice.deviceCurve25519Key; -}; - -/** - * Set the global override for whether the client should ever send encrypted - * messages to unverified devices. This provides the default for rooms which - * do not specify a value. - * - * @param {boolean} value whether to blacklist all unverified devices by default - */ -Crypto.prototype.setGlobalBlacklistUnverifiedDevices = function(value) { - this._globalBlacklistUnverifiedDevices = value; -}; - -/** - * @return {boolean} whether to blacklist all unverified devices by default - */ -Crypto.prototype.getGlobalBlacklistUnverifiedDevices = function() { - return this._globalBlacklistUnverifiedDevices; -}; - -/** - * Set whether sendMessage in a room with unknown and unverified devices - * should throw an error and not send them message. This has 'Global' for - * symmertry with setGlobalBlacklistUnverifiedDevices but there is currently - * no room-level equivalent for this setting. - * - * This API is currently UNSTABLE and may change or be removed without notice. - * - * @param {boolean} value whether error on unknown devices - */ -Crypto.prototype.setGlobalErrorOnUnknownDevices = function(value) { - this._globalErrorOnUnknownDevices = value; -}; - -/** - * @return {boolean} whether to error on unknown devices - * - * This API is currently UNSTABLE and may change or be removed without notice. - */ -Crypto.prototype.getGlobalErrorOnUnknownDevices = function() { - return this._globalErrorOnUnknownDevices; -}; - -/** - * Upload the device keys to the homeserver. - * @return {object} A promise that will resolve when the keys are uploaded. - */ -Crypto.prototype.uploadDeviceKeys = function() { - const crypto = this; - const userId = crypto._userId; - const deviceId = crypto._deviceId; - - const deviceKeys = { - algorithms: crypto._supportedAlgorithms, - device_id: deviceId, - keys: crypto._deviceKeys, - user_id: userId, - }; - - return crypto._signObject(deviceKeys).then(() => { - return crypto._baseApis.uploadKeysRequest({ - device_keys: deviceKeys, - }); - }); -}; - -/** - * Stores the current one_time_key count which will be handled later (in a call of - * onSyncCompleted). The count is e.g. coming from a /sync response. - * - * @param {Number} currentCount The current count of one_time_keys to be stored - */ -Crypto.prototype.updateOneTimeKeyCount = function(currentCount) { - if (isFinite(currentCount)) { - this._oneTimeKeyCount = currentCount; - } else { - throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); - } -}; - -Crypto.prototype.setNeedsNewFallback = function(needsNewFallback) { - this._needsNewFallback = !!needsNewFallback; -}; - -Crypto.prototype.getNeedsNewFallback = function() { - return this._needsNewFallback; -}; - -// check if it's time to upload one-time keys, and do so if so. -function _maybeUploadOneTimeKeys(crypto) { - // frequency with which to check & upload one-time keys - const uploadPeriod = 1000 * 60; // one minute - - // max number of keys to upload at once - // Creating keys can be an expensive operation so we limit the - // number we generate in one go to avoid blocking the application - // for too long. - const maxKeysPerCycle = 5; - - if (crypto._oneTimeKeyCheckInProgress) { - return; - } - - const now = Date.now(); - if (crypto._lastOneTimeKeyCheck !== null && - now - crypto._lastOneTimeKeyCheck < uploadPeriod - ) { - // we've done a key upload recently. - return; - } - - crypto._lastOneTimeKeyCheck = now; - - // We need to keep a pool of one time public keys on the server so that - // other devices can start conversations with us. But we can only store - // a finite number of private keys in the olm Account object. - // To complicate things further then can be a delay between a device - // claiming a public one time key from the server and it sending us a - // message. We need to keep the corresponding private key locally until - // we receive the message. - // But that message might never arrive leaving us stuck with duff - // private keys clogging up our local storage. - // So we need some kind of enginering compromise to balance all of - // these factors. - - // Check how many keys we can store in the Account object. - const maxOneTimeKeys = crypto._olmDevice.maxNumberOfOneTimeKeys(); - // Try to keep at most half that number on the server. This leaves the - // rest of the slots free to hold keys that have been claimed from the - // server but we haven't recevied a message for. - // If we run out of slots when generating new keys then olm will - // discard the oldest private keys first. This will eventually clean - // out stale private keys that won't receive a message. - const keyLimit = Math.floor(maxOneTimeKeys / 2); - - async function uploadLoop(keyCount) { - while (keyLimit > keyCount || crypto.getNeedsNewFallback()) { - // Ask olm to generate new one time keys, then upload them to synapse. - if (keyLimit > keyCount) { - logger.info("generating oneTimeKeys"); - const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); - await crypto._olmDevice.generateOneTimeKeys(keysThisLoop); - } - - if (crypto.getNeedsNewFallback()) { - logger.info("generating fallback key"); - await crypto._olmDevice.generateFallbackKey(); - } - - logger.info("calling _uploadOneTimeKeys"); - const res = await _uploadOneTimeKeys(crypto); - if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { - // if the response contains a more up to date value use this - // for the next loop - keyCount = res.one_time_key_counts.signed_curve25519; - } else { - throw new Error("response for uploading keys does not contain " + - "one_time_key_counts.signed_curve25519"); - } - } - } - - crypto._oneTimeKeyCheckInProgress = true; - Promise.resolve().then(() => { - if (crypto._oneTimeKeyCount !== undefined) { - // We already have the current one_time_key count from a /sync response. - // Use this value instead of asking the server for the current key count. - return Promise.resolve(crypto._oneTimeKeyCount); - } - // ask the server how many keys we have - return crypto._baseApis.uploadKeysRequest({}).then((res) => { - return res.one_time_key_counts.signed_curve25519 || 0; - }); - }).then((keyCount) => { - // Start the uploadLoop with the current keyCount. The function checks if - // we need to upload new keys or not. - // If there are too many keys on the server then we don't need to - // create any more keys. - return uploadLoop(keyCount); - }).catch((e) => { - logger.error("Error uploading one-time keys", e.stack || e); - }).finally(() => { - // reset _oneTimeKeyCount to prevent start uploading based on old data. - // it will be set again on the next /sync-response - crypto._oneTimeKeyCount = undefined; - crypto._oneTimeKeyCheckInProgress = false; - }); -} - -// returns a promise which resolves to the response -async function _uploadOneTimeKeys(crypto) { - const promises = []; - - const fallbackJson = {}; - if (crypto.getNeedsNewFallback()) { - const fallbackKeys = await crypto._olmDevice.getFallbackKey(); - for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { - const k = { key, fallback: true }; - fallbackJson["signed_curve25519:" + keyId] = k; - promises.push(crypto._signObject(k)); - } - crypto.setNeedsNewFallback(false); - } - - const oneTimeKeys = await crypto._olmDevice.getOneTimeKeys(); - const oneTimeJson = {}; - - for (const keyId in oneTimeKeys.curve25519) { - if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { - const k = { - key: oneTimeKeys.curve25519[keyId], - }; - oneTimeJson["signed_curve25519:" + keyId] = k; - promises.push(crypto._signObject(k)); - } - } - - await Promise.all(promises); - - const res = await crypto._baseApis.uploadKeysRequest({ - "one_time_keys": oneTimeJson, - "org.matrix.msc2732.fallback_keys": fallbackJson, - }); - - await crypto._olmDevice.markKeysAsPublished(); - return res; -} - -/** - * Download the keys for a list of users and stores the keys in the session - * store. - * @param {Array} userIds The users to fetch. - * @param {bool} forceDownload Always download the keys even if cached. - * - * @return {Promise} A promise which resolves to a map userId->deviceId->{@link - * module:crypto/deviceinfo|DeviceInfo}. - */ -Crypto.prototype.downloadKeys = function(userIds, forceDownload) { - return this._deviceList.downloadKeys(userIds, forceDownload); -}; - -/** - * Get the stored device keys for a user id - * - * @param {string} userId the user to list keys for. - * - * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't - * managed to get a list of devices for this user yet. - */ -Crypto.prototype.getStoredDevicesForUser = function(userId) { - return this._deviceList.getStoredDevicesForUser(userId); -}; - -/** - * Get the stored keys for a single device - * - * @param {string} userId - * @param {string} deviceId - * - * @return {module:crypto/deviceinfo?} device, or undefined - * if we don't know about this device - */ -Crypto.prototype.getStoredDevice = function(userId, deviceId) { - return this._deviceList.getStoredDevice(userId, deviceId); -}; - -/** - * Save the device list, if necessary - * - * @param {integer} delay Time in ms before which the save actually happens. - * By default, the save is delayed for a short period in order to batch - * multiple writes, but this behaviour can be disabled by passing 0. - * - * @return {Promise} true if the data was saved, false if - * it was not (eg. because no changes were pending). The promise - * will only resolve once the data is saved, so may take some time - * to resolve. - */ -Crypto.prototype.saveDeviceList = function(delay) { - return this._deviceList.saveIfDirty(delay); -}; - -/** - * Update the blocked/verified state of the given device - * - * @param {string} userId owner of the device - * @param {string} deviceId unique identifier for the device or user's - * cross-signing public key ID. - * - * @param {?boolean} verified whether to mark the device as verified. Null to - * leave unchanged. - * - * @param {?boolean} blocked whether to mark the device as blocked. Null to - * leave unchanged. - * - * @param {?boolean} known whether to mark that the user has been made aware of - * the existence of this device. Null to leave unchanged - * - * @return {Promise} updated DeviceInfo - */ -Crypto.prototype.setDeviceVerification = async function( - userId, deviceId, verified, blocked, known, -) { - // get rid of any `undefined`s here so we can just check - // for null rather than null or undefined - if (verified === undefined) verified = null; - if (blocked === undefined) blocked = null; - if (known === undefined) known = null; - - // Check if the 'device' is actually a cross signing key - // The js-sdk's verification treats cross-signing keys as devices - // and so uses this method to mark them verified. - const xsk = this._deviceList.getStoredCrossSigningForUser(userId); - if (xsk && xsk.getId() === deviceId) { - if (blocked !== null || known !== null) { - throw new Error("Cannot set blocked or known for a cross-signing key"); - } - if (!verified) { - throw new Error("Cannot set a cross-signing key as unverified"); - } - - if (!this._crossSigningInfo.getId() && userId === this._crossSigningInfo.userId) { - this._storeTrustedSelfKeys(xsk.keys); - // This will cause our own user trust to change, so emit the event - this.emit( - "userTrustStatusChanged", this._userId, this.checkUserTrust(userId), - ); - } - - // Now sign the master key with our user signing key (unless it's ourself) - if (userId !== this._userId) { - logger.info( - "Master key " + xsk.getId() + " for " + userId + - " marked verified. Signing...", - ); - const device = await this._crossSigningInfo.signUser(xsk); - if (device) { - const upload = async ({ shouldEmit }) => { - logger.info("Uploading signature for " + userId + "..."); - const response = await this._baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "setDeviceVerification", - upload, - ); - } - /* Throwing here causes the process to be cancelled and the other - * user to be notified */ - throw new KeySignatureUploadError( - "Key upload failed", - { failures }, - ); - } - }; - await upload({ shouldEmit: true }); - - // This will emit events when it comes back down the sync - // (we could do local echo to speed things up) - } - return device; - } else { - return xsk; - } - } - - const devices = this._deviceList.getRawStoredDevicesForUser(userId); - if (!devices || !devices[deviceId]) { - throw new Error("Unknown device " + userId + ":" + deviceId); - } - - const dev = devices[deviceId]; - let verificationStatus = dev.verified; - - if (verified) { - verificationStatus = DeviceVerification.VERIFIED; - } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - if (blocked) { - verificationStatus = DeviceVerification.BLOCKED; - } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { - verificationStatus = DeviceVerification.UNVERIFIED; - } - - let knownStatus = dev.known; - if (known !== null) { - knownStatus = known; - } - - if (dev.verified !== verificationStatus || dev.known !== knownStatus) { - dev.verified = verificationStatus; - dev.known = knownStatus; - this._deviceList.storeDevicesForUser(userId, devices); - this._deviceList.saveIfDirty(); - } - - // do cross-signing - if (verified && userId === this._userId) { - logger.info("Own device " + deviceId + " marked verified: signing"); - - // Signing only needed if other device not already signed - let device; - const deviceTrust = this.checkDeviceTrust(userId, deviceId); - if (deviceTrust.isCrossSigningVerified()) { - logger.log(`Own device ${deviceId} already cross-signing verified`); - } else { - device = await this._crossSigningInfo.signDevice( - userId, DeviceInfo.fromStorage(dev, deviceId), - ); - } - - if (device) { - const upload = async ({ shouldEmit }) => { - logger.info("Uploading signature for " + deviceId); - const response = await this._baseApis.uploadKeySignatures({ - [userId]: { - [deviceId]: device, - }, - }); - const { failures } = response || {}; - if (Object.keys(failures || []).length > 0) { - if (shouldEmit) { - this._baseApis.emit( - "crypto.keySignatureUploadFailure", - failures, - "setDeviceVerification", - upload, // continuation - ); - } - throw new KeySignatureUploadError("Key upload failed", { failures }); - } - }; - await upload({ shouldEmit: true }); - // XXX: we'll need to wait for the device list to be updated - } - } - - const deviceObj = DeviceInfo.fromStorage(dev, deviceId); - this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); - return deviceObj; -}; - -Crypto.prototype.findVerificationRequestDMInProgress = function(roomId) { - return this._inRoomVerificationRequests.findRequestInProgress(roomId); -}; - -Crypto.prototype.getVerificationRequestsToDeviceInProgress = function(userId) { - return this._toDeviceVerificationRequests.getRequestsInProgress(userId); -}; - -Crypto.prototype.requestVerificationDM = function(userId, roomId) { - const existingRequest = this._inRoomVerificationRequests. - findRequestInProgress(roomId); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new InRoomChannel(this._baseApis, roomId, userId); - return this._requestVerificationWithChannel( - userId, - channel, - this._inRoomVerificationRequests, - ); -}; - -Crypto.prototype.requestVerification = function(userId, devices) { - if (!devices) { - devices = Object.keys(this._deviceList.getRawStoredDevicesForUser(userId)); - } - const existingRequest = this._toDeviceVerificationRequests - .findRequestInProgress(userId, devices); - if (existingRequest) { - return Promise.resolve(existingRequest); - } - const channel = new ToDeviceChannel(this._baseApis, userId, devices, - ToDeviceChannel.makeTransactionId()); - return this._requestVerificationWithChannel( - userId, - channel, - this._toDeviceVerificationRequests, - ); -}; - -Crypto.prototype._requestVerificationWithChannel = async function( - userId, channel, requestsMap, -) { - let request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - // if transaction id is already known, add request - if (channel.transactionId) { - requestsMap.setRequestByChannel(channel, request); - } - await request.sendRequest(); - // don't replace the request created by a racing remote echo - const racingRequest = requestsMap.getRequestByChannel(channel); - if (racingRequest) { - request = racingRequest; - } else { - logger.log(`Crypto: adding new request to ` + - `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); - requestsMap.setRequestByChannel(channel, request); - } - return request; -}; - -Crypto.prototype.beginKeyVerification = function( - method, userId, deviceId, transactionId = null, -) { - let request; - if (transactionId) { - request = this._toDeviceVerificationRequests.getRequestBySenderAndTxnId( - userId, transactionId); - if (!request) { - throw new Error( - `No request found for user ${userId} with ` + - `transactionId ${transactionId}`); - } - } else { - transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel( - this._baseApis, userId, [deviceId], transactionId, deviceId); - request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( - userId, transactionId, request); - } - return request.beginKeyVerification(method, { userId, deviceId }); -}; - -Crypto.prototype.legacyDeviceVerification = async function( - userId, deviceId, method, -) { - const transactionId = ToDeviceChannel.makeTransactionId(); - const channel = new ToDeviceChannel( - this._baseApis, userId, [deviceId], transactionId, deviceId); - const request = new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - this._toDeviceVerificationRequests.setRequestBySenderAndTxnId( - userId, transactionId, request); - const verifier = request.beginKeyVerification(method, { userId, deviceId }); - // either reject by an error from verify() while sending .start - // or resolve when the request receives the - // local (fake remote) echo for sending the .start event - await Promise.race([ - verifier.verify(), - request.waitFor(r => r.started), - ]); - return request; -}; - -/** - * Get information on the active olm sessions with a user - *

- * Returns a map from device id to an object with keys 'deviceIdKey' (the - * device's curve25519 identity key) and 'sessions' (an array of objects in the - * same format as that returned by - * {@link module:crypto/OlmDevice#getSessionInfoForDevice}). - *

- * This method is provided for debugging purposes. - * - * @param {string} userId id of user to inspect - * - * @return {Promise>} - */ -Crypto.prototype.getOlmSessionsForUser = async function(userId) { - const devices = this.getStoredDevicesForUser(userId) || []; - const result = {}; - for (let j = 0; j < devices.length; ++j) { - const device = devices[j]; - const deviceKey = device.getIdentityKey(); - const sessions = await this._olmDevice.getSessionInfoForDevice(deviceKey); - - result[device.deviceId] = { - deviceIdKey: deviceKey, - sessions: sessions, - }; - } - return result; -}; - -/** - * Get the device which sent an event - * - * @param {module:models/event.MatrixEvent} event event to be checked - * - * @return {module:crypto/deviceinfo?} - */ -Crypto.prototype.getEventSenderDeviceInfo = function(event) { - const senderKey = event.getSenderKey(); - const algorithm = event.getWireContent().algorithm; - - if (!senderKey || !algorithm) { - return null; - } - - const forwardingChain = event.getForwardingCurve25519KeyChain(); - if (forwardingChain.length > 0) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - return null; - } - - if (event.isKeySourceUntrusted()) { - // we got the key for this event from a source that we consider untrusted - return null; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - const device = this._deviceList.getDeviceByIdentityKey( - algorithm, senderKey, - ); - - if (device === null) { - // we haven't downloaded the details of this device yet. - return null; - } - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + - "cannot verify sending device"); - return null; - } - - if (claimedKey !== device.getFingerprint()) { - logger.warn( - "Event " + event.getId() + " claims ed25519 key " + claimedKey + - " but sender device has key " + device.getFingerprint()); - return null; - } - - return device; -}; - -/** - * Get information about the encryption of an event - * - * @param {module:models/event.MatrixEvent} event event to be checked - * - * @return {object} An object with the fields: - * - encrypted: whether the event is encrypted (if not encrypted, some of the - * other properties may not be set) - * - senderKey: the sender's key - * - algorithm: the algorithm used to encrypt the event - * - authenticated: whether we can be sure that the owner of the senderKey - * sent the event - * - sender: the sender's device information, if available - * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match - * (only meaningful if `sender` is set) - */ -Crypto.prototype.getEventEncryptionInfo = function(event) { - const ret = {}; - - ret.senderKey = event.getSenderKey(); - ret.algorithm = event.getWireContent().algorithm; - - if (!ret.senderKey || !ret.algorithm) { - ret.encrypted = false; - return ret; - } - ret.encrypted = true; - - const forwardingChain = event.getForwardingCurve25519KeyChain(); - if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) { - // we got the key this event from somewhere else - // TODO: check if we can trust the forwarders. - ret.authenticated = false; - } else { - ret.authenticated = true; - } - - // senderKey is the Curve25519 identity key of the device which the event - // was sent from. In the case of Megolm, it's actually the Curve25519 - // identity key of the device which set up the Megolm session. - - ret.sender = this._deviceList.getDeviceByIdentityKey( - ret.algorithm, ret.senderKey, - ); - - // so far so good, but now we need to check that the sender of this event - // hadn't advertised someone else's Curve25519 key as their own. We do that - // by checking the Ed25519 claimed by the event (or, in the case of megolm, - // the event which set up the megolm session), to check that it matches the - // fingerprint of the purported sending device. - // - // (see https://github.com/vector-im/vector-web/issues/2215) - - const claimedKey = event.getClaimedEd25519Key(); - if (!claimedKey) { - logger.warn("Event " + event.getId() + " claims no ed25519 key: " + - "cannot verify sending device"); - ret.mismatchedSender = true; - } - - if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { - logger.warn( - "Event " + event.getId() + " claims ed25519 key " + claimedKey + - "but sender device has key " + ret.sender.getFingerprint()); - ret.mismatchedSender = true; - } - - return ret; -}; - -/** - * Forces the current outbound group session to be discarded such - * that another one will be created next time an event is sent. - * - * @param {string} roomId The ID of the room to discard the session for - * - * This should not normally be necessary. - */ -Crypto.prototype.forceDiscardSession = function(roomId) { - const alg = this._roomEncryptors[roomId]; - if (alg === undefined) throw new Error("Room not encrypted"); - if (alg.forceDiscardSession === undefined) { - throw new Error("Room encryption algorithm doesn't support session discarding"); - } - alg.forceDiscardSession(); -}; - -/** - * Configure a room to use encryption (ie, save a flag in the cryptoStore). - * - * @param {string} roomId The room ID to enable encryption in. - * - * @param {object} config The encryption config for the room. - * - * @param {boolean=} inhibitDeviceQuery true to suppress device list query for - * users in the room (for now). In case lazy loading is enabled, - * the device query is always inhibited as the members are not tracked. - */ -Crypto.prototype.setRoomEncryption = async function(roomId, config, inhibitDeviceQuery) { - // ignore crypto events with no algorithm defined - // This will happen if a crypto event is redacted before we fetch the room state - // It would otherwise just throw later as an unknown algorithm would, but we may - // as well catch this here - if (!config.algorithm) { - logger.log("Ignoring setRoomEncryption with no algorithm"); - return; - } - - // if state is being replayed from storage, we might already have a configuration - // for this room as they are persisted as well. - // We just need to make sure the algorithm is initialized in this case. - // However, if the new config is different, - // we should bail out as room encryption can't be changed once set. - const existingConfig = this._roomList.getRoomEncryption(roomId); - if (existingConfig) { - if (JSON.stringify(existingConfig) != JSON.stringify(config)) { - logger.error("Ignoring m.room.encryption event which requests " + - "a change of config in " + roomId); - return; - } - } - // if we already have encryption in this room, we should ignore this event, - // as it would reset the encryption algorithm. - // This is at least expected to be called twice, as sync calls onCryptoEvent - // for both the timeline and state sections in the /sync response, - // the encryption event would appear in both. - // If it's called more than twice though, - // it signals a bug on client or server. - const existingAlg = this._roomEncryptors[roomId]; - if (existingAlg) { - return; - } - - // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption - // because it first stores in memory. We should await the promise only - // after all the in-memory state (_roomEncryptors and _roomList) has been updated - // to avoid races when calling this method multiple times. Hence keep a hold of the promise. - let storeConfigPromise = null; - if (!existingConfig) { - storeConfigPromise = this._roomList.setRoomEncryption(roomId, config); - } - - const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; - if (!AlgClass) { - throw new Error("Unable to encrypt with " + config.algorithm); - } - - const alg = new AlgClass({ - userId: this._userId, - deviceId: this._deviceId, - crypto: this, - olmDevice: this._olmDevice, - baseApis: this._baseApis, - roomId: roomId, - config: config, - }); - this._roomEncryptors[roomId] = alg; - - if (storeConfigPromise) { - await storeConfigPromise; - } - - if (!this._lazyLoadMembers) { - logger.log("Enabling encryption in " + roomId + "; " + - "starting to track device lists for all users therein"); - - await this.trackRoomDevices(roomId); - // TODO: this flag is only not used from MatrixClient::setRoomEncryption - // which is never used (inside Element at least) - // but didn't want to remove it as it technically would - // be a breaking change. - if (!this.inhibitDeviceQuery) { - this._deviceList.refreshOutdatedDeviceLists(); - } - } else { - logger.log("Enabling encryption in " + roomId); - } -}; - -/** - * Make sure we are tracking the device lists for all users in this room. - * - * @param {string} roomId The room ID to start tracking devices in. - * @returns {Promise} when all devices for the room have been fetched and marked to track - */ -Crypto.prototype.trackRoomDevices = function(roomId) { - const trackMembers = async () => { - // not an encrypted room - if (!this._roomEncryptors[roomId]) { - return; - } - const room = this._clientStore.getRoom(roomId); - if (!room) { - throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); - } - logger.log(`Starting to track devices for room ${roomId} ...`); - const members = await room.getEncryptionTargetMembers(); - members.forEach((m) => { - this._deviceList.startTrackingDeviceList(m.userId); - }); - }; - - let promise = this._roomDeviceTrackingState[roomId]; - if (!promise) { - promise = trackMembers(); - this._roomDeviceTrackingState[roomId] = promise.catch(err => { - this._roomDeviceTrackingState[roomId] = null; - throw err; - }); - } - return promise; -}; - -/** - * @typedef {Object} module:crypto~OlmSessionResult - * @property {module:crypto/deviceinfo} device device info - * @property {string?} sessionId base64 olm session id; null if no session - * could be established - */ - -/** - * Try to make sure we have established olm sessions for all known devices for - * the given users. - * - * @param {string[]} users list of user ids - * - * @return {Promise} resolves once the sessions are complete, to - * an Object mapping from userId to deviceId to - * {@link module:crypto~OlmSessionResult} - */ -Crypto.prototype.ensureOlmSessionsForUsers = function(users) { - const devicesByUser = {}; - - for (let i = 0; i < users.length; ++i) { - const userId = users[i]; - devicesByUser[userId] = []; - - const devices = this.getStoredDevicesForUser(userId) || []; - for (let j = 0; j < devices.length; ++j) { - const deviceInfo = devices[j]; - - const key = deviceInfo.getIdentityKey(); - if (key == this._olmDevice.deviceCurve25519Key) { - // don't bother setting up session to ourself - continue; - } - if (deviceInfo.verified == DeviceVerification.BLOCKED) { - // don't bother setting up sessions with blocked users - continue; - } - - devicesByUser[userId].push(deviceInfo); - } - } - - return olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, - ); -}; - -/** - * Get a list containing all of the room keys - * - * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects - */ -Crypto.prototype.exportRoomKeys = async function() { - const exportedSessions = []; - await this._cryptoStore.doTxn( - 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { - this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { - if (s === null) return; - - const sess = this._olmDevice.exportInboundGroupSession( - s.senderKey, s.sessionId, s.sessionData, - ); - delete sess.first_known_index; - sess.algorithm = olmlib.MEGOLM_ALGORITHM; - exportedSessions.push(sess); - }); - }, - ); - - return exportedSessions; -}; - -/** - * Import a list of room keys previously exported by exportRoomKeys - * - * @param {Object[]} keys a list of session export objects - * @param {Object} opts - * @param {Function} opts.progressCallback called with an object which has a stage param - * @return {Promise} a promise which resolves once the keys have been imported - */ -Crypto.prototype.importRoomKeys = function(keys, opts = {}) { - let successes = 0; - let failures = 0; - const total = keys.length; - - function updateProgress() { - opts.progressCallback({ - stage: "load_keys", - successes, - failures, - total, - }); - } - - return Promise.all(keys.map((key) => { - if (!key.room_id || !key.algorithm) { - logger.warn("ignoring room key entry with missing fields", key); - failures++; - if (opts.progressCallback) { updateProgress(); } - return null; - } - - const alg = this._getRoomDecryptor(key.room_id, key.algorithm); - return alg.importRoomKey(key, opts).finally((r) => { - successes++; - if (opts.progressCallback) { updateProgress(); } - }); - })); -}; - -/** - * Counts the number of end to end session keys that are waiting to be backed up - * @returns {Promise} Resolves to the number of sessions requiring backup - */ -Crypto.prototype.countSessionsNeedingBackup = function() { - return this._backupManager.countSessionsNeedingBackup(); -}; - -/** - * Perform any background tasks that can be done before a message is ready to - * send, in order to speed up sending of the message. - * - * @param {module:models/room} room the room the event is in - */ -Crypto.prototype.prepareToEncrypt = function(room) { - const roomId = room.roomId; - const alg = this._roomEncryptors[roomId]; - if (alg) { - alg.prepareToEncrypt(room); - } -}; - -/* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 -/** - * Encrypt an event according to the configuration of the room. - * - * @param {module:models/event.MatrixEvent} event event to be sent - * - * @param {module:models/room} room destination room. - * - * @return {Promise?} Promise which resolves when the event has been - * encrypted, or null if nothing was needed - */ -/* eslint-enable valid-jsdoc */ -Crypto.prototype.encryptEvent = async function(event, room) { - if (!room) { - throw new Error("Cannot send encrypted messages in unknown rooms"); - } - - const roomId = event.getRoomId(); - - const alg = this._roomEncryptors[roomId]; - if (!alg) { - // MatrixClient has already checked that this room should be encrypted, - // so this is an unexpected situation. - throw new Error( - "Room was previously configured to use encryption, but is " + - "no longer. Perhaps the homeserver is hiding the " + - "configuration event.", - ); - } - - if (!this._roomDeviceTrackingState[roomId]) { - this.trackRoomDevices(roomId); - } - // wait for all the room devices to be loaded - await this._roomDeviceTrackingState[roomId]; - - let content = event.getContent(); - // If event has an m.relates_to then we need - // to put this on the wrapping event instead - const mRelatesTo = content['m.relates_to']; - if (mRelatesTo) { - // Clone content here so we don't remove `m.relates_to` from the local-echo - content = Object.assign({}, content); - delete content['m.relates_to']; - } - - const encryptedContent = await alg.encryptMessage( - room, event.getType(), content); - - if (mRelatesTo) { - encryptedContent['m.relates_to'] = mRelatesTo; - } - - event.makeEncrypted( - "m.room.encrypted", - encryptedContent, - this._olmDevice.deviceCurve25519Key, - this._olmDevice.deviceEd25519Key, - ); -}; - -/** - * Decrypt a received event - * - * @param {MatrixEvent} event - * - * @return {Promise} resolves once we have - * finished decrypting. Rejects with an `algorithms.DecryptionError` if there - * is a problem decrypting the event. - */ -Crypto.prototype.decryptEvent = async function(event) { - if (event.isRedacted()) { - const redactionEvent = new MatrixEvent(event.getUnsigned().redacted_because); - const decryptedEvent = await this.decryptEvent(redactionEvent); - - return { - clearEvent: { - room_id: event.getRoomId(), - type: "m.room.message", - content: {}, - unsigned: { - redacted_because: decryptedEvent.clearEvent, - }, - }, - }; - } else { - const content = event.getWireContent(); - const alg = this._getRoomDecryptor(event.getRoomId(), content.algorithm); - return await alg.decryptEvent(event); - } -}; - -/** - * Handle the notification from /sync or /keys/changes that device lists have - * been changed. - * - * @param {Object} syncData Object containing sync tokens associated with this sync - * @param {Object} syncDeviceLists device_lists field from /sync, or response from - * /keys/changes - */ -Crypto.prototype.handleDeviceListChanges = async function(syncData, syncDeviceLists) { - // Initial syncs don't have device change lists. We'll either get the complete list - // of changes for the interval or will have invalidated everything in willProcessSync - if (!syncData.oldSyncToken) return; - - // Here, we're relying on the fact that we only ever save the sync data after - // sucessfully saving the device list data, so we're guaranteed that the device - // list store is at least as fresh as the sync token from the sync store, ie. - // any device changes received in sync tokens prior to the 'next' token here - // have been processed and are reflected in the current device list. - // If we didn't make this assumption, we'd have to use the /keys/changes API - // to get key changes between the sync token in the device list and the 'old' - // sync token used here to make sure we didn't miss any. - await this._evalDeviceListChanges(syncDeviceLists); -}; - -/** - * Send a request for some room keys, if we have not already done so - * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * @param {Array<{userId: string, deviceId: string}>} recipients - * @param {boolean} resend whether to resend the key request if there is - * already one - * - * @return {Promise} a promise that resolves when the key request is queued - */ -Crypto.prototype.requestRoomKey = function(requestBody, recipients, resend=false) { - return this._outgoingRoomKeyRequestManager.queueRoomKeyRequest( - requestBody, recipients, resend, - ).then(() => { - if (this._sendKeyRequestsImmediately) { - this._outgoingRoomKeyRequestManager.sendQueuedRequests(); - } - }).catch((e) => { - // this normally means we couldn't talk to the store - logger.error( - 'Error requesting key for event', e, - ); - }); -}; - -/** - * Cancel any earlier room key request - * - * @param {module:crypto~RoomKeyRequestBody} requestBody - * parameters to match for cancellation - */ -Crypto.prototype.cancelRoomKeyRequest = function(requestBody) { - this._outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) - .catch((e) => { - logger.warn("Error clearing pending room key requests", e); - }); -}; - -/** - * Re-send any outgoing key requests, eg after verification - * @returns {Promise} - */ -Crypto.prototype.cancelAndResendAllOutgoingKeyRequests = function() { - return this._outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); -}; - -/** - * handle an m.room.encryption event - * - * @param {module:models/event.MatrixEvent} event encryption event - */ -Crypto.prototype.onCryptoEvent = async function(event) { - const roomId = event.getRoomId(); - const content = event.getContent(); - - try { - // inhibit the device list refresh for now - it will happen once we've - // finished processing the sync, in onSyncCompleted. - await this.setRoomEncryption(roomId, content, true); - } catch (e) { - logger.error("Error configuring encryption in room " + roomId + - ":", e); - } -}; - -/** - * Called before the result of a sync is procesed - * - * @param {Object} syncData the data from the 'MatrixClient.sync' event - */ -Crypto.prototype.onSyncWillProcess = async function(syncData) { - if (!syncData.oldSyncToken) { - // If there is no old sync token, we start all our tracking from - // scratch, so mark everything as untracked. onCryptoEvent will - // be called for all e2e rooms during the processing of the sync, - // at which point we'll start tracking all the users of that room. - logger.log("Initial sync performed - resetting device tracking state"); - this._deviceList.stopTrackingAllDeviceLists(); - // we always track our own device list (for key backups etc) - this._deviceList.startTrackingDeviceList(this._userId); - this._roomDeviceTrackingState = {}; - } - - this._sendKeyRequestsImmediately = false; -}; - -/** - * handle the completion of a /sync - * - * This is called after the processing of each successful /sync response. - * It is an opportunity to do a batch process on the information received. - * - * @param {Object} syncData the data from the 'MatrixClient.sync' event - */ -Crypto.prototype.onSyncCompleted = async function(syncData) { - const nextSyncToken = syncData.nextSyncToken; - - this._deviceList.setSyncToken(syncData.nextSyncToken); - this._deviceList.saveIfDirty(); - - // catch up on any new devices we got told about during the sync. - this._deviceList.lastKnownSyncToken = nextSyncToken; - - // we always track our own device list (for key backups etc) - this._deviceList.startTrackingDeviceList(this._userId); - - this._deviceList.refreshOutdatedDeviceLists(); - - // we don't start uploading one-time keys until we've caught up with - // to-device messages, to help us avoid throwing away one-time-keys that we - // are about to receive messages for - // (https://github.com/vector-im/element-web/issues/2782). - if (!syncData.catchingUp) { - _maybeUploadOneTimeKeys(this); - this._processReceivedRoomKeyRequests(); - - // likewise don't start requesting keys until we've caught up - // on to_device messages, otherwise we'll request keys that we're - // just about to get. - this._outgoingRoomKeyRequestManager.sendQueuedRequests(); - - // Sync has finished so send key requests straight away. - this._sendKeyRequestsImmediately = true; - } -}; - -/** - * Trigger the appropriate invalidations and removes for a given - * device list - * - * @param {Object} deviceLists device_lists field from /sync, or response from - * /keys/changes - */ -Crypto.prototype._evalDeviceListChanges = async function(deviceLists) { - if (deviceLists.changed && Array.isArray(deviceLists.changed)) { - deviceLists.changed.forEach((u) => { - this._deviceList.invalidateUserDeviceList(u); - }); - } - - if (deviceLists.left && Array.isArray(deviceLists.left) && - deviceLists.left.length) { - // Check we really don't share any rooms with these users - // any more: the server isn't required to give us the - // exact correct set. - const e2eUserIds = new Set(await this._getTrackedE2eUsers()); - - deviceLists.left.forEach((u) => { - if (!e2eUserIds.has(u)) { - this._deviceList.stopTrackingDeviceList(u); - } - }); - } -}; - -/** - * Get a list of all the IDs of users we share an e2e room with - * for which we are tracking devices already - * - * @returns {string[]} List of user IDs - */ -Crypto.prototype._getTrackedE2eUsers = async function() { - const e2eUserIds = []; - for (const room of this._getTrackedE2eRooms()) { - const members = await room.getEncryptionTargetMembers(); - for (const member of members) { - e2eUserIds.push(member.userId); - } - } - return e2eUserIds; -}; - -/** - * Get a list of the e2e-enabled rooms we are members of, - * and for which we are already tracking the devices - * - * @returns {module:models.Room[]} - */ -Crypto.prototype._getTrackedE2eRooms = function() { - return this._clientStore.getRooms().filter((room) => { - // check for rooms with encryption enabled - const alg = this._roomEncryptors[room.roomId]; - if (!alg) { - return false; - } - if (!this._roomDeviceTrackingState[room.roomId]) { - return false; - } - - // ignore any rooms which we have left - const myMembership = room.getMyMembership(); - return myMembership === "join" || myMembership === "invite"; - }); -}; - -Crypto.prototype._onToDeviceEvent = function(event) { - try { - logger.log(`received to_device ${event.getType()} from: ` + - `${event.getSender()} id: ${event.getId()}`); - - if (event.getType() == "m.room_key" - || event.getType() == "m.forwarded_room_key") { - this._onRoomKeyEvent(event); - } else if (event.getType() == "m.room_key_request") { - this._onRoomKeyRequestEvent(event); - } else if (event.getType() === "m.secret.request") { - this._secretStorage._onRequestReceived(event); - } else if (event.getType() === "m.secret.send") { - this._secretStorage._onSecretReceived(event); - } else if (event.getType() === "org.matrix.room_key.withheld") { - this._onRoomKeyWithheldEvent(event); - } else if (event.getContent().transaction_id) { - this._onKeyVerificationMessage(event); - } else if (event.getContent().msgtype === "m.bad.encrypted") { - this._onToDeviceBadEncrypted(event); - } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - if (!event.isBeingDecrypted()) { - event.attemptDecryption(this); - } - // once the event has been decrypted, try again - event.once('Event.decrypted', (ev) => { - this._onToDeviceEvent(ev); - }); - } - } catch (e) { - logger.error("Error handling toDeviceEvent:", e); - } -}; - -/** - * Handle a key event - * - * @private - * @param {module:models/event.MatrixEvent} event key event - */ -Crypto.prototype._onRoomKeyEvent = function(event) { - const content = event.getContent(); - - if (!content.room_id || !content.algorithm) { - logger.error("key event is missing fields"); - return; - } - - if (!this._backupManager.checkedForBackup) { - // don't bother awaiting on this - the important thing is that we retry if we - // haven't managed to check before - this._backupManager.checkAndStart(); - } - - const alg = this._getRoomDecryptor(content.room_id, content.algorithm); - alg.onRoomKeyEvent(event); -}; - -/** - * Handle a key withheld event - * - * @private - * @param {module:models/event.MatrixEvent} event key withheld event - */ -Crypto.prototype._onRoomKeyWithheldEvent = function(event) { - const content = event.getContent(); - - if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) - || !content.algorithm || !content.sender_key) { - logger.error("key withheld event is missing fields"); - return; - } - - logger.info( - `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` - + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` - + `with reason ${content.code} (${content.reason})`, - ); - - const alg = this._getRoomDecryptor(content.room_id, content.algorithm); - if (alg.onRoomKeyWithheldEvent) { - alg.onRoomKeyWithheldEvent(event); - } - if (!content.room_id) { - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const roomDecryptors = this._getRoomDecryptors(content.algorithm); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(content.sender_key); - } - } -}; - -/** - * Handle a general key verification event. - * - * @private - * @param {module:models/event.MatrixEvent} event verification start event - */ -Crypto.prototype._onKeyVerificationMessage = function(event) { - if (!ToDeviceChannel.validateEvent(event, this._baseApis)) { - return; - } - const createRequest = event => { - if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { - return; - } - const content = event.getContent(); - const deviceId = content && content.from_device; - if (!deviceId) { - return; - } - const userId = event.getSender(); - const channel = new ToDeviceChannel( - this._baseApis, - userId, - [deviceId], - ); - return new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - }; - this._handleVerificationEvent( - event, - this._toDeviceVerificationRequests, - createRequest, - ); -}; - -/** - * Handle key verification requests sent as timeline events - * - * @private - * @param {module:models/event.MatrixEvent} event the timeline event - * @param {module:models/Room} room not used - * @param {bool} atStart not used - * @param {bool} removed not used - * @param {bool} data.liveEvent whether this is a live event - */ -Crypto.prototype._onTimelineEvent = function( - event, room, atStart, removed, { liveEvent } = {}, -) { - if (!InRoomChannel.validateEvent(event, this._baseApis)) { - return; - } - const createRequest = event => { - const channel = new InRoomChannel( - this._baseApis, - event.getRoomId(), - ); - return new VerificationRequest( - channel, this._verificationMethods, this._baseApis); - }; - this._handleVerificationEvent( - event, - this._inRoomVerificationRequests, - createRequest, - liveEvent, - ); -}; - -Crypto.prototype._handleVerificationEvent = async function( - event, requestsMap, createRequest, isLiveEvent = true, -) { - let request = requestsMap.getRequest(event); - let isNewRequest = false; - if (!request) { - request = createRequest(event); - // a request could not be made from this event, so ignore event - if (!request) { - logger.log(`Crypto: could not find VerificationRequest for ` + - `${event.getType()}, and could not create one, so ignoring.`); - return; - } - isNewRequest = true; - requestsMap.setRequest(event, request); - } - event.setVerificationRequest(request); - try { - await request.channel.handleEvent(event, request, isLiveEvent); - } catch (err) { - logger.error("error while handling verification event: " + err.message); - } - const shouldEmit = isNewRequest && - !request.initiatedByMe && - !request.invalid && // check it has enough events to pass the UNSENT stage - !request.observeOnly; - if (shouldEmit) { - this._baseApis.emit("crypto.verification.request", request); - } -}; - -/** - * Handle a toDevice event that couldn't be decrypted - * - * @private - * @param {module:models/event.MatrixEvent} event undecryptable event - */ -Crypto.prototype._onToDeviceBadEncrypted = async function(event) { - const content = event.getWireContent(); - const sender = event.getSender(); - const algorithm = content.algorithm; - const deviceKey = content.sender_key; - - // retry decryption for all events sent by the sender_key. This will - // update the events to show a message indicating that the olm session was - // wedged. - const retryDecryption = () => { - const roomDecryptors = this._getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); - for (const decryptor of roomDecryptors) { - decryptor.retryDecryptionFromSender(deviceKey); - } - }; - - if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { - return; - } - - // check when we last forced a new session with this device: if we've already done so - // recently, don't do it again. - this._lastNewSessionForced[sender] = this._lastNewSessionForced[sender] || {}; - const lastNewSessionForced = this._lastNewSessionForced[sender][deviceKey] || 0; - if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { - logger.debug( - "New session already forced with device " + sender + ":" + deviceKey + - " at " + lastNewSessionForced + ": not forcing another", - ); - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - return; - } - - // establish a new olm session with this device since we're failing to decrypt messages - // on a current session. - // Note that an undecryptable message from another device could easily be spoofed - - // is there anything we can do to mitigate this? - let device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - // if we don't know about the device, fetch the user's devices again - // and retry before giving up - await this.downloadKeys([sender], false); - device = this._deviceList.getDeviceByIdentityKey(algorithm, deviceKey); - if (!device) { - logger.info( - "Couldn't find device for identity key " + deviceKey + - ": not re-establishing session", - ); - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", false); - retryDecryption(); - return; - } - } - const devicesByUser = {}; - devicesByUser[sender] = [device]; - await olmlib.ensureOlmSessionsForDevices( - this._olmDevice, this._baseApis, devicesByUser, true, - ); - - this._lastNewSessionForced[sender][deviceKey] = Date.now(); - - // Now send a blank message on that session so the other side knows about it. - // (The keyshare request is sent in the clear so that won't do) - // We send this first such that, as long as the toDevice messages arrive in the - // same order we sent them, the other end will get this first, set up the new session, - // then get the keyshare request and send the key over this new session (because it - // is the session it has most recently received a message on). - const encryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this._olmDevice.deviceCurve25519Key, - ciphertext: {}, - }; - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this._userId, - this._deviceId, - this._olmDevice, - sender, - device, - { type: "m.dummy" }, - ); - - await this._olmDevice.recordSessionProblem(deviceKey, "wedged", true); - retryDecryption(); - - await this._baseApis.sendToDevice("m.room.encrypted", { - [sender]: { - [device.deviceId]: encryptedContent, - }, - }); - - // Most of the time this probably won't be necessary since we'll have queued up a key request when - // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending - // it. This won't always be the case though so we need to re-send any that have already been sent - // to avoid races. - const requestsToResend = - await this._outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest( - sender, device.deviceId, - ); - for (const keyReq of requestsToResend) { - this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); - } -}; - -/** - * Handle a change in the membership state of a member of a room - * - * @private - * @param {module:models/event.MatrixEvent} event event causing the change - * @param {module:models/room-member} member user whose membership changed - * @param {string=} oldMembership previous membership - */ -Crypto.prototype._onRoomMembership = function(event, member, oldMembership) { - // this event handler is registered on the *client* (as opposed to the room - // member itself), which means it is only called on changes to the *live* - // membership state (ie, it is not called when we back-paginate, nor when - // we load the state in the initialsync). - // - // Further, it is automatically registered and called when new members - // arrive in the room. - - const roomId = member.roomId; - - const alg = this._roomEncryptors[roomId]; - if (!alg) { - // not encrypting in this room - return; - } - // only mark users in this room as tracked if we already started tracking in this room - // this way we don't start device queries after sync on behalf of this room which we won't use - // the result of anyway, as we'll need to do a query again once all the members are fetched - // by calling _trackRoomDevices - if (this._roomDeviceTrackingState[roomId]) { - if (member.membership == 'join') { - logger.log('Join event for ' + member.userId + ' in ' + roomId); - // make sure we are tracking the deviceList for this user - this._deviceList.startTrackingDeviceList(member.userId); - } else if (member.membership == 'invite' && - this._clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { - logger.log('Invite event for ' + member.userId + ' in ' + roomId); - this._deviceList.startTrackingDeviceList(member.userId); - } - } - - alg.onRoomMembership(event, member, oldMembership); -}; - -/** - * Called when we get an m.room_key_request event. - * - * @private - * @param {module:models/event.MatrixEvent} event key request event - */ -Crypto.prototype._onRoomKeyRequestEvent = function(event) { - const content = event.getContent(); - if (content.action === "request") { - // Queue it up for now, because they tend to arrive before the room state - // events at initial sync, and we want to see if we know anything about the - // room before passing them on to the app. - const req = new IncomingRoomKeyRequest(event); - this._receivedRoomKeyRequests.push(req); - } else if (content.action === "request_cancellation") { - const req = new IncomingRoomKeyRequestCancellation(event); - this._receivedRoomKeyRequestCancellations.push(req); - } -}; - -/** - * Process any m.room_key_request events which were queued up during the - * current sync. - * - * @private - */ -Crypto.prototype._processReceivedRoomKeyRequests = async function() { - if (this._processingRoomKeyRequests) { - // we're still processing last time's requests; keep queuing new ones - // up for now. - return; - } - this._processingRoomKeyRequests = true; - - try { - // we need to grab and clear the queues in the synchronous bit of this method, - // so that we don't end up racing with the next /sync. - const requests = this._receivedRoomKeyRequests; - this._receivedRoomKeyRequests = []; - const cancellations = this._receivedRoomKeyRequestCancellations; - this._receivedRoomKeyRequestCancellations = []; - - // Process all of the requests, *then* all of the cancellations. - // - // This makes sure that if we get a request and its cancellation in the - // same /sync result, then we process the request before the - // cancellation (and end up with a cancelled request), rather than the - // cancellation before the request (and end up with an outstanding - // request which should have been cancelled.) - await Promise.all(requests.map((req) => - this._processReceivedRoomKeyRequest(req))); - await Promise.all(cancellations.map((cancellation) => - this._processReceivedRoomKeyRequestCancellation(cancellation))); - } catch (e) { - logger.error(`Error processing room key requsts: ${e}`); - } finally { - this._processingRoomKeyRequests = false; - } -}; - -/** - * Helper for processReceivedRoomKeyRequests - * - * @param {IncomingRoomKeyRequest} req - */ -Crypto.prototype._processReceivedRoomKeyRequest = async function(req) { - const userId = req.userId; - const deviceId = req.deviceId; - - const body = req.requestBody; - const roomId = body.room_id; - const alg = body.algorithm; - - logger.log(`m.room_key_request from ${userId}:${deviceId}` + - ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); - - if (userId !== this._userId) { - if (!this._roomEncryptors[roomId]) { - logger.debug(`room key request for unencrypted room ${roomId}`); - return; - } - const encryptor = this._roomEncryptors[roomId]; - const device = this._deviceList.getStoredDevice(userId, deviceId); - if (!device) { - logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); - return; - } - - try { - await encryptor.reshareKeyWithDevice( - body.sender_key, body.session_id, userId, device, - ); - } catch (e) { - logger.warn( - "Failed to re-share keys for session " + body.session_id + - " with device " + userId + ":" + device.deviceId, e, - ); - } - return; - } - - if (deviceId === this._deviceId) { - // We'll always get these because we send room key requests to - // '*' (ie. 'all devices') which includes the sending device, - // so ignore requests from ourself because apart from it being - // very silly, it won't work because an Olm session cannot send - // messages to itself. - // The log here is probably superfluous since we know this will - // always happen, but let's log anyway for now just in case it - // causes issues. - logger.log("Ignoring room key request from ourselves"); - return; - } - - // todo: should we queue up requests we don't yet have keys for, - // in case they turn up later? - - // if we don't have a decryptor for this room/alg, we don't have - // the keys for the requested events, and can drop the requests. - if (!this._roomDecryptors[roomId]) { - logger.log(`room key request for unencrypted room ${roomId}`); - return; - } - - const decryptor = this._roomDecryptors[roomId][alg]; - if (!decryptor) { - logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); - return; - } - - if (!await decryptor.hasKeysForKeyRequest(req)) { - logger.log( - `room key request for unknown session ${roomId} / ` + - body.session_id, - ); - return; - } - - req.share = () => { - decryptor.shareKeysWithDevice(req); - }; - - // if the device is verified already, share the keys - if (this.checkDeviceTrust(userId, deviceId).isVerified()) { - logger.log('device is already verified: sharing keys'); - req.share(); - return; - } - - this.emit("crypto.roomKeyRequest", req); -}; - -/** - * Helper for processReceivedRoomKeyRequests - * - * @param {IncomingRoomKeyRequestCancellation} cancellation - */ -Crypto.prototype._processReceivedRoomKeyRequestCancellation = async function( - cancellation, -) { - logger.log( - `m.room_key_request cancellation for ${cancellation.userId}:` + - `${cancellation.deviceId} (id ${cancellation.requestId})`, - ); - - // we should probably only notify the app of cancellations we told it - // about, but we don't currently have a record of that, so we just pass - // everything through. - this.emit("crypto.roomKeyRequestCancellation", cancellation); -}; - -/** - * Get a decryptor for a given room and algorithm. - * - * If we already have a decryptor for the given room and algorithm, return - * it. Otherwise try to instantiate it. - * - * @private - * - * @param {string?} roomId room id for decryptor. If undefined, a temporary - * decryptor is instantiated. - * - * @param {string} algorithm crypto algorithm - * - * @return {module:crypto.algorithms.base.DecryptionAlgorithm} - * - * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is - * unknown - */ -Crypto.prototype._getRoomDecryptor = function(roomId, algorithm) { - let decryptors; - let alg; - - roomId = roomId || null; - if (roomId) { - decryptors = this._roomDecryptors[roomId]; - if (!decryptors) { - this._roomDecryptors[roomId] = decryptors = {}; - } - - alg = decryptors[algorithm]; - if (alg) { - return alg; - } - } - - const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; - if (!AlgClass) { - throw new algorithms.DecryptionError( - 'UNKNOWN_ENCRYPTION_ALGORITHM', - 'Unknown encryption algorithm "' + algorithm + '".', - ); - } - alg = new AlgClass({ - userId: this._userId, - crypto: this, - olmDevice: this._olmDevice, - baseApis: this._baseApis, - roomId: roomId, - }); - - if (decryptors) { - decryptors[algorithm] = alg; - } - return alg; -}; - -/** - * Get all the room decryptors for a given encryption algorithm. - * - * @param {string} algorithm The encryption algorithm - * - * @return {array} An array of room decryptors - */ -Crypto.prototype._getRoomDecryptors = function(algorithm) { - const decryptors = []; - for (const d of Object.values(this._roomDecryptors)) { - if (algorithm in d) { - decryptors.push(d[algorithm]); - } - } - return decryptors; -}; - -/** - * sign the given object with our ed25519 key - * - * @param {Object} obj Object to which we will add a 'signatures' property - */ -Crypto.prototype._signObject = async function(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] = - await this._olmDevice.sign(anotherjson.stringify(obj)); - obj.signatures = sigs; - if (unsigned !== undefined) obj.unsigned = unsigned; -}; - -/** - * The parameters of a room key request. The details of the request may - * vary with the crypto algorithm, but the management and storage layers for - * outgoing requests expect it to have 'room_id' and 'session_id' properties. - * - * @typedef {Object} RoomKeyRequestBody - */ - -/** - * Represents a received m.room_key_request event - * - * @property {string} userId user requesting the key - * @property {string} deviceId device requesting the key - * @property {string} requestId unique id for the request - * @property {module:crypto~RoomKeyRequestBody} requestBody - * @property {function()} share callback which, when called, will ask - * the relevant crypto algorithm implementation to share the keys for - * this request. - */ -class IncomingRoomKeyRequest { - constructor(event) { - const content = event.getContent(); - - this.userId = event.getSender(); - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - this.requestBody = content.body || {}; - this.share = () => { - throw new Error("don't know how to share keys for this request yet"); - }; - } -} - -/** - * Represents a received m.room_key_request cancellation - * - * @property {string} userId user requesting the cancellation - * @property {string} deviceId device requesting the cancellation - * @property {string} requestId unique id for the request to be cancelled - */ -class IncomingRoomKeyRequestCancellation { - constructor(event) { - const content = event.getContent(); - - this.userId = event.getSender(); - this.deviceId = content.requesting_device_id; - this.requestId = content.request_id; - } -} - -/** - * The result of a (successful) call to decryptEvent. - * - * @typedef {Object} EventDecryptionResult - * - * @property {Object} clearEvent The plaintext payload for the event - * (typically containing type and content fields). - * - * @property {?string} senderCurve25519Key Key owned by the sender of this - * event. See {@link module:models/event.MatrixEvent#getSenderKey}. - * - * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of - * this event. See - * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. - * - * @property {?Array} forwardingCurve25519KeyChain list of curve25519 - * keys involved in telling us about the senderCurve25519Key and - * claimedEd25519Key. See - * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. - */ - -/** - * Fires when we receive a room key request - * - * @event module:client~MatrixClient#"crypto.roomKeyRequest" - * @param {module:crypto~IncomingRoomKeyRequest} req request details - */ - -/** - * Fires when we receive a room key request cancellation - * - * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" - * @param {module:crypto~IncomingRoomKeyRequestCancellation} req - */ - -/** - * Fires when the app may wish to warn the user about something related - * the end-to-end crypto. - * - * @event module:client~MatrixClient#"crypto.warning" - * @param {string} type One of the strings listed above - */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts new file mode 100644 index 000000000..12b05ffac --- /dev/null +++ b/src/crypto/index.ts @@ -0,0 +1,3719 @@ +/* +Copyright 2016 OpenMarket Ltd +Copyright 2017 Vector Creations Ltd +Copyright 2018-2019 New Vector Ltd +Copyright 2019-2021 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. +*/ + +/** + * @module crypto + */ + +import anotherjson from "another-json"; +import { EventEmitter } from 'events'; + +import { ReEmitter } from '../ReEmitter'; +import { logger } from '../logger'; +import { OlmDevice } from "./OlmDevice"; +import * as olmlib from "./olmlib"; +import { DeviceInfoMap, DeviceList } from "./DeviceList"; +import { DeviceInfo, IDevice } from "./deviceinfo"; +import * as algorithms from "./algorithms"; +import { createCryptoStoreCacheCallbacks, CrossSigningInfo, DeviceTrustLevel, UserTrustLevel } from './CrossSigning'; +import { EncryptionSetupBuilder } from "./EncryptionSetup"; +import { SECRET_STORAGE_ALGORITHM_V1_AES, SecretStorage } from './SecretStorage'; +import { OutgoingRoomKeyRequestManager } from './OutgoingRoomKeyRequestManager'; +import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store'; +import { ReciprocateQRCode, SCAN_QR_CODE_METHOD, SHOW_QR_CODE_METHOD } from './verification/QRCode'; +import { SAS } from './verification/SAS'; +import { keyFromPassphrase } from './key_passphrase'; +import { decodeRecoveryKey, encodeRecoveryKey } from './recoverykey'; +import { VerificationRequest } from "./verification/request/VerificationRequest"; +import { InRoomChannel, InRoomRequests } from "./verification/request/InRoomChannel"; +import { ToDeviceChannel, ToDeviceRequests } from "./verification/request/ToDeviceChannel"; +import { IllegalMethod } from "./verification/IllegalMethod"; +import { KeySignatureUploadError } from "../errors"; +import { decryptAES, encryptAES, calculateKeyCheck } from './aes'; +import { DehydrationManager } from './dehydration'; +import { BackupManager } from "./backup"; +import { IStore } from "../store"; +import { Room } from "../models/room"; +import { RoomMember } from "../models/room-member"; +import { MatrixEvent } from "../models/event"; +import { MatrixClient, IKeysUploadResponse, SessionStore, CryptoStore, ISignedKey } from "../client"; +import type { EncryptionAlgorithm, DecryptionAlgorithm } from "./algorithms/base"; +import type { RoomList } from "./RoomList"; +import { IRecoveryKey, IEncryptedEventInfo } from "./api"; +import { IKeyBackupInfo } from "./keybackup"; +import { ISyncStateData } from "../sync"; + +const DeviceVerification = DeviceInfo.DeviceVerification; + +const defaultVerificationMethods = { + [ReciprocateQRCode.NAME]: ReciprocateQRCode, + [SAS.NAME]: SAS, + + // These two can't be used for actual verification, but we do + // need to be able to define them here for the verification flows + // to start. + [SHOW_QR_CODE_METHOD]: IllegalMethod, + [SCAN_QR_CODE_METHOD]: IllegalMethod, +}; + +/** + * verification method names + */ +export const verificationMethods = { + RECIPROCATE_QR_CODE: ReciprocateQRCode.NAME, + SAS: SAS.NAME, +}; + +export function isCryptoAvailable(): boolean { + return Boolean(global.Olm); +} + +const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000; + +interface IInitOpts { + exportedOlmDevice?: any; // TODO types + pickleKey?: string; +} + +export interface IBootstrapCrossSigningOpts { + setupNewCrossSigning?: boolean; + authUploadDeviceSigningKeys?(makeRequest: (authData: any) => {}): Promise; +} + +interface IBootstrapSecretStorageOpts { + keyBackupInfo?: any; // TODO types + setupNewKeyBackup?: boolean; + setupNewSecretStorage?: boolean; + createSecretStorageKey?(): Promise<{ + keyInfo?: any; // TODO types + privateKey?: Uint8Array; + }>; + getKeyBackupPassphrase?(): Promise; +} + +/* eslint-disable camelcase */ +interface IRoomKey { + room_id: string; + algorithm: string; +} + +export interface IRoomKeyRequestBody extends IRoomKey { + session_id: string; + sender_key: string; +} + +export interface IMegolmSessionData { + sender_key: string; + forwarding_curve25519_key_chain: string[]; + sender_claimed_keys: Record; + room_id: string; + session_id: string; + session_key: string; + algorithm: string; +} +/* eslint-enable camelcase */ + +interface IDeviceVerificationUpgrade { + devices: DeviceInfo[]; + crossSigningInfo: CrossSigningInfo; +} + +/** + * @typedef {Object} module:crypto~OlmSessionResult + * @property {module:crypto/deviceinfo} device device info + * @property {string?} sessionId base64 olm session id; null if no session + * could be established + */ + +interface IUserOlmSession { + deviceIdKey: string; + sessions: { + sessionId: string; + hasReceivedMessage: boolean; + }[]; +} + +interface ISyncDeviceLists { + changed: string[]; + left: string[]; +} + +export interface IRoomKeyRequestRecipient { + userId: string; + deviceId: string; +} + +interface ISignableObject { + signatures?: object; + unsigned?: object; +} + +export interface IEventDecryptionResult { + clearEvent: object; + senderCurve25519Key?: string; + claimedEd25519Key?: string; + forwardingCurve25519KeyChain?: string[]; + untrusted?: boolean; +} + +export class Crypto extends EventEmitter { + /** + * @return {string} The version of Olm. + */ + static getOlmVersion(): string { + return OlmDevice.getOlmVersion(); + } + + public readonly backupManager: BackupManager; + public readonly crossSigningInfo: CrossSigningInfo; + public readonly olmDevice: OlmDevice; + public readonly deviceList: DeviceList; + public readonly dehydrationManager: DehydrationManager; + public readonly secretStorage: SecretStorage; + + private readonly reEmitter: ReEmitter; + private readonly verificationMethods: any; // TODO types + private readonly supportedAlgorithms: string[]; + private readonly outgoingRoomKeyRequestManager: OutgoingRoomKeyRequestManager; + private readonly toDeviceVerificationRequests: ToDeviceRequests; + private readonly inRoomVerificationRequests: InRoomRequests; + + private trustCrossSignedDevices = true; + // the last time we did a check for the number of one-time-keys on the server. + private lastOneTimeKeyCheck: number = null; + private oneTimeKeyCheckInProgress = false; + + // EncryptionAlgorithm instance for each room + private roomEncryptors: Record = {}; + // map from algorithm to DecryptionAlgorithm instance, for each room + private roomDecryptors: Record> = {}; + + private deviceKeys: Record = {}; // type: key + + private globalBlacklistUnverifiedDevices = false; + private globalErrorOnUnknownDevices = true; + + // list of IncomingRoomKeyRequests/IncomingRoomKeyRequestCancellations + // we received in the current sync. + private receivedRoomKeyRequests: IncomingRoomKeyRequest[] = []; + private receivedRoomKeyRequestCancellations: IncomingRoomKeyRequestCancellation[] = []; + // true if we are currently processing received room key requests + private processingRoomKeyRequests = false; + // controls whether device tracking is delayed + // until calling encryptEvent or trackRoomDevices, + // or done immediately upon enabling room encryption. + private lazyLoadMembers = false; + // in case lazyLoadMembers is true, + // track if an initial tracking of all the room members + // has happened for a given room. This is delayed + // to avoid loading room members as long as possible. + private roomDeviceTrackingState: Record> = {}; // roomId: Promise> = {}; + + // This flag will be unset whilst the client processes a sync response + // so that we don't start requesting keys until we've actually finished + // processing the response. + private sendKeyRequestsImmediately = false; + + private oneTimeKeyCount: number; + private needsNewFallback: boolean; + + /** + * Cryptography bits + * + * This module is internal to the js-sdk; the public API is via MatrixClient. + * + * @constructor + * @alias module:crypto + * + * @internal + * + * @param {MatrixClient} baseApis base matrix api interface + * + * @param {module:store/session/webstorage~WebStorageSessionStore} sessionStore + * Store to be used for end-to-end crypto session data + * + * @param {string} userId The user ID for the local user + * + * @param {string} deviceId The identifier for this device. + * + * @param {Object} clientStore the MatrixClient data store. + * + * @param {module:crypto/store/base~CryptoStore} cryptoStore + * storage for the crypto layer. + * + * @param {RoomList} roomList An initialised RoomList object + * + * @param {Array} verificationMethods Array of verification methods to use. + * Each element can either be a string from MatrixClient.verificationMethods + * or a class that implements a verification method. + */ + constructor( + public readonly baseApis: MatrixClient, + public readonly sessionStore: SessionStore, + private readonly userId: string, + private readonly deviceId: string, + private readonly clientStore: IStore, + public readonly cryptoStore: CryptoStore, + private readonly roomList: RoomList, + verificationMethods: any[], // TODO types + ) { + super(); + this.reEmitter = new ReEmitter(this); + + if (verificationMethods) { + this.verificationMethods = new Map(); + for (const method of verificationMethods) { + if (typeof method === "string") { + if (defaultVerificationMethods[method]) { + this.verificationMethods.set( + method, + defaultVerificationMethods[method], + ); + } + } else if (method.NAME) { + this.verificationMethods.set( + method.NAME, + method, + ); + } else { + logger.warn(`Excluding unknown verification method ${method}`); + } + } + } else { + this.verificationMethods = defaultVerificationMethods; + } + + this.backupManager = new BackupManager(baseApis, async () => { + // try to get key from cache + const cachedKey = await this.getSessionBackupPrivateKey(); + if (cachedKey) { + return cachedKey; + } + + // try to get key from secret storage + const storedKey = await this.getSecret("m.megolm_backup.v1"); + + if (storedKey) { + // ensure that the key is in the right format. If not, fix the key and + // store the fixed version + const fixedKey = fixBackupKey(storedKey); + if (fixedKey) { + const [keyId] = await this.getSecretStorageKey(); + await this.storeSecret("m.megolm_backup.v1", fixedKey, [keyId]); + } + + return olmlib.decodeBase64(fixedKey || storedKey); + } + + // try to get key from app + if (this.baseApis.cryptoCallbacks && this.baseApis.cryptoCallbacks.getBackupKey) { + return await this.baseApis.cryptoCallbacks.getBackupKey(); + } + + throw new Error("Unable to get private key"); + }); + + this.olmDevice = new OlmDevice(cryptoStore); + this.deviceList = new DeviceList(baseApis, cryptoStore, this.olmDevice); + + // XXX: This isn't removed at any point, but then none of the event listeners + // this class sets seem to be removed at any point... :/ + this.deviceList.on('userCrossSigningUpdated', this.onDeviceListUserCrossSigningUpdated); + this.reEmitter.reEmit(this.deviceList, ["crypto.devicesUpdated", "crypto.willUpdateDevices"]); + + this.supportedAlgorithms = Object.keys(algorithms.DECRYPTION_CLASSES); + + this.outgoingRoomKeyRequestManager = new OutgoingRoomKeyRequestManager( + baseApis, this.deviceId, this.cryptoStore, + ); + + this.toDeviceVerificationRequests = new ToDeviceRequests(); + this.inRoomVerificationRequests = new InRoomRequests(); + + const cryptoCallbacks = this.baseApis.cryptoCallbacks || {}; + const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this.olmDevice); + + this.crossSigningInfo = new CrossSigningInfo(userId, cryptoCallbacks, cacheCallbacks); + this.secretStorage = new SecretStorage(baseApis, cryptoCallbacks); + this.dehydrationManager = new DehydrationManager(this); + + // Assuming no app-supplied callback, default to getting from SSSS. + if (!cryptoCallbacks.getCrossSigningKey && cryptoCallbacks.getSecretStorageKey) { + cryptoCallbacks.getCrossSigningKey = async (type) => { + return CrossSigningInfo.getFromSecretStorage(type, this.secretStorage); + }; + } + } + + /** + * Initialise the crypto module so that it is ready for use + * + * Returns a promise which resolves once the crypto module is ready for use. + * + * @param {Object} opts keyword arguments. + * @param {string} opts.exportedOlmDevice (Optional) data from exported device + * that must be re-created. + */ + public async init({ exportedOlmDevice, pickleKey }: IInitOpts = {}): Promise { + logger.log("Crypto: initialising Olm..."); + await global.Olm.init(); + logger.log(exportedOlmDevice + ? "Crypto: initialising Olm device from exported device..." + : "Crypto: initialising Olm device...", + ); + await this.olmDevice.init({ fromExportedDevice: exportedOlmDevice, pickleKey }); + logger.log("Crypto: loading device list..."); + await this.deviceList.load(); + + // build our device keys: these will later be uploaded + this.deviceKeys["ed25519:" + this.deviceId] = this.olmDevice.deviceEd25519Key; + this.deviceKeys["curve25519:" + this.deviceId] = this.olmDevice.deviceCurve25519Key; + + logger.log("Crypto: fetching own devices..."); + let myDevices = this.deviceList.getRawStoredDevicesForUser(this.userId); + + if (!myDevices) { + myDevices = {}; + } + + if (!myDevices[this.deviceId]) { + // add our own deviceinfo to the cryptoStore + logger.log("Crypto: adding this device to the store..."); + const deviceInfo = { + keys: this.deviceKeys, + algorithms: this.supportedAlgorithms, + verified: DeviceVerification.VERIFIED, + known: true, + }; + + myDevices[this.deviceId] = deviceInfo; + this.deviceList.storeDevicesForUser(this.userId, myDevices); + this.deviceList.saveIfDirty(); + } + + await this.cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.getCrossSigningKeys(txn, (keys) => { + // can be an empty object after resetting cross-signing keys, see storeTrustedSelfKeys + if (keys && Object.keys(keys).length !== 0) { + logger.log("Loaded cross-signing public keys from crypto store"); + this.crossSigningInfo.setKeys(keys); + } + }); + }, + ); + // make sure we are keeping track of our own devices + // (this is important for key backups & things) + this.deviceList.startTrackingDeviceList(this.userId); + + logger.log("Crypto: checking for key backup..."); + this.backupManager.checkAndStart(); + } + + /** + * Whether to trust a others users signatures of their devices. + * If false, devices will only be considered 'verified' if we have + * verified that device individually (effectively disabling cross-signing). + * + * Default: true + * + * @return {boolean} True if trusting cross-signed devices + */ + public getCryptoTrustCrossSignedDevices(): boolean { + return this.trustCrossSignedDevices; + } + + /** + * See getCryptoTrustCrossSignedDevices + + * This may be set before initCrypto() is called to ensure no races occur. + * + * @param {boolean} val True to trust cross-signed devices + */ + public setCryptoTrustCrossSignedDevices(val: boolean): void { + this.trustCrossSignedDevices = val; + + for (const userId of this.deviceList.getKnownUserIds()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + for (const deviceId of Object.keys(devices)) { + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + // If the device is locally verified then isVerified() is always true, + // so this will only have caused the value to change if the device is + // cross-signing verified but not locally verified + if ( + !deviceTrust.isLocallyVerified() && + deviceTrust.isCrossSigningVerified() + ) { + const deviceObj = this.deviceList.getStoredDevice(userId, deviceId); + this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + } + } + } + } + + /** + * Create a recovery key from a user-supplied passphrase. + * + * @param {string} password Passphrase string that can be entered by the user + * when restoring the backup as an alternative to entering the recovery key. + * Optional. + * @returns {Promise} Object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + */ + public async createRecoveryKeyFromPassphrase(password: string): Promise { + const decryption = new global.Olm.PkDecryption(); + try { + const keyInfo: Partial = {}; + if (password) { + const derivation = await keyFromPassphrase(password); + keyInfo.passphrase = { + algorithm: "m.pbkdf2", + iterations: derivation.iterations, + salt: derivation.salt, + }; + keyInfo.pubkey = decryption.init_with_private_key(derivation.key); + } else { + keyInfo.pubkey = decryption.generate_key(); + } + const privateKey = decryption.get_private_key(); + const encodedPrivateKey = encodeRecoveryKey(privateKey); + return { + keyInfo: keyInfo as IRecoveryKey["keyInfo"], + encodedPrivateKey, + privateKey, + }; + } finally { + if (decryption) decryption.free(); + } + } + + /** + * Checks whether cross signing: + * - is enabled on this account and trusted by this device + * - has private keys either cached locally or stored in secret storage + * + * If this function returns false, bootstrapCrossSigning() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapCrossSigning() completes successfully, this function should + * return true. + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @return {boolean} True if cross-signing is ready to be used on this device + */ + public async isCrossSigningReady(): Promise { + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysExistSomewhere = ( + await this.crossSigningInfo.isStoredInKeyCache() || + await this.crossSigningInfo.isStoredInSecretStorage(this.secretStorage) + ); + + return !!(publicKeysOnDevice && privateKeysExistSomewhere); + } + + /** + * Checks whether secret storage: + * - is enabled on this account + * - is storing cross-signing private keys + * - is storing session backup key (if enabled) + * + * If this function returns false, bootstrapSecretStorage() can be used + * to fix things such that it returns true. That is to say, after + * bootstrapSecretStorage() completes successfully, this function should + * return true. + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @return {boolean} True if secret storage is ready to be used on this device + */ + public async isSecretStorageReady(): Promise { + const secretStorageKeyInAccount = await this.secretStorage.hasKey(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage( + this.secretStorage, + ); + const sessionBackupInStorage = ( + !this.backupManager.getKeyBackupEnabled() || + await this.baseApis.isKeyBackupKeyStored() + ); + + return !!( + secretStorageKeyInAccount && + privateKeysInStorage && + sessionBackupInStorage + ); + } + + /** + * Bootstrap cross-signing by creating keys if needed. If everything is already + * set up, then no changes are made, so this is safe to run to ensure + * cross-signing is ready for use. + * + * This function: + * - creates new cross-signing keys if they are not found locally cached nor in + * secret storage (if it has been setup) + * + * The cross-signing API is currently UNSTABLE and may change without notice. + * + * @param {function} opts.authUploadDeviceSigningKeys Function + * called to await an interactive auth flow when uploading device signing keys. + * @param {boolean} [opts.setupNewCrossSigning] Optional. Reset even if keys + * already exist. + * Args: + * {function} A function that makes the request requiring auth. Receives the + * auth data as an object. Can be called multiple times, first with an empty + * authDict, to obtain the flows. + */ + public async bootstrapCrossSigning({ + authUploadDeviceSigningKeys, + setupNewCrossSigning, + }: IBootstrapCrossSigningOpts = {}): Promise { + logger.log("Bootstrapping cross-signing"); + + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder( + this.baseApis.store.accountData, + delegateCryptoCallbacks, + ); + const crossSigningInfo = new CrossSigningInfo( + this.userId, + builder.crossSigningCallbacks, + builder.crossSigningCallbacks, + ); + + // Reset the cross-signing keys + const resetCrossSigning = async () => { + crossSigningInfo.resetKeys(); + // Sign master key with device key + await this.signObject(crossSigningInfo.keys.master); + + // Store auth flow helper function, as we need to call it when uploading + // to ensure we handle auth errors properly. + builder.addCrossSigningKeys(authUploadDeviceSigningKeys, crossSigningInfo.keys); + + // Cross-sign own device + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const deviceSignature = await crossSigningInfo.signDevice(this.userId, device) as ISignedKey; + builder.addKeySignature(this.userId, this.deviceId, deviceSignature); + + // Sign message key backup with cross-signing master key + if (this.backupManager.backupInfo) { + await crossSigningInfo.signObject( + this.backupManager.backupInfo.auth_data, "master", + ); + builder.addSessionBackup(this.backupManager.backupInfo); + } + }; + + const publicKeysOnDevice = this.crossSigningInfo.getId(); + const privateKeysInCache = await this.crossSigningInfo.isStoredInKeyCache(); + const privateKeysInStorage = await this.crossSigningInfo.isStoredInSecretStorage( + this.secretStorage, + ); + const privateKeysExistSomewhere = ( + privateKeysInCache || + privateKeysInStorage + ); + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + setupNewCrossSigning, + publicKeysOnDevice, + privateKeysInCache, + privateKeysInStorage, + privateKeysExistSomewhere, + }); + + if (!privateKeysExistSomewhere || setupNewCrossSigning) { + logger.log( + "Cross-signing private keys not found locally or in secret storage, " + + "creating new keys", + ); + // If a user has multiple devices, it important to only call bootstrap + // as part of some UI flow (and not silently during startup), as they + // may have setup cross-signing on a platform which has not saved keys + // to secret storage, and this would reset them. In such a case, you + // should prompt the user to verify any existing devices first (and + // request private keys from those devices) before calling bootstrap. + await resetCrossSigning(); + } else if (publicKeysOnDevice && privateKeysInCache) { + logger.log( + "Cross-signing public keys trusted and private keys found locally", + ); + } else if (privateKeysInStorage) { + logger.log( + "Cross-signing private keys not found locally, but they are available " + + "in secret storage, reading storage and caching locally", + ); + await this.checkOwnCrossSigningTrust({ + allowPrivateKeyRequests: true, + }); + } + + // Assuming no app-supplied callback, default to storing new private keys in + // secret storage if it exists. If it does not, it is assumed this will be + // done as part of setting up secret storage later. + const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys; + if ( + crossSigningPrivateKeys.size && + !this.baseApis.cryptoCallbacks.saveCrossSigningKeys + ) { + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks); + if (await secretStorage.hasKey()) { + logger.log("Storing new cross-signing private keys in secret storage"); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage( + crossSigningPrivateKeys, + secretStorage, + ); + } + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // This persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Cross-signing ready"); + } + + /** + * Bootstrap Secure Secret Storage if needed by creating a default key. If everything is + * already set up, then no changes are made, so this is safe to run to ensure secret + * storage is ready for use. + * + * This function + * - creates a new Secure Secret Storage key if no default key exists + * - if a key backup exists, it is migrated to store the key in the Secret + * Storage + * - creates a backup if none exists, and one is requested + * - migrates Secure Secret Storage to use the latest algorithm, if an outdated + * algorithm is found + * + * The Secure Secret Storage API is currently UNSTABLE and may change without notice. + * + * @param {function} [opts.createSecretStorageKey] Optional. Function + * called to await a secret storage key creation flow. + * Returns: + * {Promise} Object with public key metadata, encoded private + * recovery key which should be disposed of after displaying to the user, + * and raw private key to avoid round tripping if needed. + * @param {object} [opts.keyBackupInfo] The current key backup object. If passed, + * the passphrase and recovery key from this backup will be used. + * @param {boolean} [opts.setupNewKeyBackup] If true, a new key backup version will be + * created and the private key stored in the new SSSS store. Ignored if keyBackupInfo + * is supplied. + * @param {boolean} [opts.setupNewSecretStorage] Optional. Reset even if keys already exist. + * @param {func} [opts.getKeyBackupPassphrase] Optional. Function called to get the user's + * current key backup passphrase. Should return a promise that resolves with a Buffer + * containing the key, or rejects if the key cannot be obtained. + * Returns: + * {Promise} A promise which resolves to key creation data for + * SecretStorage#addKey: an object with `passphrase` etc fields. + */ + // TODO this does not resolve with what it says it does + public async bootstrapSecretStorage({ + createSecretStorageKey = async () => ({ }), + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + getKeyBackupPassphrase, + }: IBootstrapSecretStorageOpts = {}) { + logger.log("Bootstrapping Secure Secret Storage"); + const delegateCryptoCallbacks = this.baseApis.cryptoCallbacks; + const builder = new EncryptionSetupBuilder( + this.baseApis.store.accountData, + delegateCryptoCallbacks, + ); + const secretStorage = new SecretStorage( + builder.accountDataClientAdapter, + builder.ssssCryptoCallbacks, + ); + + // the ID of the new SSSS key, if we create one + let newKeyId = null; + + // create a new SSSS key and set it as default + const createSSSS = async (opts, privateKey: Uint8Array) => { + opts = opts || {}; + if (privateKey) { + opts.key = privateKey; + } + + const { keyId, keyInfo } = await secretStorage.addKey(SECRET_STORAGE_ALGORITHM_V1_AES, opts); + + if (privateKey) { + // make the private key available to encrypt 4S secrets + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + } + + await secretStorage.setDefaultKeyId(keyId); + return keyId; + }; + + const ensureCanCheckPassphrase = async (keyId, keyInfo) => { + if (!keyInfo.mac) { + const key = await this.baseApis.cryptoCallbacks.getSecretStorageKey( + { keys: { [keyId]: keyInfo } }, "", + ); + if (key) { + const privateKey = key[1]; + builder.ssssCryptoCallbacks.addPrivateKey(keyId, keyInfo, privateKey); + const { iv, mac } = await calculateKeyCheck(privateKey); + keyInfo.iv = iv; + keyInfo.mac = mac; + + await builder.setAccountData( + `m.secret_storage.key.${keyId}`, keyInfo, + ); + } + } + }; + + const signKeyBackupWithCrossSigning = async (keyBackupAuthData) => { + if ( + this.crossSigningInfo.getId() && + await this.crossSigningInfo.isStoredInKeyCache("master") + ) { + try { + logger.log("Adding cross-signing signature to key backup"); + await this.crossSigningInfo.signObject(keyBackupAuthData, "master"); + } catch (e) { + // This step is not critical (just helpful), so we catch here + // and continue if it fails. + logger.error("Signing key backup with cross-signing keys failed", e); + } + } else { + logger.warn( + "Cross-signing keys not available, skipping signature on key backup", + ); + } + }; + + const oldSSSSKey = await this.getSecretStorageKey(); + const [oldKeyId, oldKeyInfo] = oldSSSSKey || [null, null]; + const storageExists = ( + !setupNewSecretStorage && + oldKeyInfo && + oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES + ); + + // Log all relevant state for easier parsing of debug logs. + logger.log({ + keyBackupInfo, + setupNewKeyBackup, + setupNewSecretStorage, + storageExists, + oldKeyInfo, + }); + + if (!storageExists && !keyBackupInfo) { + // either we don't have anything, or we've been asked to restart + // from scratch + logger.log( + "Secret storage does not exist, creating new storage key", + ); + + // if we already have a usable default SSSS key and aren't resetting + // SSSS just use it. otherwise, create a new one + // Note: we leave the old SSSS key in place: there could be other + // secrets using it, in theory. We could move them to the new key but a) + // that would mean we'd need to prompt for the old passphrase, and b) + // it's not clear that would be the right thing to do anyway. + const { keyInfo, privateKey } = await createSecretStorageKey(); + newKeyId = await createSSSS(keyInfo, privateKey); + } else if (!storageExists && keyBackupInfo) { + // we have an existing backup, but no SSSS + logger.log("Secret storage does not exist, using key backup key"); + + // if we have the backup key already cached, use it; otherwise use the + // callback to prompt for the key + const backupKey = await this.getSessionBackupPrivateKey() || await getKeyBackupPassphrase(); + + // create a new SSSS key and use the backup key as the new SSSS key + const opts: any = {}; // TODO types + + if ( + keyBackupInfo.auth_data.private_key_salt && + keyBackupInfo.auth_data.private_key_iterations + ) { + // FIXME: ??? + opts.passphrase = { + algorithm: "m.pbkdf2", + iterations: keyBackupInfo.auth_data.private_key_iterations, + salt: keyBackupInfo.auth_data.private_key_salt, + bits: 256, + }; + } + + newKeyId = await createSSSS(opts, backupKey); + + // store the backup key in secret storage + await secretStorage.store( + "m.megolm_backup.v1", olmlib.encodeBase64(backupKey), [newKeyId], + ); + + // The backup is trusted because the user provided the private key. + // Sign the backup with the cross-signing key so the key backup can + // be trusted via cross-signing. + await signKeyBackupWithCrossSigning(keyBackupInfo.auth_data); + + builder.addSessionBackup(keyBackupInfo); + } else { + // 4S is already set up + logger.log("Secret storage exists"); + + if (oldKeyInfo && oldKeyInfo.algorithm === SECRET_STORAGE_ALGORITHM_V1_AES) { + // make sure that the default key has the information needed to + // check the passphrase + await ensureCanCheckPassphrase(oldKeyId, oldKeyInfo); + } + } + + // If we have cross-signing private keys cached, store them in secret + // storage if they are not there already. + if ( + !this.baseApis.cryptoCallbacks.saveCrossSigningKeys && + await this.isCrossSigningReady() && + (newKeyId || !await this.crossSigningInfo.isStoredInSecretStorage(secretStorage)) + ) { + logger.log("Copying cross-signing private keys from cache to secret storage"); + const crossSigningPrivateKeys = + await this.crossSigningInfo.getCrossSigningKeysFromCache(); + // This is writing to in-memory account data in + // builder.accountDataClientAdapter so won't fail + await CrossSigningInfo.storeInSecretStorage(crossSigningPrivateKeys, secretStorage); + } + + if (setupNewKeyBackup && !keyBackupInfo) { + logger.log("Creating new message key backup version"); + const info = await this.baseApis.prepareKeyBackupVersion( + null /* random key */, + // don't write to secret storage, as it will write to this.secretStorage. + // Here, we want to capture all the side-effects of bootstrapping, + // and want to write to the local secretStorage object + { secureSecretStorage: false }, + ); + // write the key ourselves to 4S + const privateKey = decodeRecoveryKey(info.recovery_key); + await secretStorage.store("m.megolm_backup.v1", olmlib.encodeBase64(privateKey)); + + // create keyBackupInfo object to add to builder + const data: IKeyBackupInfo = { + algorithm: info.algorithm, + auth_data: info.auth_data, + }; + + // Sign with cross-signing master key + await signKeyBackupWithCrossSigning(data.auth_data); + + // sign with the device fingerprint + await this.signObject(data.auth_data); + + builder.addSessionBackup(data); + } + + // Cache the session backup key + const sessionBackupKey = await secretStorage.get('m.megolm_backup.v1'); + if (sessionBackupKey) { + logger.info("Got session backup key from secret storage: caching"); + // fix up the backup key if it's in the wrong format, and replace + // in secret storage + const fixedBackupKey = fixBackupKey(sessionBackupKey); + if (fixedBackupKey) { + await secretStorage.store("m.megolm_backup.v1", + fixedBackupKey, [newKeyId || oldKeyId], + ); + } + const decodedBackupKey = new Uint8Array(olmlib.decodeBase64( + fixedBackupKey || sessionBackupKey, + )); + await builder.addSessionBackupPrivateKeyToCache(decodedBackupKey); + } + + const operation = builder.buildOperation(); + await operation.apply(this); + // this persists private keys and public keys as trusted, + // only do this if apply succeeded for now as retry isn't in place yet + await builder.persist(this); + + logger.log("Secure Secret Storage ready"); + } + + public addSecretStorageKey(algorithm: string, opts: any, keyID: string): any { // TODO types + return this.secretStorage.addKey(algorithm, opts, keyID); + } + + public hasSecretStorageKey(keyID: string): boolean { + return this.secretStorage.hasKey(keyID); + } + + public getSecretStorageKey(keyID?: string): any { // TODO types + return this.secretStorage.getKey(keyID); + } + + public storeSecret(name: string, secret: string, keys?: string[]): Promise { + return this.secretStorage.store(name, secret, keys); + } + + public getSecret(name: string): Promise { + return this.secretStorage.get(name); + } + + public isSecretStored(name: string, checkKey?: boolean): any { // TODO types + return this.secretStorage.isStored(name, checkKey); + } + + public requestSecret(name: string, devices: string[]): Promise { // TODO types + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(this.userId)); + } + return this.secretStorage.request(name, devices); + } + + public getDefaultSecretStorageKeyId(): Promise { + return this.secretStorage.getDefaultKeyId(); + } + + public setDefaultSecretStorageKeyId(k: string): Promise { + return this.secretStorage.setDefaultKeyId(k); + } + + public checkSecretStorageKey(key: string, info: any): Promise { // TODO types + return this.secretStorage.checkKey(key, info); + } + + /** + * Checks that a given secret storage private key matches a given public key. + * This can be used by the getSecretStorageKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param {Uint8Array} privateKey The private key + * @param {string} expectedPublicKey The public key + * @returns {boolean} true if the key matches, otherwise false + */ + public checkSecretStoragePrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let decryption = null; + try { + decryption = new global.Olm.PkDecryption(); + const gotPubkey = decryption.init_with_private_key(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + if (decryption) decryption.free(); + } + } + + /** + * Fetches the backup private key, if cached + * @returns {Promise} the key, if any, or null + */ + public async getSessionBackupPrivateKey(): Promise { + let key = await new Promise((resolve) => { // TODO types + this.cryptoStore.doTxn( + 'readonly', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.getSecretStorePrivateKey( + txn, + resolve, + "m.megolm_backup.v1", + ); + }, + ); + }); + + // make sure we have a Uint8Array, rather than a string + if (key && typeof key === "string") { + key = new Uint8Array(olmlib.decodeBase64(fixBackupKey(key) || key)); + await this.storeSessionBackupPrivateKey(key); + } + if (key && key.ciphertext) { + const pickleKey = Buffer.from(this.olmDevice._pickleKey); + const decrypted = await decryptAES(key, pickleKey, "m.megolm_backup.v1"); + key = olmlib.decodeBase64(decrypted); + } + return key; + } + + /** + * Stores the session backup key to the cache + * @param {Uint8Array} key the private key + * @returns {Promise} so you can catch failures + */ + public async storeSessionBackupPrivateKey(key: ArrayLike): Promise { + if (!(key instanceof Uint8Array)) { + throw new Error(`storeSessionBackupPrivateKey expects Uint8Array, got ${key}`); + } + const pickleKey = Buffer.from(this.olmDevice._pickleKey); + const encryptedKey = await encryptAES(olmlib.encodeBase64(key), pickleKey, "m.megolm_backup.v1"); + return this.cryptoStore.doTxn( + 'readwrite', + [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.storeSecretStorePrivateKey(txn, "m.megolm_backup.v1", encryptedKey); + }, + ); + } + + /** + * Checks that a given cross-signing private key matches a given public key. + * This can be used by the getCrossSigningKey callback to verify that the + * private key it is about to supply is the one that was requested. + * + * @param {Uint8Array} privateKey The private key + * @param {string} expectedPublicKey The public key + * @returns {boolean} true if the key matches, otherwise false + */ + public checkCrossSigningPrivateKey(privateKey: Uint8Array, expectedPublicKey: string): boolean { + let signing = null; + try { + signing = new global.Olm.PkSigning(); + const gotPubkey = signing.init_with_seed(privateKey); + // make sure it agrees with the given pubkey + return gotPubkey === expectedPublicKey; + } finally { + if (signing) signing.free(); + } + } + + /** + * Run various follow-up actions after cross-signing keys have changed locally + * (either by resetting the keys for the account or by getting them from secret + * storage), such as signing the current device, upgrading device + * verifications, etc. + */ + private async afterCrossSigningLocalKeyChange(): Promise { + logger.info("Starting cross-signing key change post-processing"); + + // sign the current device with the new key, and upload to the server + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice(this.userId, device); + logger.info(`Starting background key sig upload for ${this.deviceId}`); + + const upload = ({ shouldEmit }) => { + return this.baseApis.uploadKeySignatures({ + [this.userId]: { + [this.deviceId]: signedDevice, + }, + }).then((response) => { + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "afterCrossSigningLocalKeyChange", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + logger.info(`Finished background key sig upload for ${this.deviceId}`); + }).catch(e => { + logger.error( + `Error during background key sig upload for ${this.deviceId}`, + e, + ); + }); + }; + upload({ shouldEmit: true }); + + const shouldUpgradeCb = ( + this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications + ); + if (shouldUpgradeCb) { + logger.info("Starting device verification upgrade"); + + // Check all users for signatures if upgrade callback present + // FIXME: do this in batches + const users = {}; + for (const [userId, crossSigningInfo] + of Object.entries(this.deviceList.crossSigningInfo)) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade( + userId, CrossSigningInfo.fromStorage(crossSigningInfo, userId), + ); + if (upgradeInfo) { + users[userId] = upgradeInfo; + } + } + + if (Object.keys(users).length > 0) { + logger.info(`Found ${Object.keys(users).length} verif users to upgrade`); + try { + const usersToUpgrade = await shouldUpgradeCb({ users: users }); + if (usersToUpgrade) { + for (const userId of usersToUpgrade) { + if (userId in users) { + await this.baseApis.setDeviceVerified( + userId, users[userId].crossSigningInfo.getId(), + ); + } + } + } + } catch (e) { + logger.log( + "shouldUpgradeDeviceVerifications threw an error: not upgrading", e, + ); + } + } + + logger.info("Finished device verification upgrade"); + } + + logger.info("Finished cross-signing key change post-processing"); + } + + /** + * Check if a user's cross-signing key is a candidate for upgrading from device + * verification. + * + * @param {string} userId the user whose cross-signing information is to be checked + * @param {object} crossSigningInfo the cross-signing information to check + */ + private async checkForDeviceVerificationUpgrade( + userId: string, + crossSigningInfo: CrossSigningInfo, + ): Promise { + // only upgrade if this is the first cross-signing key that we've seen for + // them, and if their cross-signing key isn't already verified + const trustLevel = this.crossSigningInfo.checkUserTrust(crossSigningInfo); + if (crossSigningInfo.firstUse && !trustLevel.isVerified()) { + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + const deviceIds = await this.checkForValidDeviceSignature( + userId, crossSigningInfo.keys.master, devices, + ); + if (deviceIds.length) { + return { + devices: deviceIds.map( + deviceId => DeviceInfo.fromStorage(devices[deviceId], deviceId), + ), + crossSigningInfo, + }; + } + } + } + + /** + * Check if the cross-signing key is signed by a verified device. + * + * @param {string} userId the user ID whose key is being checked + * @param {object} key the key that is being checked + * @param {object} devices the user's devices. Should be a map from device ID + * to device info + */ + private async checkForValidDeviceSignature( + userId: string, + key: any, // TODO types + devices: Record, + ): Promise { + const deviceIds: string[] = []; + if (devices && key.signatures && key.signatures[userId]) { + for (const signame of Object.keys(key.signatures[userId])) { + const [, deviceId] = signame.split(':', 2); + if (deviceId in devices + && devices[deviceId].verified === DeviceVerification.VERIFIED) { + try { + await olmlib.verifySignature( + this.olmDevice, + key, + userId, + deviceId, + devices[deviceId].keys[signame], + ); + deviceIds.push(deviceId); + } catch (e) {} + } + } + } + return deviceIds; + } + + /** + * Get the user's cross-signing key ID. + * + * @param {string} [type=master] The type of key to get the ID of. One of + * "master", "self_signing", or "user_signing". Defaults to "master". + * + * @returns {string} the key ID + */ + public getCrossSigningId(type: string): string { + return this.crossSigningInfo.getId(type); + } + + /** + * Get the cross signing information for a given user. + * + * @param {string} userId the user ID to get the cross-signing info for. + * + * @returns {CrossSigningInfo} the cross signing information for the user. + */ + public getStoredCrossSigningForUser(userId: string): CrossSigningInfo { + return this.deviceList.getStoredCrossSigningForUser(userId); + } + + /** + * Check whether a given user is trusted. + * + * @param {string} userId The ID of the user to check. + * + * @returns {UserTrustLevel} + */ + public checkUserTrust(userId: string): UserTrustLevel { + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!userCrossSigning) { + return new UserTrustLevel(false, false, false); + } + return this.crossSigningInfo.checkUserTrust(userCrossSigning); + } + + /** + * Check whether a given device is trusted. + * + * @param {string} userId The ID of the user whose devices is to be checked. + * @param {string} deviceId The ID of the device to check + * + * @returns {DeviceTrustLevel} + */ + public checkDeviceTrust(userId: string, deviceId: string): DeviceTrustLevel { + const device = this.deviceList.getStoredDevice(userId, deviceId); + return this.checkDeviceInfoTrust(userId, device); + } + + /** + * Check whether a given deviceinfo is trusted. + * + * @param {string} userId The ID of the user whose devices is to be checked. + * @param {module:crypto/deviceinfo?} device The device info object to check + * + * @returns {DeviceTrustLevel} + */ + public checkDeviceInfoTrust(userId: string, device: DeviceInfo): DeviceTrustLevel { + const trustedLocally = !!(device && device.isVerified()); + + const userCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (device && userCrossSigning) { + // The trustCrossSignedDevices only affects trust of other people's cross-signing + // signatures + const trustCrossSig = this.trustCrossSignedDevices || userId === this.userId; + return this.crossSigningInfo.checkDeviceTrust( + userCrossSigning, device, trustedLocally, trustCrossSig, + ); + } else { + return new DeviceTrustLevel(false, false, trustedLocally, false); + } + } + + /* + * Event handler for DeviceList's userNewDevices event + */ + private onDeviceListUserCrossSigningUpdated = async (userId: string) => { + if (userId === this.userId) { + // An update to our own cross-signing key. + // Get the new key first: + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + const seenPubkey = newCrossSigning ? newCrossSigning.getId() : null; + const currentPubkey = this.crossSigningInfo.getId(); + const changed = currentPubkey !== seenPubkey; + + if (currentPubkey && seenPubkey && !changed) { + // If it's not changed, just make sure everything is up to date + await this.checkOwnCrossSigningTrust(); + } else { + // We'll now be in a state where cross-signing on the account is not trusted + // because our locally stored cross-signing keys will not match the ones + // on the server for our account. So we clear our own stored cross-signing keys, + // effectively disabling cross-signing until the user gets verified by the device + // that reset the keys + this.storeTrustedSelfKeys(null); + // emit cross-signing has been disabled + this.emit("crossSigning.keysChanged", {}); + // as the trust for our own user has changed, + // also emit an event for this + this.emit("userTrustStatusChanged", + this.userId, this.checkUserTrust(userId)); + } + } else { + await this.checkDeviceVerifications(userId); + + // Update verified before latch using the current state and save the new + // latch value in the device list store. + const crossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigning) { + crossSigning.updateCrossSigningVerifiedBefore( + this.checkUserTrust(userId).isCrossSigningVerified(), + ); + this.deviceList.setRawStoredCrossSigningForUser( + userId, crossSigning.toStorage(), + ); + } + + this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + } + }; + + /** + * Check the copy of our cross-signing key that we have in the device list and + * see if we can get the private key. If so, mark it as trusted. + */ + async checkOwnCrossSigningTrust({ + allowPrivateKeyRequests = false, + } = {}) { + const userId = this.userId; + + // Before proceeding, ensure our cross-signing public keys have been + // downloaded via the device list. + await this.downloadKeys([this.userId]); + + // Also check which private keys are locally cached. + const crossSigningPrivateKeys = + await this.crossSigningInfo.getCrossSigningKeysFromCache(); + + // If we see an update to our own master key, check it against the master + // key we have and, if it matches, mark it as verified + + // First, get the new cross-signing info + const newCrossSigning = this.deviceList.getStoredCrossSigningForUser(userId); + if (!newCrossSigning) { + logger.error( + "Got cross-signing update event for user " + userId + + " but no new cross-signing information found!", + ); + return; + } + + const seenPubkey = newCrossSigning.getId(); + const masterChanged = this.crossSigningInfo.getId() !== seenPubkey; + const masterExistsNotLocallyCached = + newCrossSigning.getId() && !crossSigningPrivateKeys.has("master"); + if (masterChanged) { + logger.info("Got new master public key", seenPubkey); + } + if ( + allowPrivateKeyRequests && + (masterChanged || masterExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing master private key"); + let signing = null; + // It's important for control flow that we leave any errors alone for + // higher levels to handle so that e.g. cancelling access properly + // aborts any larger operation as well. + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + 'master', seenPubkey, + ); + signing = ret[1]; + logger.info("Got cross-signing master private key"); + } finally { + if (signing) signing.free(); + } + } + + const oldSelfSigningId = this.crossSigningInfo.getId("self_signing"); + const oldUserSigningId = this.crossSigningInfo.getId("user_signing"); + + // Update the version of our keys in our cross-signing object and the local store + this.storeTrustedSelfKeys(newCrossSigning.keys); + + const selfSigningChanged = oldSelfSigningId !== newCrossSigning.getId("self_signing"); + const userSigningChanged = oldUserSigningId !== newCrossSigning.getId("user_signing"); + + const selfSigningExistsNotLocallyCached = ( + newCrossSigning.getId("self_signing") && + !crossSigningPrivateKeys.has("self_signing") + ); + const userSigningExistsNotLocallyCached = ( + newCrossSigning.getId("user_signing") && + !crossSigningPrivateKeys.has("user_signing") + ); + + const keySignatures = {}; + + if (selfSigningChanged) { + logger.info("Got new self-signing key", newCrossSigning.getId("self_signing")); + } + if ( + allowPrivateKeyRequests && + (selfSigningChanged || selfSigningExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing self-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "self_signing", newCrossSigning.getId("self_signing"), + ); + signing = ret[1]; + logger.info("Got cross-signing self-signing private key"); + } finally { + if (signing) signing.free(); + } + + const device = this.deviceList.getStoredDevice(this.userId, this.deviceId); + const signedDevice = await this.crossSigningInfo.signDevice( + this.userId, device, + ); + keySignatures[this.deviceId] = signedDevice; + } + if (userSigningChanged) { + logger.info("Got new user-signing key", newCrossSigning.getId("user_signing")); + } + if ( + allowPrivateKeyRequests && + (userSigningChanged || userSigningExistsNotLocallyCached) + ) { + logger.info("Attempting to retrieve cross-signing user-signing private key"); + let signing = null; + try { + const ret = await this.crossSigningInfo.getCrossSigningKey( + "user_signing", newCrossSigning.getId("user_signing"), + ); + signing = ret[1]; + logger.info("Got cross-signing user-signing private key"); + } finally { + if (signing) signing.free(); + } + } + + if (masterChanged) { + const masterKey = this.crossSigningInfo.keys.master; + await this.signObject(masterKey); + const deviceSig = masterKey.signatures[this.userId]["ed25519:" + this.deviceId]; + // Include only the _new_ device signature in the upload. + // We may have existing signatures from deleted devices, which will cause + // the entire upload to fail. + keySignatures[this.crossSigningInfo.getId()] = Object.assign( + {}, + masterKey, + { + signatures: { + [this.userId]: { + ["ed25519:" + this.deviceId]: deviceSig, + }, + }, + }, + ); + } + + const keysToUpload = Object.keys(keySignatures); + if (keysToUpload.length) { + const upload = ({ shouldEmit }) => { + logger.info(`Starting background key sig upload for ${keysToUpload}`); + return this.baseApis.uploadKeySignatures({ [this.userId]: keySignatures }) + .then((response) => { + const { failures } = response || {}; + logger.info(`Finished background key sig upload for ${keysToUpload}`); + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "checkOwnCrossSigningTrust", + upload, + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }).catch(e => { + logger.error( + `Error during background key sig upload for ${keysToUpload}`, + e, + ); + }); + }; + upload({ shouldEmit: true }); + } + + this.emit("userTrustStatusChanged", userId, this.checkUserTrust(userId)); + + if (masterChanged) { + this.baseApis.emit("crossSigning.keysChanged", {}); + await this.afterCrossSigningLocalKeyChange(); + } + + // Now we may be able to trust our key backup + await this.backupManager.checkKeyBackup(); + // FIXME: if we previously trusted the backup, should we automatically sign + // the backup with the new key (if not already signed)? + } + + /** + * Store a set of keys as our own, trusted, cross-signing keys. + * + * @param {object} keys The new trusted set of keys + */ + private async storeTrustedSelfKeys(keys: any): Promise { // TODO types + if (keys) { + this.crossSigningInfo.setKeys(keys); + } else { + this.crossSigningInfo.clearKeys(); + } + await this.cryptoStore.doTxn( + 'readwrite', [IndexedDBCryptoStore.STORE_ACCOUNT], + (txn) => { + this.cryptoStore.storeCrossSigningKeys(txn, this.crossSigningInfo.keys); + }, + ); + } + + /** + * Check if the master key is signed by a verified device, and if so, prompt + * the application to mark it as verified. + * + * @param {string} userId the user ID whose key should be checked + */ + private async checkDeviceVerifications(userId: string): Promise { + const shouldUpgradeCb = ( + this.baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications + ); + if (!shouldUpgradeCb) { + // Upgrading skipped when callback is not present. + return; + } + logger.info(`Starting device verification upgrade for ${userId}`); + if (this.crossSigningInfo.keys.user_signing) { + const crossSigningInfo = this.deviceList.getStoredCrossSigningForUser(userId); + if (crossSigningInfo) { + const upgradeInfo = await this.checkForDeviceVerificationUpgrade( + userId, crossSigningInfo, + ); + if (upgradeInfo) { + const usersToUpgrade = await shouldUpgradeCb({ + users: { + [userId]: upgradeInfo, + }, + }); + if (usersToUpgrade.includes(userId)) { + await this.baseApis.setDeviceVerified( + userId, crossSigningInfo.getId(), + ); + } + } + } + } + logger.info(`Finished device verification upgrade for ${userId}`); + } + + public async setTrustedBackupPubKey(trustedPubKey: string): Promise { + // This should be redundant post cross-signing is a thing, so just + // plonk it in localStorage for now. + this.sessionStore.setLocalTrustedBackupPubKey(trustedPubKey); + await this.backupManager.checkKeyBackup(); + } + + /** + */ + public enableLazyLoading(): void { + this.lazyLoadMembers = true; + } + + /** + * Tell the crypto module to register for MatrixClient events which it needs to + * listen for + * + * @param {external:EventEmitter} eventEmitter event source where we can register + * for event notifications + */ + public registerEventHandlers(eventEmitter: EventEmitter): void { + eventEmitter.on("RoomMember.membership", (event: MatrixEvent, member: RoomMember, oldMembership?: string) => { + try { + this.onRoomMembership(event, member, oldMembership); + } catch (e) { + logger.error("Error handling membership change:", e); + } + }); + + eventEmitter.on("toDeviceEvent", this.onToDeviceEvent); + eventEmitter.on("Room.timeline", this.onTimelineEvent); + eventEmitter.on("Event.decrypted", this.onTimelineEvent); + } + + /** Start background processes related to crypto */ + public start(): void { + this.outgoingRoomKeyRequestManager.start(); + } + + /** Stop background processes related to crypto */ + public stop(): void { + this.outgoingRoomKeyRequestManager.stop(); + this.deviceList.stop(); + this.dehydrationManager.stop(); + } + + /** + * Get the Ed25519 key for this device + * + * @return {string} base64-encoded ed25519 key. + */ + public getDeviceEd25519Key(): string { + return this.olmDevice.deviceEd25519Key; + } + + /** + * Get the Curve25519 key for this device + * + * @return {string} base64-encoded curve25519 key. + */ + public getDeviceCurve25519Key(): string { + return this.olmDevice.deviceCurve25519Key; + } + + /** + * Set the global override for whether the client should ever send encrypted + * messages to unverified devices. This provides the default for rooms which + * do not specify a value. + * + * @param {boolean} value whether to blacklist all unverified devices by default + */ + public setGlobalBlacklistUnverifiedDevices(value: boolean): void { + this.globalBlacklistUnverifiedDevices = value; + } + + /** + * @return {boolean} whether to blacklist all unverified devices by default + */ + public getGlobalBlacklistUnverifiedDevices(): boolean { + return this.globalBlacklistUnverifiedDevices; + } + + /** + * Set whether sendMessage in a room with unknown and unverified devices + * should throw an error and not send them message. This has 'Global' for + * symmetry with setGlobalBlacklistUnverifiedDevices but there is currently + * no room-level equivalent for this setting. + * + * This API is currently UNSTABLE and may change or be removed without notice. + * + * @param {boolean} value whether error on unknown devices + */ + public setGlobalErrorOnUnknownDevices(value: boolean): void { + this.globalErrorOnUnknownDevices = value; + } + + /** + * @return {boolean} whether to error on unknown devices + * + * This API is currently UNSTABLE and may change or be removed without notice. + */ + public getGlobalErrorOnUnknownDevices(): boolean { + return this.globalErrorOnUnknownDevices; + } + + /** + * Upload the device keys to the homeserver. + * @return {object} A promise that will resolve when the keys are uploaded. + */ + public uploadDeviceKeys(): Promise { + const deviceKeys = { + algorithms: this.supportedAlgorithms, + device_id: this.deviceId, + keys: this.deviceKeys, + user_id: this.userId, + }; + + return this.signObject(deviceKeys).then(() => { + return this.baseApis.uploadKeysRequest({ + device_keys: deviceKeys, + }); + }); + } + + /** + * Stores the current one_time_key count which will be handled later (in a call of + * onSyncCompleted). The count is e.g. coming from a /sync response. + * + * @param {Number} currentCount The current count of one_time_keys to be stored + */ + public updateOneTimeKeyCount(currentCount: number): void { + if (isFinite(currentCount)) { + this.oneTimeKeyCount = currentCount; + } else { + throw new TypeError("Parameter for updateOneTimeKeyCount has to be a number"); + } + } + + public setNeedsNewFallback(needsNewFallback: boolean) { + this.needsNewFallback = !!needsNewFallback; + } + + public getNeedsNewFallback(): boolean { + return this.needsNewFallback; + } + + // check if it's time to upload one-time keys, and do so if so. + private maybeUploadOneTimeKeys() { + // frequency with which to check & upload one-time keys + const uploadPeriod = 1000 * 60; // one minute + + // max number of keys to upload at once + // Creating keys can be an expensive operation so we limit the + // number we generate in one go to avoid blocking the application + // for too long. + const maxKeysPerCycle = 5; + + if (this.oneTimeKeyCheckInProgress) { + return; + } + + const now = Date.now(); + if (this.lastOneTimeKeyCheck !== null && + now - this.lastOneTimeKeyCheck < uploadPeriod + ) { + // we've done a key upload recently. + return; + } + + this.lastOneTimeKeyCheck = now; + + // We need to keep a pool of one time public keys on the server so that + // other devices can start conversations with us. But we can only store + // a finite number of private keys in the olm Account object. + // To complicate things further then can be a delay between a device + // claiming a public one time key from the server and it sending us a + // message. We need to keep the corresponding private key locally until + // we receive the message. + // But that message might never arrive leaving us stuck with duff + // private keys clogging up our local storage. + // So we need some kind of engineering compromise to balance all of + // these factors. + + // Check how many keys we can store in the Account object. + const maxOneTimeKeys = this.olmDevice.maxNumberOfOneTimeKeys(); + // Try to keep at most half that number on the server. This leaves the + // rest of the slots free to hold keys that have been claimed from the + // server but we haven't received a message for. + // If we run out of slots when generating new keys then olm will + // discard the oldest private keys first. This will eventually clean + // out stale private keys that won't receive a message. + const keyLimit = Math.floor(maxOneTimeKeys / 2); + + const uploadLoop = async (keyCount: number) => { + while (keyLimit > keyCount || this.getNeedsNewFallback()) { + // Ask olm to generate new one time keys, then upload them to synapse. + if (keyLimit > keyCount) { + logger.info("generating oneTimeKeys"); + const keysThisLoop = Math.min(keyLimit - keyCount, maxKeysPerCycle); + await this.olmDevice.generateOneTimeKeys(keysThisLoop); + } + + if (this.getNeedsNewFallback()) { + logger.info("generating fallback key"); + await this.olmDevice.generateFallbackKey(); + } + + logger.info("calling uploadOneTimeKeys"); + const res = await this.uploadOneTimeKeys(); + if (res.one_time_key_counts && res.one_time_key_counts.signed_curve25519) { + // if the response contains a more up to date value use this + // for the next loop + keyCount = res.one_time_key_counts.signed_curve25519; + } else { + throw new Error("response for uploading keys does not contain " + + "one_time_key_counts.signed_curve25519"); + } + } + }; + + this.oneTimeKeyCheckInProgress = true; + Promise.resolve().then(() => { + if (this.oneTimeKeyCount !== undefined) { + // We already have the current one_time_key count from a /sync response. + // Use this value instead of asking the server for the current key count. + return Promise.resolve(this.oneTimeKeyCount); + } + // ask the server how many keys we have + return this.baseApis.uploadKeysRequest({}).then((res) => { + return res.one_time_key_counts.signed_curve25519 || 0; + }); + }).then((keyCount) => { + // Start the uploadLoop with the current keyCount. The function checks if + // we need to upload new keys or not. + // If there are too many keys on the server then we don't need to + // create any more keys. + return uploadLoop(keyCount); + }).catch((e) => { + logger.error("Error uploading one-time keys", e.stack || e); + }).finally(() => { + // reset oneTimeKeyCount to prevent start uploading based on old data. + // it will be set again on the next /sync-response + this.oneTimeKeyCount = undefined; + this.oneTimeKeyCheckInProgress = false; + }); + } + + // returns a promise which resolves to the response + private async uploadOneTimeKeys() { + const promises = []; + + const fallbackJson = {}; + if (this.getNeedsNewFallback()) { + const fallbackKeys = await this.olmDevice.getFallbackKey(); + for (const [keyId, key] of Object.entries(fallbackKeys.curve25519)) { + const k = { key, fallback: true }; + fallbackJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + this.setNeedsNewFallback(false); + } + + const oneTimeKeys = await this.olmDevice.getOneTimeKeys(); + const oneTimeJson = {}; + + for (const keyId in oneTimeKeys.curve25519) { + if (oneTimeKeys.curve25519.hasOwnProperty(keyId)) { + const k = { + key: oneTimeKeys.curve25519[keyId], + }; + oneTimeJson["signed_curve25519:" + keyId] = k; + promises.push(this.signObject(k)); + } + } + + await Promise.all(promises); + + const res = await this.baseApis.uploadKeysRequest({ + "one_time_keys": oneTimeJson, + "org.matrix.msc2732.fallback_keys": fallbackJson, + }); + + await this.olmDevice.markKeysAsPublished(); + return res; + } + + /** + * Download the keys for a list of users and stores the keys in the session + * store. + * @param {Array} userIds The users to fetch. + * @param {boolean} forceDownload Always download the keys even if cached. + * + * @return {Promise} A promise which resolves to a map userId->deviceId->{@link + * module:crypto/deviceinfo|DeviceInfo}. + */ + public downloadKeys(userIds: string[], forceDownload?: boolean): Promise { + return this.deviceList.downloadKeys(userIds, forceDownload); + } + + /** + * Get the stored device keys for a user id + * + * @param {string} userId the user to list keys for. + * + * @return {module:crypto/deviceinfo[]|null} list of devices, or null if we haven't + * managed to get a list of devices for this user yet. + */ + public getStoredDevicesForUser(userId: string): Array | null { + return this.deviceList.getStoredDevicesForUser(userId); + } + + /** + * Get the stored keys for a single device + * + * @param {string} userId + * @param {string} deviceId + * + * @return {module:crypto/deviceinfo?} device, or undefined + * if we don't know about this device + */ + public getStoredDevice(userId: string, deviceId: string): DeviceInfo | undefined { + return this.deviceList.getStoredDevice(userId, deviceId); + } + + /** + * Save the device list, if necessary + * + * @param {number} delay Time in ms before which the save actually happens. + * By default, the save is delayed for a short period in order to batch + * multiple writes, but this behaviour can be disabled by passing 0. + * + * @return {Promise} true if the data was saved, false if + * it was not (eg. because no changes were pending). The promise + * will only resolve once the data is saved, so may take some time + * to resolve. + */ + public saveDeviceList(delay: number): Promise { + return this.deviceList.saveIfDirty(delay); + } + + /** + * Update the blocked/verified state of the given device + * + * @param {string} userId owner of the device + * @param {string} deviceId unique identifier for the device or user's + * cross-signing public key ID. + * + * @param {?boolean} verified whether to mark the device as verified. Null to + * leave unchanged. + * + * @param {?boolean} blocked whether to mark the device as blocked. Null to + * leave unchanged. + * + * @param {?boolean} known whether to mark that the user has been made aware of + * the existence of this device. Null to leave unchanged + * + * @return {Promise} updated DeviceInfo + */ + public async setDeviceVerification( + userId: string, + deviceId: string, + verified?: boolean, + blocked?: boolean, + known?: boolean, + ): Promise { + // get rid of any `undefined`s here so we can just check + // for null rather than null or undefined + if (verified === undefined) verified = null; + if (blocked === undefined) blocked = null; + if (known === undefined) known = null; + + // Check if the 'device' is actually a cross signing key + // The js-sdk's verification treats cross-signing keys as devices + // and so uses this method to mark them verified. + const xsk = this.deviceList.getStoredCrossSigningForUser(userId); + if (xsk && xsk.getId() === deviceId) { + if (blocked !== null || known !== null) { + throw new Error("Cannot set blocked or known for a cross-signing key"); + } + if (!verified) { + throw new Error("Cannot set a cross-signing key as unverified"); + } + + if (!this.crossSigningInfo.getId() && userId === this.crossSigningInfo.userId) { + this.storeTrustedSelfKeys(xsk.keys); + // This will cause our own user trust to change, so emit the event + this.emit( + "userTrustStatusChanged", this.userId, this.checkUserTrust(userId), + ); + } + + // Now sign the master key with our user signing key (unless it's ourself) + if (userId !== this.userId) { + logger.info( + "Master key " + xsk.getId() + " for " + userId + + " marked verified. Signing...", + ); + const device = await this.crossSigningInfo.signUser(xsk); + if (device) { + const upload = async ({ shouldEmit }) => { + logger.info("Uploading signature for " + userId + "..."); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "setDeviceVerification", + upload, + ); + } + /* Throwing here causes the process to be cancelled and the other + * user to be notified */ + throw new KeySignatureUploadError( + "Key upload failed", + { failures }, + ); + } + }; + await upload({ shouldEmit: true }); + + // This will emit events when it comes back down the sync + // (we could do local echo to speed things up) + } + return device as any; // TODO types + } else { + return xsk; + } + } + + const devices = this.deviceList.getRawStoredDevicesForUser(userId); + if (!devices || !devices[deviceId]) { + throw new Error("Unknown device " + userId + ":" + deviceId); + } + + const dev = devices[deviceId]; + let verificationStatus = dev.verified; + + if (verified) { + verificationStatus = DeviceVerification.VERIFIED; + } else if (verified !== null && verificationStatus == DeviceVerification.VERIFIED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + if (blocked) { + verificationStatus = DeviceVerification.BLOCKED; + } else if (blocked !== null && verificationStatus == DeviceVerification.BLOCKED) { + verificationStatus = DeviceVerification.UNVERIFIED; + } + + let knownStatus = dev.known; + if (known !== null) { + knownStatus = known; + } + + if (dev.verified !== verificationStatus || dev.known !== knownStatus) { + dev.verified = verificationStatus; + dev.known = knownStatus; + this.deviceList.storeDevicesForUser(userId, devices); + this.deviceList.saveIfDirty(); + } + + // do cross-signing + if (verified && userId === this.userId) { + logger.info("Own device " + deviceId + " marked verified: signing"); + + // Signing only needed if other device not already signed + let device; + const deviceTrust = this.checkDeviceTrust(userId, deviceId); + if (deviceTrust.isCrossSigningVerified()) { + logger.log(`Own device ${deviceId} already cross-signing verified`); + } else { + device = await this.crossSigningInfo.signDevice( + userId, DeviceInfo.fromStorage(dev, deviceId), + ); + } + + if (device) { + const upload = async ({ shouldEmit }) => { + logger.info("Uploading signature for " + deviceId); + const response = await this.baseApis.uploadKeySignatures({ + [userId]: { + [deviceId]: device, + }, + }); + const { failures } = response || {}; + if (Object.keys(failures || []).length > 0) { + if (shouldEmit) { + this.baseApis.emit( + "crypto.keySignatureUploadFailure", + failures, + "setDeviceVerification", + upload, // continuation + ); + } + throw new KeySignatureUploadError("Key upload failed", { failures }); + } + }; + await upload({ shouldEmit: true }); + // XXX: we'll need to wait for the device list to be updated + } + } + + const deviceObj = DeviceInfo.fromStorage(dev, deviceId); + this.emit("deviceVerificationChanged", userId, deviceId, deviceObj); + return deviceObj; + } + + public findVerificationRequestDMInProgress(roomId: string): VerificationRequest { + return this.inRoomVerificationRequests.findRequestInProgress(roomId); + } + + public getVerificationRequestsToDeviceInProgress(userId: string): VerificationRequest { + return this.toDeviceVerificationRequests.getRequestsInProgress(userId); + } + + public requestVerificationDM(userId: string, roomId: string): VerificationRequest { + const existingRequest = this.inRoomVerificationRequests.findRequestInProgress(roomId); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new InRoomChannel(this.baseApis, roomId, userId); + return this.requestVerificationWithChannel( + userId, + channel, + this.inRoomVerificationRequests, + ); + } + + public requestVerification(userId: string, devices: string[]): VerificationRequest { + if (!devices) { + devices = Object.keys(this.deviceList.getRawStoredDevicesForUser(userId)); + } + const existingRequest = this.toDeviceVerificationRequests.findRequestInProgress(userId, devices); + if (existingRequest) { + return Promise.resolve(existingRequest); + } + const channel = new ToDeviceChannel(this.baseApis, userId, devices, ToDeviceChannel.makeTransactionId()); + return this.requestVerificationWithChannel( + userId, + channel, + this.toDeviceVerificationRequests, + ); + } + + private async requestVerificationWithChannel( + userId: string, + channel: any, // TODO types + requestsMap: any, // TODO types + ): VerificationRequest { + let request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + // if transaction id is already known, add request + if (channel.transactionId) { + requestsMap.setRequestByChannel(channel, request); + } + await request.sendRequest(); + // don't replace the request created by a racing remote echo + const racingRequest = requestsMap.getRequestByChannel(channel); + if (racingRequest) { + request = racingRequest; + } else { + logger.log(`Crypto: adding new request to ` + + `requestsByTxnId with id ${channel.transactionId} ${channel.roomId}`); + requestsMap.setRequestByChannel(channel, request); + } + return request; + } + + public beginKeyVerification( + method: string, + userId: string, + deviceId: string, + transactionId: string = null, + ): any { // TODO types + let request; + if (transactionId) { + request = this.toDeviceVerificationRequests.getRequestBySenderAndTxnId(userId, transactionId); + if (!request) { + throw new Error( + `No request found for user ${userId} with ` + + `transactionId ${transactionId}`); + } + } else { + transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel(this.baseApis, userId, [deviceId], transactionId, deviceId); + request = new VerificationRequest(channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId(userId, transactionId, request); + } + return request.beginKeyVerification(method, { userId, deviceId }); + } + + public async legacyDeviceVerification( + userId: string, + deviceId: string, + method: string, + ): VerificationRequest { + const transactionId = ToDeviceChannel.makeTransactionId(); + const channel = new ToDeviceChannel( + this.baseApis, userId, [deviceId], transactionId, deviceId); + const request = new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + this.toDeviceVerificationRequests.setRequestBySenderAndTxnId( + userId, transactionId, request); + const verifier = request.beginKeyVerification(method, { userId, deviceId }); + // either reject by an error from verify() while sending .start + // or resolve when the request receives the + // local (fake remote) echo for sending the .start event + await Promise.race([ + verifier.verify(), + request.waitFor(r => r.started), + ]); + return request; + } + + /** + * Get information on the active olm sessions with a user + *

+ * Returns a map from device id to an object with keys 'deviceIdKey' (the + * device's curve25519 identity key) and 'sessions' (an array of objects in the + * same format as that returned by + * {@link module:crypto/OlmDevice#getSessionInfoForDevice}). + *

+ * This method is provided for debugging purposes. + * + * @param {string} userId id of user to inspect + * + * @return {Promise>} + */ + public async getOlmSessionsForUser(userId: string): Promise> { + const devices = this.getStoredDevicesForUser(userId) || []; + const result = {}; + for (let j = 0; j < devices.length; ++j) { + const device = devices[j]; + const deviceKey = device.getIdentityKey(); + const sessions = await this.olmDevice.getSessionInfoForDevice(deviceKey); + + result[device.deviceId] = { + deviceIdKey: deviceKey, + sessions: sessions, + }; + } + return result; + } + + /** + * Get the device which sent an event + * + * @param {module:models/event.MatrixEvent} event event to be checked + * + * @return {module:crypto/deviceinfo?} + */ + public getEventSenderDeviceInfo(event: MatrixEvent): DeviceInfo | null { + const senderKey = event.getSenderKey(); + const algorithm = event.getWireContent().algorithm; + + if (!senderKey || !algorithm) { + return null; + } + + const forwardingChain = event.getForwardingCurve25519KeyChain(); + if (forwardingChain.length > 0) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + return null; + } + + if (event.isKeySourceUntrusted()) { + // we got the key for this event from a source that we consider untrusted + return null; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + const device = this.deviceList.getDeviceByIdentityKey( + algorithm, senderKey, + ); + + if (device === null) { + // we haven't downloaded the details of this device yet. + return null; + } + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + + "cannot verify sending device"); + return null; + } + + if (claimedKey !== device.getFingerprint()) { + logger.warn( + "Event " + event.getId() + " claims ed25519 key " + claimedKey + + " but sender device has key " + device.getFingerprint()); + return null; + } + + return device; + } + + /** + * Get information about the encryption of an event + * + * @param {module:models/event.MatrixEvent} event event to be checked + * + * @return {object} An object with the fields: + * - encrypted: whether the event is encrypted (if not encrypted, some of the + * other properties may not be set) + * - senderKey: the sender's key + * - algorithm: the algorithm used to encrypt the event + * - authenticated: whether we can be sure that the owner of the senderKey + * sent the event + * - sender: the sender's device information, if available + * - mismatchedSender: if the event's ed25519 and curve25519 keys don't match + * (only meaningful if `sender` is set) + */ + public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo { + const ret: Partial = {}; + + ret.senderKey = event.getSenderKey(); + ret.algorithm = event.getWireContent().algorithm; + + if (!ret.senderKey || !ret.algorithm) { + ret.encrypted = false; + return ret as IEncryptedEventInfo; + } + ret.encrypted = true; + + const forwardingChain = event.getForwardingCurve25519KeyChain(); + if (forwardingChain.length > 0 || event.isKeySourceUntrusted()) { + // we got the key this event from somewhere else + // TODO: check if we can trust the forwarders. + ret.authenticated = false; + } else { + ret.authenticated = true; + } + + // senderKey is the Curve25519 identity key of the device which the event + // was sent from. In the case of Megolm, it's actually the Curve25519 + // identity key of the device which set up the Megolm session. + + ret.sender = this.deviceList.getDeviceByIdentityKey(ret.algorithm, ret.senderKey); + + // so far so good, but now we need to check that the sender of this event + // hadn't advertised someone else's Curve25519 key as their own. We do that + // by checking the Ed25519 claimed by the event (or, in the case of megolm, + // the event which set up the megolm session), to check that it matches the + // fingerprint of the purported sending device. + // + // (see https://github.com/vector-im/vector-web/issues/2215) + + const claimedKey = event.getClaimedEd25519Key(); + if (!claimedKey) { + logger.warn("Event " + event.getId() + " claims no ed25519 key: " + + "cannot verify sending device"); + ret.mismatchedSender = true; + } + + if (ret.sender && claimedKey !== ret.sender.getFingerprint()) { + logger.warn( + "Event " + event.getId() + " claims ed25519 key " + claimedKey + + "but sender device has key " + ret.sender.getFingerprint()); + ret.mismatchedSender = true; + } + + return ret as IEncryptedEventInfo; + } + + /** + * Forces the current outbound group session to be discarded such + * that another one will be created next time an event is sent. + * + * @param {string} roomId The ID of the room to discard the session for + * + * This should not normally be necessary. + */ + public forceDiscardSession(roomId: string): void { + const alg = this.roomEncryptors[roomId]; + if (alg === undefined) throw new Error("Room not encrypted"); + if (alg.forceDiscardSession === undefined) { + throw new Error("Room encryption algorithm doesn't support session discarding"); + } + alg.forceDiscardSession(); + } + + /** + * Configure a room to use encryption (ie, save a flag in the cryptoStore). + * + * @param {string} roomId The room ID to enable encryption in. + * + * @param {object} config The encryption config for the room. + * + * @param {boolean=} inhibitDeviceQuery true to suppress device list query for + * users in the room (for now). In case lazy loading is enabled, + * the device query is always inhibited as the members are not tracked. + */ + public async setRoomEncryption( + roomId: string, + config: any, // TODO types + inhibitDeviceQuery?: boolean, + ): Promise { + // ignore crypto events with no algorithm defined + // This will happen if a crypto event is redacted before we fetch the room state + // It would otherwise just throw later as an unknown algorithm would, but we may + // as well catch this here + if (!config.algorithm) { + logger.log("Ignoring setRoomEncryption with no algorithm"); + return; + } + + // if state is being replayed from storage, we might already have a configuration + // for this room as they are persisted as well. + // We just need to make sure the algorithm is initialized in this case. + // However, if the new config is different, + // we should bail out as room encryption can't be changed once set. + const existingConfig = this.roomList.getRoomEncryption(roomId); + if (existingConfig) { + if (JSON.stringify(existingConfig) != JSON.stringify(config)) { + logger.error("Ignoring m.room.encryption event which requests " + + "a change of config in " + roomId); + return; + } + } + // if we already have encryption in this room, we should ignore this event, + // as it would reset the encryption algorithm. + // This is at least expected to be called twice, as sync calls onCryptoEvent + // for both the timeline and state sections in the /sync response, + // the encryption event would appear in both. + // If it's called more than twice though, + // it signals a bug on client or server. + const existingAlg = this.roomEncryptors[roomId]; + if (existingAlg) { + return; + } + + // _roomList.getRoomEncryption will not race with _roomList.setRoomEncryption + // because it first stores in memory. We should await the promise only + // after all the in-memory state (roomEncryptors and _roomList) has been updated + // to avoid races when calling this method multiple times. Hence keep a hold of the promise. + let storeConfigPromise = null; + if (!existingConfig) { + storeConfigPromise = this.roomList.setRoomEncryption(roomId, config); + } + + const AlgClass = algorithms.ENCRYPTION_CLASSES[config.algorithm]; + if (!AlgClass) { + throw new Error("Unable to encrypt with " + config.algorithm); + } + + const alg = new AlgClass({ + userId: this.userId, + deviceId: this.deviceId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId, + config: config, + }); + this.roomEncryptors[roomId] = alg; + + if (storeConfigPromise) { + await storeConfigPromise; + } + + if (!this.lazyLoadMembers) { + logger.log("Enabling encryption in " + roomId + "; " + + "starting to track device lists for all users therein"); + + await this.trackRoomDevices(roomId); + // TODO: this flag is only not used from MatrixClient::setRoomEncryption + // which is never used (inside Element at least) + // but didn't want to remove it as it technically would + // be a breaking change. + if (!inhibitDeviceQuery) { + this.deviceList.refreshOutdatedDeviceLists(); + } + } else { + logger.log("Enabling encryption in " + roomId); + } + } + + /** + * Make sure we are tracking the device lists for all users in this room. + * + * @param {string} roomId The room ID to start tracking devices in. + * @returns {Promise} when all devices for the room have been fetched and marked to track + */ + public trackRoomDevices(roomId: string): Promise { + const trackMembers = async () => { + // not an encrypted room + if (!this.roomEncryptors[roomId]) { + return; + } + const room = this.clientStore.getRoom(roomId); + if (!room) { + throw new Error(`Unable to start tracking devices in unknown room ${roomId}`); + } + logger.log(`Starting to track devices for room ${roomId} ...`); + const members = await room.getEncryptionTargetMembers(); + members.forEach((m) => { + this.deviceList.startTrackingDeviceList(m.userId); + }); + }; + + let promise = this.roomDeviceTrackingState[roomId]; + if (!promise) { + promise = trackMembers(); + this.roomDeviceTrackingState[roomId] = promise.catch(err => { + this.roomDeviceTrackingState[roomId] = null; + throw err; + }); + } + return promise; + } + + /** + * Try to make sure we have established olm sessions for all known devices for + * the given users. + * + * @param {string[]} users list of user ids + * + * @return {Promise} resolves once the sessions are complete, to + * an Object mapping from userId to deviceId to + * {@link module:crypto~OlmSessionResult} + */ + ensureOlmSessionsForUsers(users: string[]): Promise>> { + const devicesByUser = {}; + + for (let i = 0; i < users.length; ++i) { + const userId = users[i]; + devicesByUser[userId] = []; + + const devices = this.getStoredDevicesForUser(userId) || []; + for (let j = 0; j < devices.length; ++j) { + const deviceInfo = devices[j]; + + const key = deviceInfo.getIdentityKey(); + if (key == this.olmDevice.deviceCurve25519Key) { + // don't bother setting up session to ourself + continue; + } + if (deviceInfo.verified == DeviceVerification.BLOCKED) { + // don't bother setting up sessions with blocked users + continue; + } + + devicesByUser[userId].push(deviceInfo); + } + } + + return olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser); + } + + /** + * Get a list containing all of the room keys + * + * @return {module:crypto/OlmDevice.MegolmSessionData[]} a list of session export objects + */ + public async exportRoomKeys(): Promise { + const exportedSessions = []; + await this.cryptoStore.doTxn( + 'readonly', [IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS], (txn) => { + this.cryptoStore.getAllEndToEndInboundGroupSessions(txn, (s) => { + if (s === null) return; + + const sess = this.olmDevice.exportInboundGroupSession( + s.senderKey, s.sessionId, s.sessionData, + ); + delete sess.first_known_index; + sess.algorithm = olmlib.MEGOLM_ALGORITHM; + exportedSessions.push(sess); + }); + }, + ); + + return exportedSessions; + } + + /** + * Import a list of room keys previously exported by exportRoomKeys + * + * @param {Object[]} keys a list of session export objects + * @param {Object} opts + * @param {Function} opts.progressCallback called with an object which has a stage param + * @return {Promise} a promise which resolves once the keys have been imported + */ + public importRoomKeys(keys: IMegolmSessionData[], opts: any = {}): Promise { // TODO types + let successes = 0; + let failures = 0; + const total = keys.length; + + function updateProgress() { + opts.progressCallback({ + stage: "load_keys", + successes, + failures, + total, + }); + } + + return Promise.all(keys.map((key) => { + if (!key.room_id || !key.algorithm) { + logger.warn("ignoring room key entry with missing fields", key); + failures++; + if (opts.progressCallback) { updateProgress(); } + return null; + } + + const alg = this.getRoomDecryptor(key.room_id, key.algorithm); + return alg.importRoomKey(key, opts).finally(() => { + successes++; + if (opts.progressCallback) { updateProgress(); } + }); + })); + } + + /** + * Counts the number of end to end session keys that are waiting to be backed up + * @returns {Promise} Resolves to the number of sessions requiring backup + */ + public countSessionsNeedingBackup(): Promise { + return this.backupManager.countSessionsNeedingBackup(); + } + + /** + * Perform any background tasks that can be done before a message is ready to + * send, in order to speed up sending of the message. + * + * @param {module:models/room} room the room the event is in + */ + public prepareToEncrypt(room: Room): void { + const alg = this.roomEncryptors[room.roomId]; + if (alg) { + alg.prepareToEncrypt(room); + } + } + + /* eslint-disable valid-jsdoc */ //https://github.com/eslint/eslint/issues/7307 + /** + * Encrypt an event according to the configuration of the room. + * + * @param {module:models/event.MatrixEvent} event event to be sent + * + * @param {module:models/room} room destination room. + * + * @return {Promise?} Promise which resolves when the event has been + * encrypted, or null if nothing was needed + */ + /* eslint-enable valid-jsdoc */ + // TODO this return type lies + public async encryptEvent(event: MatrixEvent, room: Room): Promise { + if (!room) { + throw new Error("Cannot send encrypted messages in unknown rooms"); + } + + const roomId = event.getRoomId(); + + const alg = this.roomEncryptors[roomId]; + if (!alg) { + // MatrixClient has already checked that this room should be encrypted, + // so this is an unexpected situation. + throw new Error( + "Room was previously configured to use encryption, but is " + + "no longer. Perhaps the homeserver is hiding the " + + "configuration event.", + ); + } + + if (!this.roomDeviceTrackingState[roomId]) { + this.trackRoomDevices(roomId); + } + // wait for all the room devices to be loaded + await this.roomDeviceTrackingState[roomId]; + + let content = event.getContent(); + // If event has an m.relates_to then we need + // to put this on the wrapping event instead + const mRelatesTo = content['m.relates_to']; + if (mRelatesTo) { + // Clone content here so we don't remove `m.relates_to` from the local-echo + content = Object.assign({}, content); + delete content['m.relates_to']; + } + + const encryptedContent = await alg.encryptMessage( + room, event.getType(), content); + + if (mRelatesTo) { + encryptedContent['m.relates_to'] = mRelatesTo; + } + + event.makeEncrypted( + "m.room.encrypted", + encryptedContent, + this.olmDevice.deviceCurve25519Key, + this.olmDevice.deviceEd25519Key, + ); + } + + /** + * Decrypt a received event + * + * @param {MatrixEvent} event + * + * @return {Promise} resolves once we have + * finished decrypting. Rejects with an `algorithms.DecryptionError` if there + * is a problem decrypting the event. + */ + public async decryptEvent(event: MatrixEvent): Promise { + if (event.isRedacted()) { + const redactionEvent = new MatrixEvent(event.getUnsigned().redacted_because); + const decryptedEvent = await this.decryptEvent(redactionEvent); + + return { + clearEvent: { + room_id: event.getRoomId(), + type: "m.room.message", + content: {}, + unsigned: { + redacted_because: decryptedEvent.clearEvent, + }, + }, + }; + } else { + const content = event.getWireContent(); + const alg = this.getRoomDecryptor(event.getRoomId(), content.algorithm); + return await alg.decryptEvent(event); + } + } + + /** + * Handle the notification from /sync or /keys/changes that device lists have + * been changed. + * + * @param {Object} syncData Object containing sync tokens associated with this sync + * @param {Object} syncDeviceLists device_lists field from /sync, or response from + * /keys/changes + */ + public async handleDeviceListChanges(syncData: ISyncStateData, syncDeviceLists: ISyncDeviceLists): Promise { + // Initial syncs don't have device change lists. We'll either get the complete list + // of changes for the interval or will have invalidated everything in willProcessSync + if (!syncData.oldSyncToken) return; + + // Here, we're relying on the fact that we only ever save the sync data after + // sucessfully saving the device list data, so we're guaranteed that the device + // list store is at least as fresh as the sync token from the sync store, ie. + // any device changes received in sync tokens prior to the 'next' token here + // have been processed and are reflected in the current device list. + // If we didn't make this assumption, we'd have to use the /keys/changes API + // to get key changes between the sync token in the device list and the 'old' + // sync token used here to make sure we didn't miss any. + await this.evalDeviceListChanges(syncDeviceLists); + } + + /** + * Send a request for some room keys, if we have not already done so + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * @param {Array<{userId: string, deviceId: string}>} recipients + * @param {boolean} resend whether to resend the key request if there is + * already one + * + * @return {Promise} a promise that resolves when the key request is queued + */ + public requestRoomKey( + requestBody: IRoomKeyRequestBody, + recipients: IRoomKeyRequestRecipient[], + resend = false, + ): Promise { + return this.outgoingRoomKeyRequestManager.queueRoomKeyRequest( + requestBody, recipients, resend, + ).then(() => { + if (this.sendKeyRequestsImmediately) { + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + } + }).catch((e) => { + // this normally means we couldn't talk to the store + logger.error( + 'Error requesting key for event', e, + ); + }); + } + + /** + * Cancel any earlier room key request + * + * @param {module:crypto~RoomKeyRequestBody} requestBody + * parameters to match for cancellation + */ + public cancelRoomKeyRequest(requestBody: IRoomKeyRequestBody): void { + this.outgoingRoomKeyRequestManager.cancelRoomKeyRequest(requestBody) + .catch((e) => { + logger.warn("Error clearing pending room key requests", e); + }); + } + + /** + * Re-send any outgoing key requests, eg after verification + * @returns {Promise} + */ + public async cancelAndResendAllOutgoingKeyRequests(): Promise { + await this.outgoingRoomKeyRequestManager.cancelAndResendAllOutgoingRequests(); + } + + /** + * handle an m.room.encryption event + * + * @param {module:models/event.MatrixEvent} event encryption event + */ + public async onCryptoEvent(event: MatrixEvent): Promise { + const roomId = event.getRoomId(); + const content = event.getContent(); + + try { + // inhibit the device list refresh for now - it will happen once we've + // finished processing the sync, in onSyncCompleted. + await this.setRoomEncryption(roomId, content, true); + } catch (e) { + logger.error("Error configuring encryption in room " + roomId + + ":", e); + } + } + + /** + * Called before the result of a sync is processed + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ + public async onSyncWillProcess(syncData: ISyncStateData): Promise { + if (!syncData.oldSyncToken) { + // If there is no old sync token, we start all our tracking from + // scratch, so mark everything as untracked. onCryptoEvent will + // be called for all e2e rooms during the processing of the sync, + // at which point we'll start tracking all the users of that room. + logger.log("Initial sync performed - resetting device tracking state"); + this.deviceList.stopTrackingAllDeviceLists(); + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + this.roomDeviceTrackingState = {}; + } + + this.sendKeyRequestsImmediately = false; + } + + /** + * handle the completion of a /sync + * + * This is called after the processing of each successful /sync response. + * It is an opportunity to do a batch process on the information received. + * + * @param {Object} syncData the data from the 'MatrixClient.sync' event + */ + public async onSyncCompleted(syncData: ISyncStateData): Promise { + this.deviceList.setSyncToken(syncData.nextSyncToken); + this.deviceList.saveIfDirty(); + + // we always track our own device list (for key backups etc) + this.deviceList.startTrackingDeviceList(this.userId); + + this.deviceList.refreshOutdatedDeviceLists(); + + // we don't start uploading one-time keys until we've caught up with + // to-device messages, to help us avoid throwing away one-time-keys that we + // are about to receive messages for + // (https://github.com/vector-im/element-web/issues/2782). + if (!syncData.catchingUp) { + this.maybeUploadOneTimeKeys(); + this.processReceivedRoomKeyRequests(); + + // likewise don't start requesting keys until we've caught up + // on to_device messages, otherwise we'll request keys that we're + // just about to get. + this.outgoingRoomKeyRequestManager.sendQueuedRequests(); + + // Sync has finished so send key requests straight away. + this.sendKeyRequestsImmediately = true; + } + } + + /** + * Trigger the appropriate invalidations and removes for a given + * device list + * + * @param {Object} deviceLists device_lists field from /sync, or response from + * /keys/changes + */ + private async evalDeviceListChanges(deviceLists: ISyncDeviceLists): Promise { + if (deviceLists.changed && Array.isArray(deviceLists.changed)) { + deviceLists.changed.forEach((u) => { + this.deviceList.invalidateUserDeviceList(u); + }); + } + + if (deviceLists.left && Array.isArray(deviceLists.left) && + deviceLists.left.length) { + // Check we really don't share any rooms with these users + // any more: the server isn't required to give us the + // exact correct set. + const e2eUserIds = new Set(await this.getTrackedE2eUsers()); + + deviceLists.left.forEach((u) => { + if (!e2eUserIds.has(u)) { + this.deviceList.stopTrackingDeviceList(u); + } + }); + } + } + + /** + * Get a list of all the IDs of users we share an e2e room with + * for which we are tracking devices already + * + * @returns {string[]} List of user IDs + */ + private async getTrackedE2eUsers(): Promise { + const e2eUserIds = []; + for (const room of this.getTrackedE2eRooms()) { + const members = await room.getEncryptionTargetMembers(); + for (const member of members) { + e2eUserIds.push(member.userId); + } + } + return e2eUserIds; + } + + /** + * Get a list of the e2e-enabled rooms we are members of, + * and for which we are already tracking the devices + * + * @returns {module:models.Room[]} + */ + private getTrackedE2eRooms(): Room[] { + return this.clientStore.getRooms().filter((room) => { + // check for rooms with encryption enabled + const alg = this.roomEncryptors[room.roomId]; + if (!alg) { + return false; + } + if (!this.roomDeviceTrackingState[room.roomId]) { + return false; + } + + // ignore any rooms which we have left + const myMembership = room.getMyMembership(); + return myMembership === "join" || myMembership === "invite"; + }); + } + + private onToDeviceEvent = (event: MatrixEvent): void => { + try { + logger.log(`received to_device ${event.getType()} from: ` + + `${event.getSender()} id: ${event.getId()}`); + + if (event.getType() == "m.room_key" + || event.getType() == "m.forwarded_room_key") { + this.onRoomKeyEvent(event); + } else if (event.getType() == "m.room_key_request") { + this.onRoomKeyRequestEvent(event); + } else if (event.getType() === "m.secret.request") { + this.secretStorage._onRequestReceived(event); + } else if (event.getType() === "m.secret.send") { + this.secretStorage._onSecretReceived(event); + } else if (event.getType() === "org.matrix.room_key.withheld") { + this.onRoomKeyWithheldEvent(event); + } else if (event.getContent().transaction_id) { + this.onKeyVerificationMessage(event); + } else if (event.getContent().msgtype === "m.bad.encrypted") { + this.onToDeviceBadEncrypted(event); + } else if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + if (!event.isBeingDecrypted()) { + event.attemptDecryption(this); + } + // once the event has been decrypted, try again + event.once('Event.decrypted', (ev) => { + this.onToDeviceEvent(ev); + }); + } + } catch (e) { + logger.error("Error handling toDeviceEvent:", e); + } + }; + + /** + * Handle a key event + * + * @private + * @param {module:models/event.MatrixEvent} event key event + */ + private onRoomKeyEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if (!content.room_id || !content.algorithm) { + logger.error("key event is missing fields"); + return; + } + + if (!this.backupManager.checkedForBackup) { + // don't bother awaiting on this - the important thing is that we retry if we + // haven't managed to check before + this.backupManager.checkAndStart(); + } + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + alg.onRoomKeyEvent(event); + } + + /** + * Handle a key withheld event + * + * @private + * @param {module:models/event.MatrixEvent} event key withheld event + */ + private onRoomKeyWithheldEvent(event: MatrixEvent): void { + const content = event.getContent(); + + if ((content.code !== "m.no_olm" && (!content.room_id || !content.session_id)) + || !content.algorithm || !content.sender_key) { + logger.error("key withheld event is missing fields"); + return; + } + + logger.info( + `Got room key withheld event from ${event.getSender()} (${content.sender_key}) ` + + `for ${content.algorithm}/${content.room_id}/${content.session_id} ` + + `with reason ${content.code} (${content.reason})`, + ); + + const alg = this.getRoomDecryptor(content.room_id, content.algorithm); + if (alg.onRoomKeyWithheldEvent) { + alg.onRoomKeyWithheldEvent(event); + } + if (!content.room_id) { + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const roomDecryptors = this.getRoomDecryptors(content.algorithm); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(content.sender_key); + } + } + } + + /** + * Handle a general key verification event. + * + * @private + * @param {module:models/event.MatrixEvent} event verification start event + */ + private onKeyVerificationMessage(event: MatrixEvent): void { + if (!ToDeviceChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + if (!ToDeviceChannel.canCreateRequest(ToDeviceChannel.getEventType(event))) { + return; + } + const content = event.getContent(); + const deviceId = content && content.from_device; + if (!deviceId) { + return; + } + const userId = event.getSender(); + const channel = new ToDeviceChannel( + this.baseApis, + userId, + [deviceId], + ); + return new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent( + event, + this.toDeviceVerificationRequests, + createRequest, + ); + } + + /** + * Handle key verification requests sent as timeline events + * + * @private + * @param {module:models/event.MatrixEvent} event the timeline event + * @param {module:models/Room} room not used + * @param {boolean} atStart not used + * @param {boolean} removed not used + * @param {boolean} { liveEvent } whether this is a live event + */ + private onTimelineEvent = ( + event: MatrixEvent, + room: Room, + atStart: boolean, + removed: boolean, + { liveEvent = true } = {}, + ): void => { + if (!InRoomChannel.validateEvent(event, this.baseApis)) { + return; + } + const createRequest = event => { + const channel = new InRoomChannel( + this.baseApis, + event.getRoomId(), + ); + return new VerificationRequest( + channel, this.verificationMethods, this.baseApis); + }; + this.handleVerificationEvent( + event, + this.inRoomVerificationRequests, + createRequest, + liveEvent, + ); + }; + + private async handleVerificationEvent( + event: MatrixEvent, + requestsMap: any, // TODO types + createRequest: any, // TODO types + isLiveEvent = true, + ): Promise { + let request = requestsMap.getRequest(event); + let isNewRequest = false; + if (!request) { + request = createRequest(event); + // a request could not be made from this event, so ignore event + if (!request) { + logger.log(`Crypto: could not find VerificationRequest for ` + + `${event.getType()}, and could not create one, so ignoring.`); + return; + } + isNewRequest = true; + requestsMap.setRequest(event, request); + } + event.setVerificationRequest(request); + try { + await request.channel.handleEvent(event, request, isLiveEvent); + } catch (err) { + logger.error("error while handling verification event: " + err.message); + } + const shouldEmit = isNewRequest && + !request.initiatedByMe && + !request.invalid && // check it has enough events to pass the UNSENT stage + !request.observeOnly; + if (shouldEmit) { + this.baseApis.emit("crypto.verification.request", request); + } + } + + /** + * Handle a toDevice event that couldn't be decrypted + * + * @private + * @param {module:models/event.MatrixEvent} event undecryptable event + */ + private async onToDeviceBadEncrypted(event: MatrixEvent): Promise { + const content = event.getWireContent(); + const sender = event.getSender(); + const algorithm = content.algorithm; + const deviceKey = content.sender_key; + + // retry decryption for all events sent by the sender_key. This will + // update the events to show a message indicating that the olm session was + // wedged. + const retryDecryption = () => { + const roomDecryptors = this.getRoomDecryptors(olmlib.MEGOLM_ALGORITHM); + for (const decryptor of roomDecryptors) { + decryptor.retryDecryptionFromSender(deviceKey); + } + }; + + if (sender === undefined || deviceKey === undefined || deviceKey === undefined) { + return; + } + + // check when we last forced a new session with this device: if we've already done so + // recently, don't do it again. + this.lastNewSessionForced[sender] = this.lastNewSessionForced[sender] || {}; + const lastNewSessionForced = this.lastNewSessionForced[sender][deviceKey] || 0; + if (lastNewSessionForced + MIN_FORCE_SESSION_INTERVAL_MS > Date.now()) { + logger.debug( + "New session already forced with device " + sender + ":" + deviceKey + + " at " + lastNewSessionForced + ": not forcing another", + ); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + return; + } + + // establish a new olm session with this device since we're failing to decrypt messages + // on a current session. + // Note that an undecryptable message from another device could easily be spoofed - + // is there anything we can do to mitigate this? + let device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + // if we don't know about the device, fetch the user's devices again + // and retry before giving up + await this.downloadKeys([sender], false); + device = this.deviceList.getDeviceByIdentityKey(algorithm, deviceKey); + if (!device) { + logger.info( + "Couldn't find device for identity key " + deviceKey + + ": not re-establishing session", + ); + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", false); + retryDecryption(); + return; + } + } + const devicesByUser = {}; + devicesByUser[sender] = [device]; + await olmlib.ensureOlmSessionsForDevices(this.olmDevice, this.baseApis, devicesByUser, true); + + this.lastNewSessionForced[sender][deviceKey] = Date.now(); + + // Now send a blank message on that session so the other side knows about it. + // (The keyshare request is sent in the clear so that won't do) + // We send this first such that, as long as the toDevice messages arrive in the + // same order we sent them, the other end will get this first, set up the new session, + // then get the keyshare request and send the key over this new session (because it + // is the session it has most recently received a message on). + const encryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key, + ciphertext: {}, + }; + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + sender, + device, + { type: "m.dummy" }, + ); + + await this.olmDevice.recordSessionProblem(deviceKey, "wedged", true); + retryDecryption(); + + await this.baseApis.sendToDevice("m.room.encrypted", { + [sender]: { + [device.deviceId]: encryptedContent, + }, + }); + + // Most of the time this probably won't be necessary since we'll have queued up a key request when + // we failed to decrypt the message and will be waiting a bit for the key to arrive before sending + // it. This won't always be the case though so we need to re-send any that have already been sent + // to avoid races. + const requestsToResend = + await this.outgoingRoomKeyRequestManager.getOutgoingSentRoomKeyRequest(sender, device.deviceId); + for (const keyReq of requestsToResend) { + this.requestRoomKey(keyReq.requestBody, keyReq.recipients, true); + } + } + + /** + * Handle a change in the membership state of a member of a room + * + * @private + * @param {module:models/event.MatrixEvent} event event causing the change + * @param {module:models/room-member} member user whose membership changed + * @param {string=} oldMembership previous membership + */ + private onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void { + // this event handler is registered on the *client* (as opposed to the room + // member itself), which means it is only called on changes to the *live* + // membership state (ie, it is not called when we back-paginate, nor when + // we load the state in the initialsync). + // + // Further, it is automatically registered and called when new members + // arrive in the room. + + const roomId = member.roomId; + + const alg = this.roomEncryptors[roomId]; + if (!alg) { + // not encrypting in this room + return; + } + // only mark users in this room as tracked if we already started tracking in this room + // this way we don't start device queries after sync on behalf of this room which we won't use + // the result of anyway, as we'll need to do a query again once all the members are fetched + // by calling _trackRoomDevices + if (this.roomDeviceTrackingState[roomId]) { + if (member.membership == 'join') { + logger.log('Join event for ' + member.userId + ' in ' + roomId); + // make sure we are tracking the deviceList for this user + this.deviceList.startTrackingDeviceList(member.userId); + } else if (member.membership == 'invite' && + this.clientStore.getRoom(roomId).shouldEncryptForInvitedMembers()) { + logger.log('Invite event for ' + member.userId + ' in ' + roomId); + this.deviceList.startTrackingDeviceList(member.userId); + } + } + + alg.onRoomMembership(event, member, oldMembership); + } + + /** + * Called when we get an m.room_key_request event. + * + * @private + * @param {module:models/event.MatrixEvent} event key request event + */ + private onRoomKeyRequestEvent(event: MatrixEvent): void { + const content = event.getContent(); + if (content.action === "request") { + // Queue it up for now, because they tend to arrive before the room state + // events at initial sync, and we want to see if we know anything about the + // room before passing them on to the app. + const req = new IncomingRoomKeyRequest(event); + this.receivedRoomKeyRequests.push(req); + } else if (content.action === "request_cancellation") { + const req = new IncomingRoomKeyRequestCancellation(event); + this.receivedRoomKeyRequestCancellations.push(req); + } + } + + /** + * Process any m.room_key_request events which were queued up during the + * current sync. + * + * @private + */ + private async processReceivedRoomKeyRequests(): Promise { + if (this.processingRoomKeyRequests) { + // we're still processing last time's requests; keep queuing new ones + // up for now. + return; + } + this.processingRoomKeyRequests = true; + + try { + // we need to grab and clear the queues in the synchronous bit of this method, + // so that we don't end up racing with the next /sync. + const requests = this.receivedRoomKeyRequests; + this.receivedRoomKeyRequests = []; + const cancellations = this.receivedRoomKeyRequestCancellations; + this.receivedRoomKeyRequestCancellations = []; + + // Process all of the requests, *then* all of the cancellations. + // + // This makes sure that if we get a request and its cancellation in the + // same /sync result, then we process the request before the + // cancellation (and end up with a cancelled request), rather than the + // cancellation before the request (and end up with an outstanding + // request which should have been cancelled.) + await Promise.all(requests.map((req) => + this.processReceivedRoomKeyRequest(req))); + await Promise.all(cancellations.map((cancellation) => + this.processReceivedRoomKeyRequestCancellation(cancellation))); + } catch (e) { + logger.error(`Error processing room key requsts: ${e}`); + } finally { + this.processingRoomKeyRequests = false; + } + } + + /** + * Helper for processReceivedRoomKeyRequests + * + * @param {IncomingRoomKeyRequest} req + */ + private async processReceivedRoomKeyRequest(req: IncomingRoomKeyRequest): Promise { + const userId = req.userId; + const deviceId = req.deviceId; + + const body = req.requestBody; + const roomId = body.room_id; + const alg = body.algorithm; + + logger.log(`m.room_key_request from ${userId}:${deviceId}` + + ` for ${roomId} / ${body.session_id} (id ${req.requestId})`); + + if (userId !== this.userId) { + if (!this.roomEncryptors[roomId]) { + logger.debug(`room key request for unencrypted room ${roomId}`); + return; + } + const encryptor = this.roomEncryptors[roomId]; + const device = this.deviceList.getStoredDevice(userId, deviceId); + if (!device) { + logger.debug(`Ignoring keyshare for unknown device ${userId}:${deviceId}`); + return; + } + + try { + await encryptor.reshareKeyWithDevice(body.sender_key, body.session_id, userId, device); + } catch (e) { + logger.warn( + "Failed to re-share keys for session " + body.session_id + + " with device " + userId + ":" + device.deviceId, e, + ); + } + return; + } + + if (deviceId === this.deviceId) { + // We'll always get these because we send room key requests to + // '*' (ie. 'all devices') which includes the sending device, + // so ignore requests from ourself because apart from it being + // very silly, it won't work because an Olm session cannot send + // messages to itself. + // The log here is probably superfluous since we know this will + // always happen, but let's log anyway for now just in case it + // causes issues. + logger.log("Ignoring room key request from ourselves"); + return; + } + + // todo: should we queue up requests we don't yet have keys for, + // in case they turn up later? + + // if we don't have a decryptor for this room/alg, we don't have + // the keys for the requested events, and can drop the requests. + if (!this.roomDecryptors[roomId]) { + logger.log(`room key request for unencrypted room ${roomId}`); + return; + } + + const decryptor = this.roomDecryptors[roomId][alg]; + if (!decryptor) { + logger.log(`room key request for unknown alg ${alg} in room ${roomId}`); + return; + } + + if (!await decryptor.hasKeysForKeyRequest(req)) { + logger.log( + `room key request for unknown session ${roomId} / ` + + body.session_id, + ); + return; + } + + req.share = () => { + decryptor.shareKeysWithDevice(req); + }; + + // if the device is verified already, share the keys + if (this.checkDeviceTrust(userId, deviceId).isVerified()) { + logger.log('device is already verified: sharing keys'); + req.share(); + return; + } + + this.emit("crypto.roomKeyRequest", req); + } + + /** + * Helper for processReceivedRoomKeyRequests + * + * @param {IncomingRoomKeyRequestCancellation} cancellation + */ + private async processReceivedRoomKeyRequestCancellation( + cancellation: IncomingRoomKeyRequestCancellation, + ): Promise { + logger.log( + `m.room_key_request cancellation for ${cancellation.userId}:` + + `${cancellation.deviceId} (id ${cancellation.requestId})`, + ); + + // we should probably only notify the app of cancellations we told it + // about, but we don't currently have a record of that, so we just pass + // everything through. + this.emit("crypto.roomKeyRequestCancellation", cancellation); + } + + /** + * Get a decryptor for a given room and algorithm. + * + * If we already have a decryptor for the given room and algorithm, return + * it. Otherwise try to instantiate it. + * + * @private + * + * @param {string?} roomId room id for decryptor. If undefined, a temporary + * decryptor is instantiated. + * + * @param {string} algorithm crypto algorithm + * + * @return {module:crypto.algorithms.base.DecryptionAlgorithm} + * + * @raises {module:crypto.algorithms.DecryptionError} if the algorithm is + * unknown + */ + public getRoomDecryptor(roomId: string, algorithm: string): DecryptionAlgorithm { + let decryptors: Record; + let alg: DecryptionAlgorithm; + + roomId = roomId || null; + if (roomId) { + decryptors = this.roomDecryptors[roomId]; + if (!decryptors) { + this.roomDecryptors[roomId] = decryptors = {}; + } + + alg = decryptors[algorithm]; + if (alg) { + return alg; + } + } + + const AlgClass = algorithms.DECRYPTION_CLASSES[algorithm]; + if (!AlgClass) { + throw new algorithms.DecryptionError( + 'UNKNOWN_ENCRYPTION_ALGORITHM', + 'Unknown encryption algorithm "' + algorithm + '".', + ); + } + alg = new AlgClass({ + userId: this.userId, + crypto: this, + olmDevice: this.olmDevice, + baseApis: this.baseApis, + roomId: roomId, + }); + + if (decryptors) { + decryptors[algorithm] = alg; + } + return alg; + } + + /** + * Get all the room decryptors for a given encryption algorithm. + * + * @param {string} algorithm The encryption algorithm + * + * @return {array} An array of room decryptors + */ + private getRoomDecryptors(algorithm: string): DecryptionAlgorithm[] { + const decryptors = []; + for (const d of Object.values(this.roomDecryptors)) { + if (algorithm in d) { + decryptors.push(d[algorithm]); + } + } + return decryptors; + } + + /** + * sign the given object with our ed25519 key + * + * @param {Object} obj Object to which we will add a 'signatures' property + */ + public async signObject(obj: object & ISignableObject): Promise { + 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] = await this.olmDevice.sign(anotherjson.stringify(obj)); + obj.signatures = sigs; + if (unsigned !== undefined) obj.unsigned = unsigned; + } +} + +/** + * Fix up the backup key, that may be in the wrong format due to a bug in a + * migration step. Some backup keys were stored as a comma-separated list of + * integers, rather than a base64-encoded byte array. If this function is + * passed a string that looks like a list of integers rather than a base64 + * string, it will attempt to convert it to the right format. + * + * @param {string} key the key to check + * @returns {null | string} If the key is in the wrong format, then the fixed + * key will be returned. Otherwise null will be returned. + * + */ +export function fixBackupKey(key: string): string | null { + if (typeof key !== "string" || key.indexOf(",") < 0) { + return null; + } + const fixedKey = Uint8Array.from(key.split(","), x => parseInt(x)); + return olmlib.encodeBase64(fixedKey); +} + +/** + * The parameters of a room key request. The details of the request may + * vary with the crypto algorithm, but the management and storage layers for + * outgoing requests expect it to have 'room_id' and 'session_id' properties. + * + * @typedef {Object} RoomKeyRequestBody + */ + +/** + * Represents a received m.room_key_request event + * + * @property {string} userId user requesting the key + * @property {string} deviceId device requesting the key + * @property {string} requestId unique id for the request + * @property {module:crypto~RoomKeyRequestBody} requestBody + * @property {function()} share callback which, when called, will ask + * the relevant crypto algorithm implementation to share the keys for + * this request. + */ +export class IncomingRoomKeyRequest { + public readonly userId: string; + public readonly deviceId: string; + public readonly requestId: string; + public readonly requestBody: IRoomKeyRequestBody; + public share: () => void; + + constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + this.requestBody = content.body || {}; + this.share = () => { + throw new Error("don't know how to share keys for this request yet"); + }; + } +} + +/** + * Represents a received m.room_key_request cancellation + * + * @property {string} userId user requesting the cancellation + * @property {string} deviceId device requesting the cancellation + * @property {string} requestId unique id for the request to be cancelled + */ +class IncomingRoomKeyRequestCancellation { + public readonly userId: string; + public readonly deviceId: string; + public readonly requestId: string; + + constructor(event: MatrixEvent) { + const content = event.getContent(); + + this.userId = event.getSender(); + this.deviceId = content.requesting_device_id; + this.requestId = content.request_id; + } +} + +/** + * The result of a (successful) call to decryptEvent. + * + * @typedef {Object} EventDecryptionResult + * + * @property {Object} clearEvent The plaintext payload for the event + * (typically containing type and content fields). + * + * @property {?string} senderCurve25519Key Key owned by the sender of this + * event. See {@link module:models/event.MatrixEvent#getSenderKey}. + * + * @property {?string} claimedEd25519Key ed25519 key claimed by the sender of + * this event. See + * {@link module:models/event.MatrixEvent#getClaimedEd25519Key}. + * + * @property {?Array} forwardingCurve25519KeyChain list of curve25519 + * keys involved in telling us about the senderCurve25519Key and + * claimedEd25519Key. See + * {@link module:models/event.MatrixEvent#getForwardingCurve25519KeyChain}. + */ + +/** + * Fires when we receive a room key request + * + * @event module:client~MatrixClient#"crypto.roomKeyRequest" + * @param {module:crypto~IncomingRoomKeyRequest} req request details + */ + +/** + * Fires when we receive a room key request cancellation + * + * @event module:client~MatrixClient#"crypto.roomKeyRequestCancellation" + * @param {module:crypto~IncomingRoomKeyRequestCancellation} req + */ + +/** + * Fires when the app may wish to warn the user about something related + * the end-to-end crypto. + * + * @event module:client~MatrixClient#"crypto.warning" + * @param {string} type One of the strings listed above + */ diff --git a/src/crypto/key_passphrase.js b/src/crypto/key_passphrase.ts similarity index 76% rename from src/crypto/key_passphrase.js rename to src/crypto/key_passphrase.ts index 84384cc4e..ca11e7d2f 100644 --- a/src/crypto/key_passphrase.js +++ b/src/crypto/key_passphrase.ts @@ -1,6 +1,5 @@ /* -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2018 - 2021 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. @@ -21,7 +20,21 @@ const DEFAULT_ITERATIONS = 500000; const DEFAULT_BITSIZE = 256; -export async function keyFromAuthData(authData, password) { +/* eslint-disable camelcase */ +interface IAuthData { + private_key_salt: string; + private_key_iterations: number; + private_key_bits?: number; +} +/* eslint-enable camelcase */ + +interface IKey { + key: Uint8Array; + salt: string; + iterations: number; +} + +export async function keyFromAuthData(authData: IAuthData, password: string): Promise { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -40,7 +53,7 @@ export async function keyFromAuthData(authData, password) { ); } -export async function keyFromPassphrase(password) { +export async function keyFromPassphrase(password: string): Promise { if (!global.Olm) { throw new Error("Olm is not available"); } @@ -52,7 +65,12 @@ export async function keyFromPassphrase(password) { return { key, salt, iterations: DEFAULT_ITERATIONS }; } -export async function deriveKey(password, salt, iterations, numBits = DEFAULT_BITSIZE) { +export async function deriveKey( + password: string, + salt: string, + iterations: number, + numBits = DEFAULT_BITSIZE, +): Promise { const subtleCrypto = global.crypto.subtle; const TextEncoder = global.TextEncoder; if (!subtleCrypto || !TextEncoder) { diff --git a/src/crypto/keybackup.ts b/src/crypto/keybackup.ts index c5a7979f2..123f18f76 100644 --- a/src/crypto/keybackup.ts +++ b/src/crypto/keybackup.ts @@ -15,7 +15,6 @@ limitations under the License. */ import { ISignatures } from "../@types/signed"; -import { DeviceInfo } from "./deviceinfo"; export interface IKeyBackupSession { first_message_index: number; // eslint-disable-line camelcase @@ -32,28 +31,21 @@ export interface IKeyBackupRoomSessions { [sessionId: string]: IKeyBackupSession; } -export interface IKeyBackupVersion { +/* eslint-disable camelcase */ +export interface IKeyBackupInfo { algorithm: string; - auth_data: { // eslint-disable-line camelcase - public_key: string; // eslint-disable-line camelcase + auth_data: { + public_key: string; signatures: ISignatures; + private_key_salt: string; + private_key_iterations: number; + private_key_bits?: number; }; - count: number; - etag: string; - version: string; // number contained within -} - -// TODO: Verify types -export interface IKeyBackupTrustInfo { - /** - * is the backup trusted, true if there is a sig that is valid & from a trusted device - */ - usable: boolean[]; - sigs: { - valid: boolean[]; - device: DeviceInfo[]; - }[]; + count?: number; + etag?: string; + version?: string; // number contained within } +/* eslint-enable camelcase */ export interface IKeyBackupPrepareOpts { secureSecretStorage: boolean; diff --git a/src/crypto/olmlib.js b/src/crypto/olmlib.ts similarity index 85% rename from src/crypto/olmlib.js rename to src/crypto/olmlib.ts index 74120d643..7a5e3a26c 100644 --- a/src/crypto/olmlib.js +++ b/src/crypto/olmlib.ts @@ -1,7 +1,5 @@ /* -Copyright 2016 OpenMarket Ltd -Copyright 2019 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2016 - 2021 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. @@ -22,24 +20,42 @@ limitations under the License. * Utilities common to olm encryption algorithms */ +import anotherjson from "another-json"; +import type { PkSigning } from "@matrix-org/olm"; +import { Logger } from "loglevel"; + +import OlmDevice from "./OlmDevice"; +import { DeviceInfo } from "./deviceinfo"; import { logger } from '../logger'; import * as utils from "../utils"; -import anotherjson from "another-json"; +import { OneTimeKey } from "./dehydration"; +import { MatrixClient } from "../client"; + +enum Algorithm { + Olm = "m.olm.v1.curve25519-aes-sha2", + Megolm = "m.megolm.v1.aes-sha2", + MegolmBackup = "m.megolm_backup.v1.curve25519-aes-sha2", +} /** * matrix algorithm tag for olm */ -export const OLM_ALGORITHM = "m.olm.v1.curve25519-aes-sha2"; +export const OLM_ALGORITHM = Algorithm.Olm; /** * matrix algorithm tag for megolm */ -export const MEGOLM_ALGORITHM = "m.megolm.v1.aes-sha2"; +export const MEGOLM_ALGORITHM = Algorithm.Megolm; /** * matrix algorithm tag for megolm backups */ -export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; +export const MEGOLM_BACKUP_ALGORITHM = Algorithm.MegolmBackup; + +export interface IOlmSessionResult { + device: DeviceInfo; + sessionId?: string; +} /** * Encrypt an event payload for an Olm device @@ -58,9 +74,13 @@ export const MEGOLM_BACKUP_ALGORITHM = "m.megolm_backup.v1.curve25519-aes-sha2"; * has been encrypted into `resultsObject` */ export async function encryptMessageForDevice( - resultsObject, - ourUserId, ourDeviceId, olmDevice, recipientUserId, recipientDevice, - payloadFields, + resultsObject: Record, + ourUserId: string, + ourDeviceId: string, + olmDevice: OlmDevice, + recipientUserId: string, + recipientDevice: DeviceInfo, + payloadFields: Record, ) { const deviceKey = recipientDevice.getIdentityKey(); const sessionId = await olmDevice.getSessionIdForDevice(deviceKey); @@ -77,6 +97,7 @@ export async function encryptMessageForDevice( const payload = { sender: ourUserId, + // TODO this appears to no longer be used whatsoever sender_device: ourDeviceId, // Include the Ed25519 key so that the recipient knows what @@ -129,7 +150,9 @@ export async function encryptMessageForDevice( * a map from userId to deviceId to {@link module:crypto~OlmSessionResult} */ export async function getExistingOlmSessions( - olmDevice, baseApis, devicesByUser, + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Record, ) { const devicesWithoutSession = {}; const sessions = {}; @@ -189,23 +212,30 @@ export async function getExistingOlmSessions( * {@link module:crypto~OlmSessionResult} */ export async function ensureOlmSessionsForDevices( - olmDevice, baseApis, devicesByUser, force, otkTimeout, failedServers, log, -) { + olmDevice: OlmDevice, + baseApis: MatrixClient, + devicesByUser: Record, + force = false, + otkTimeout?: number, + failedServers?: string[], + log: Logger = logger, +): Promise>> { if (typeof force === "number") { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - backwards compatibility log = failedServers; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore - backwards compatibility failedServers = otkTimeout; otkTimeout = force; force = false; } - if (!log) { - log = logger; - } const devicesWithoutSession = [ // [userId, deviceId], ... ]; const result = {}; - const resolveSession = {}; + const resolveSession: Record void> = {}; // Mark all sessions this task intends to update as in progress. It is // important to do this for all devices this task cares about in a single @@ -227,9 +257,9 @@ export async function ensureOlmSessionsForDevices( // conditions. If we find that we already have a session, then // we'll resolve olmDevice._sessionsInProgress[key] = new Promise(resolve => { - resolveSession[key] = (...args) => { + resolveSession[key] = (v: any) => { delete olmDevice._sessionsInProgress[key]; - resolve(...args); + resolve(v); }; }); } @@ -375,7 +405,12 @@ export async function ensureOlmSessionsForDevices( return result; } -async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceInfo) { +async function _verifyKeyAndStartSession( + olmDevice: OlmDevice, + oneTimeKey: OneTimeKey, + userId: string, + deviceInfo: DeviceInfo, +): Promise { const deviceId = deviceInfo.deviceId; try { await verifySignature( @@ -407,6 +442,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn return sid; } +export interface IObject { + unsigned?: object; + signatures?: object; +} + /** * Verify the signature on an object * @@ -424,7 +464,11 @@ async function _verifyKeyAndStartSession(olmDevice, oneTimeKey, userId, deviceIn * or rejects with an Error if it is bad. */ export async function verifySignature( - olmDevice, obj, signingUserId, signingDeviceId, signingKey, + olmDevice: OlmDevice, + obj: OneTimeKey | IObject, + signingUserId: string, + signingDeviceId: string, + signingKey: string, ) { const signKeyId = "ed25519:" + signingDeviceId; const signatures = obj.signatures || {}; @@ -434,10 +478,11 @@ export async function verifySignature( throw Error("No signature"); } - // prepare the canonical json: remove unsigned and signatures, and stringify with - // anotherjson + // prepare the canonical json: remove unsigned and signatures, and stringify with anotherjson const mangledObj = Object.assign({}, obj); - delete mangledObj.unsigned; + if ("unsigned" in mangledObj) { + delete mangledObj.unsigned; + } delete mangledObj.signatures; const json = anotherjson.stringify(mangledObj); @@ -453,14 +498,14 @@ export async function verifySignature( * @param {Olm.PkSigning|Uint8Array} key the signing object or the private key * seed * @param {string} userId The user ID who owns the signing key - * @param {string} pubkey The public key (ignored if key is a seed) + * @param {string} pubKey The public key (ignored if key is a seed) * @returns {string} the signature for the object */ -export function pkSign(obj, key, userId, pubkey) { +export function pkSign(obj: IObject, key: PkSigning, userId: string, pubKey: string): string { let createdKey = false; if (key instanceof Uint8Array) { const keyObj = new global.Olm.PkSigning(); - pubkey = keyObj.init_with_seed(key); + pubKey = keyObj.init_with_seed(key); key = keyObj; createdKey = true; } @@ -472,7 +517,7 @@ export function pkSign(obj, key, userId, pubkey) { const mysigs = sigs[userId] || {}; sigs[userId] = mysigs; - return mysigs['ed25519:' + pubkey] = key.sign(anotherjson.stringify(obj)); + return mysigs['ed25519:' + pubKey] = key.sign(anotherjson.stringify(obj)); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -485,11 +530,11 @@ export function pkSign(obj, key, userId, pubkey) { /** * Verify a signed JSON object * @param {Object} obj Object to verify - * @param {string} pubkey The public key to use to verify + * @param {string} pubKey The public key to use to verify * @param {string} userId The user ID who signed the object */ -export function pkVerify(obj, pubkey, userId) { - const keyId = "ed25519:" + pubkey; +export function pkVerify(obj: IObject, pubKey: string, userId: string) { + const keyId = "ed25519:" + pubKey; if (!(obj.signatures && obj.signatures[userId] && obj.signatures[userId][keyId])) { throw new Error("No signature"); } @@ -500,7 +545,7 @@ export function pkVerify(obj, pubkey, userId) { const unsigned = obj.unsigned; if (obj.unsigned) delete obj.unsigned; try { - util.ed25519_verify(pubkey, anotherjson.stringify(obj), signature); + util.ed25519_verify(pubKey, anotherjson.stringify(obj), signature); } finally { obj.signatures = sigs; if (unsigned) obj.unsigned = unsigned; @@ -513,7 +558,7 @@ export function pkVerify(obj, pubkey, userId) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The base64. */ -export function encodeBase64(uint8Array) { +export function encodeBase64(uint8Array: ArrayBuffer | Uint8Array): string { return Buffer.from(uint8Array).toString("base64"); } @@ -522,7 +567,7 @@ export function encodeBase64(uint8Array) { * @param {Uint8Array} uint8Array The data to encode. * @return {string} The unpadded base64. */ -export function encodeUnpaddedBase64(uint8Array) { +export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): string { return encodeBase64(uint8Array).replace(/=+$/g, ''); } @@ -531,6 +576,6 @@ export function encodeUnpaddedBase64(uint8Array) { * @param {string} base64 The base64 to decode. * @return {Uint8Array} The decoded data. */ -export function decodeBase64(base64) { +export function decodeBase64(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } diff --git a/src/crypto/recoverykey.js b/src/crypto/recoverykey.ts similarity index 90% rename from src/crypto/recoverykey.js rename to src/crypto/recoverykey.ts index 7fb9cf44a..5c54e6085 100644 --- a/src/crypto/recoverykey.js +++ b/src/crypto/recoverykey.ts @@ -20,7 +20,7 @@ import bs58 from 'bs58'; // (which are also base58 encoded, but bitcoin's involve a lot more hashing) const OLM_RECOVERY_KEY_PREFIX = [0x8B, 0x01]; -export function encodeRecoveryKey(key) { +export function encodeRecoveryKey(key: ArrayLike): string { const buf = new Buffer(OLM_RECOVERY_KEY_PREFIX.length + key.length + 1); buf.set(OLM_RECOVERY_KEY_PREFIX, 0); buf.set(key, OLM_RECOVERY_KEY_PREFIX.length); @@ -35,8 +35,8 @@ export function encodeRecoveryKey(key) { return base58key.match(/.{1,4}/g).join(" "); } -export function decodeRecoveryKey(recoverykey) { - const result = bs58.decode(recoverykey.replace(/ /g, '')); +export function decodeRecoveryKey(recoveryKey: string): Uint8Array { + const result = bs58.decode(recoveryKey.replace(/ /g, '')); let parity = 0; for (const b of result) { diff --git a/src/crypto/store/base.js b/src/crypto/store/base.ts similarity index 72% rename from src/crypto/store/base.js rename to src/crypto/store/base.ts index d9d1f7a94..d76fb9ead 100644 --- a/src/crypto/store/base.js +++ b/src/crypto/store/base.ts @@ -10,6 +10,9 @@ * @interface CryptoStore */ +import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index"; +import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager"; + /** * Represents an outgoing room key request * @@ -32,3 +35,11 @@ * @property {Number} state current state of this request (states are defined * in {@link module:crypto/OutgoingRoomKeyRequestManager~ROOM_KEY_REQUEST_STATES}) */ +export interface OutgoingRoomKeyRequest { + requestId: string; + requestTxnId?: string; + cancellationTxnId?: string; + recipients: IRoomKeyRequestRecipient[]; + requestBody: IRoomKeyRequestBody; + state: RoomKeyRequestState; +} diff --git a/src/crypto/verification/Base.js b/src/crypto/verification/Base.js index 2ac17cc88..4ca01bd11 100644 --- a/src/crypto/verification/Base.js +++ b/src/crypto/verification/Base.js @@ -292,7 +292,7 @@ export class VerificationBase extends EventEmitter { await verifier(keyId, device, keyInfo); verifiedDevices.push(deviceId); } else { - const crossSigningInfo = this._baseApis.crypto._deviceList + const crossSigningInfo = this._baseApis.crypto.deviceList .getStoredCrossSigningForUser(userId); if (crossSigningInfo && crossSigningInfo.getId() === deviceId) { await verifier(keyId, DeviceInfo.fromStorage({ diff --git a/src/filter-component.js b/src/filter-component.js deleted file mode 100644 index 8ff760673..000000000 --- a/src/filter-component.js +++ /dev/null @@ -1,145 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module filter-component - */ - -/** - * Checks if a value matches a given field value, which may be a * terminated - * wildcard pattern. - * @param {String} actual_value The value to be compared - * @param {String} filter_value The filter pattern to be compared - * @return {bool} true if the actual_value matches the filter_value - */ -function _matches_wildcard(actual_value, filter_value) { - if (filter_value.endsWith("*")) { - const type_prefix = filter_value.slice(0, -1); - return actual_value.substr(0, type_prefix.length) === type_prefix; - } else { - return actual_value === filter_value; - } -} - -/** - * FilterComponent is a section of a Filter definition which defines the - * types, rooms, senders filters etc to be applied to a particular type of resource. - * This is all ported over from synapse's Filter object. - * - * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as - * 'Filters' are referred to as 'FilterCollections'. - * - * @constructor - * @param {Object} filter_json the definition of this filter JSON, e.g. { 'contains_url': true } - */ -export function FilterComponent(filter_json) { - this.filter_json = filter_json; - - this.types = filter_json.types || null; - this.not_types = filter_json.not_types || []; - - this.rooms = filter_json.rooms || null; - this.not_rooms = filter_json.not_rooms || []; - - this.senders = filter_json.senders || null; - this.not_senders = filter_json.not_senders || []; - - this.contains_url = filter_json.contains_url || null; -} - -/** - * Checks with the filter component matches the given event - * @param {MatrixEvent} event event to be checked against the filter - * @return {bool} true if the event matches the filter - */ -FilterComponent.prototype.check = function(event) { - return this._checkFields( - event.getRoomId(), - event.getSender(), - event.getType(), - event.getContent() ? event.getContent().url !== undefined : false, - ); -}; - -/** - * Checks whether the filter component matches the given event fields. - * @param {String} room_id the room_id for the event being checked - * @param {String} sender the sender of the event being checked - * @param {String} event_type the type of the event being checked - * @param {String} contains_url whether the event contains a content.url field - * @return {bool} true if the event fields match the filter - */ -FilterComponent.prototype._checkFields = - function(room_id, sender, event_type, contains_url) { - const literal_keys = { - "rooms": function(v) { - return room_id === v; - }, - "senders": function(v) { - return sender === v; - }, - "types": function(v) { - return _matches_wildcard(event_type, v); - }, - }; - - const self = this; - for (let n=0; n < Object.keys(literal_keys).length; n++) { - const name = Object.keys(literal_keys)[n]; - const match_func = literal_keys[name]; - const not_name = "not_" + name; - const disallowed_values = self[not_name]; - if (disallowed_values.filter(match_func).length > 0) { - return false; - } - - const allowed_values = self[name]; - if (allowed_values && allowed_values.length > 0) { - const anyMatch = allowed_values.some(match_func); - if (!anyMatch) { - return false; - } - } - } - - const contains_url_filter = this.filter_json.contains_url; - if (contains_url_filter !== undefined) { - if (contains_url_filter !== contains_url) { - return false; - } - } - - return true; -}; - -/** - * Filters a list of events down to those which match this filter component - * @param {MatrixEvent[]} events Events to be checked againt the filter component - * @return {MatrixEvent[]} events which matched the filter component - */ -FilterComponent.prototype.filter = function(events) { - return events.filter(this.check, this); -}; - -/** - * Returns the limit field for a given filter component, providing a default of - * 10 if none is otherwise specified. Cargo-culted from Synapse. - * @return {Number} the limit for this filter component. - */ -FilterComponent.prototype.limit = function() { - return this.filter_json.limit !== undefined ? this.filter_json.limit : 10; -}; diff --git a/src/filter-component.ts b/src/filter-component.ts new file mode 100644 index 000000000..e5cdc2ec3 --- /dev/null +++ b/src/filter-component.ts @@ -0,0 +1,156 @@ +/* +Copyright 2016 - 2021 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 { MatrixEvent } from "./models/event"; + +/** + * @module filter-component + */ + +/** + * Checks if a value matches a given field value, which may be a * terminated + * wildcard pattern. + * @param {String} actualValue The value to be compared + * @param {String} filterValue The filter pattern to be compared + * @return {boolean} true if the actualValue matches the filterValue + */ +function matchesWildcard(actualValue: string, filterValue: string): boolean { + if (filterValue.endsWith("*")) { + const typePrefix = filterValue.slice(0, -1); + return actualValue.substr(0, typePrefix.length) === typePrefix; + } else { + return actualValue === filterValue; + } +} + +/* eslint-disable camelcase */ +export interface IFilterComponent { + types?: string[]; + not_types?: string[]; + rooms?: string[]; + not_rooms?: string[]; + senders?: string[]; + not_senders?: string[]; + contains_url?: boolean; + limit?: number; +} +/* eslint-enable camelcase */ + +/** + * FilterComponent is a section of a Filter definition which defines the + * types, rooms, senders filters etc to be applied to a particular type of resource. + * This is all ported over from synapse's Filter object. + * + * N.B. that synapse refers to these as 'Filters', and what js-sdk refers to as + * 'Filters' are referred to as 'FilterCollections'. + * + * @constructor + * @param {Object} filterJson the definition of this filter JSON, e.g. { 'contains_url': true } + */ +export class FilterComponent { + constructor(private filterJson: IFilterComponent) {} + + /** + * Checks with the filter component matches the given event + * @param {MatrixEvent} event event to be checked against the filter + * @return {boolean} true if the event matches the filter + */ + public check(event: MatrixEvent): boolean { + return this.checkFields( + event.getRoomId(), + event.getSender(), + event.getType(), + event.getContent() ? event.getContent().url !== undefined : false, + ); + } + + /** + * Converts the filter component into the form expected over the wire + */ + public toJSON(): object { + return { + types: this.filterJson.types || null, + not_types: this.filterJson.not_types || [], + rooms: this.filterJson.rooms || null, + not_rooms: this.filterJson.not_rooms || [], + senders: this.filterJson.senders || null, + not_senders: this.filterJson.not_senders || [], + contains_url: this.filterJson.contains_url || null, + }; + } + + /** + * Checks whether the filter component matches the given event fields. + * @param {String} roomId the roomId for the event being checked + * @param {String} sender the sender of the event being checked + * @param {String} eventType the type of the event being checked + * @param {boolean} containsUrl whether the event contains a content.url field + * @return {boolean} true if the event fields match the filter + */ + private checkFields(roomId: string, sender: string, eventType: string, containsUrl: boolean): boolean { + const literalKeys = { + "rooms": function(v: string): boolean { + return roomId === v; + }, + "senders": function(v: string): boolean { + return sender === v; + }, + "types": function(v: string): boolean { + return matchesWildcard(eventType, v); + }, + }; + + for (let n = 0; n < Object.keys(literalKeys).length; n++) { + const name = Object.keys(literalKeys)[n]; + const matchFunc = literalKeys[name]; + const notName = "not_" + name; + const disallowedValues: string[] = this.filterJson[notName]; + if (disallowedValues?.some(matchFunc)) { + return false; + } + + const allowedValues: string[] = this.filterJson[name]; + if (allowedValues && !allowedValues.some(matchFunc)) { + return false; + } + } + + const containsUrlFilter = this.filterJson.contains_url; + if (containsUrlFilter !== undefined && containsUrlFilter !== containsUrl) { + return false; + } + + return true; + } + + /** + * Filters a list of events down to those which match this filter component + * @param {MatrixEvent[]} events Events to be checked against the filter component + * @return {MatrixEvent[]} events which matched the filter component + */ + filter(events: MatrixEvent[]): MatrixEvent[] { + return events.filter(this.check, this); + } + + /** + * Returns the limit field for a given filter component, providing a default of + * 10 if none is otherwise specified. Cargo-culted from Synapse. + * @return {Number} the limit for this filter component. + */ + limit(): number { + return this.filterJson.limit !== undefined ? this.filterJson.limit : 10; + } +} diff --git a/src/filter.js b/src/filter.js deleted file mode 100644 index 08e747092..000000000 --- a/src/filter.js +++ /dev/null @@ -1,199 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module filter - */ - -import { FilterComponent } from "./filter-component"; - -/** - * @param {Object} obj - * @param {string} keyNesting - * @param {*} val - */ -function setProp(obj, keyNesting, val) { - const nestedKeys = keyNesting.split("."); - let currentObj = obj; - for (let i = 0; i < (nestedKeys.length - 1); i++) { - if (!currentObj[nestedKeys[i]]) { - currentObj[nestedKeys[i]] = {}; - } - currentObj = currentObj[nestedKeys[i]]; - } - currentObj[nestedKeys[nestedKeys.length - 1]] = val; -} - -/** - * Construct a new Filter. - * @constructor - * @param {string} userId The user ID for this filter. - * @param {string=} filterId The filter ID if known. - * @prop {string} userId The user ID of the filter - * @prop {?string} filterId The filter ID - */ -export function Filter(userId, filterId) { - this.userId = userId; - this.filterId = filterId; - this.definition = {}; -} - -Filter.LAZY_LOADING_MESSAGES_FILTER = { - lazy_load_members: true, -}; - -/** - * Get the ID of this filter on your homeserver (if known) - * @return {?Number} The filter ID - */ -Filter.prototype.getFilterId = function() { - return this.filterId; -}; - -/** - * Get the JSON body of the filter. - * @return {Object} The filter definition - */ -Filter.prototype.getDefinition = function() { - return this.definition; -}; - -/** - * Set the JSON body of the filter - * @param {Object} definition The filter definition - */ -Filter.prototype.setDefinition = function(definition) { - this.definition = definition; - - // This is all ported from synapse's FilterCollection() - - // definitions look something like: - // { - // "room": { - // "rooms": ["!abcde:example.com"], - // "not_rooms": ["!123456:example.com"], - // "state": { - // "types": ["m.room.*"], - // "not_rooms": ["!726s6s6q:example.com"], - // "lazy_load_members": true, - // }, - // "timeline": { - // "limit": 10, - // "types": ["m.room.message"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // "contains_url": true - // }, - // "ephemeral": { - // "types": ["m.receipt", "m.typing"], - // "not_rooms": ["!726s6s6q:example.com"], - // "not_senders": ["@spam:example.com"] - // } - // }, - // "presence": { - // "types": ["m.presence"], - // "not_senders": ["@alice:example.com"] - // }, - // "event_format": "client", - // "event_fields": ["type", "content", "sender"] - // } - - const room_filter_json = definition.room; - - // consider the top level rooms/not_rooms filter - const room_filter_fields = {}; - if (room_filter_json) { - if (room_filter_json.rooms) { - room_filter_fields.rooms = room_filter_json.rooms; - } - if (room_filter_json.rooms) { - room_filter_fields.not_rooms = room_filter_json.not_rooms; - } - - this._include_leave = room_filter_json.include_leave || false; - } - - this._room_filter = new FilterComponent(room_filter_fields); - this._room_timeline_filter = new FilterComponent( - room_filter_json ? (room_filter_json.timeline || {}) : {}, - ); - - // don't bother porting this from synapse yet: - // this._room_state_filter = - // new FilterComponent(room_filter_json.state || {}); - // this._room_ephemeral_filter = - // new FilterComponent(room_filter_json.ephemeral || {}); - // this._room_account_data_filter = - // new FilterComponent(room_filter_json.account_data || {}); - // this._presence_filter = - // new FilterComponent(definition.presence || {}); - // this._account_data_filter = - // new FilterComponent(definition.account_data || {}); -}; - -/** - * Get the room.timeline filter component of the filter - * @return {FilterComponent} room timeline filter component - */ -Filter.prototype.getRoomTimelineFilterComponent = function() { - return this._room_timeline_filter; -}; - -/** - * Filter the list of events based on whether they are allowed in a timeline - * based on this filter - * @param {MatrixEvent[]} events the list of events being filtered - * @return {MatrixEvent[]} the list of events which match the filter - */ -Filter.prototype.filterRoomTimeline = function(events) { - return this._room_timeline_filter.filter(this._room_filter.filter(events)); -}; - -/** - * Set the max number of events to return for each room's timeline. - * @param {Number} limit The max number of events to return for each room. - */ -Filter.prototype.setTimelineLimit = function(limit) { - setProp(this.definition, "room.timeline.limit", limit); -}; - -Filter.prototype.setLazyLoadMembers = function(enabled) { - setProp(this.definition, "room.state.lazy_load_members", !!enabled); -}; - -/** - * Control whether left rooms should be included in responses. - * @param {boolean} includeLeave True to make rooms the user has left appear - * in responses. - */ -Filter.prototype.setIncludeLeaveRooms = function(includeLeave) { - setProp(this.definition, "room.include_leave", includeLeave); -}; - -/** - * Create a filter from existing data. - * @static - * @param {string} userId - * @param {string} filterId - * @param {Object} jsonObj - * @return {Filter} - */ -Filter.fromJson = function(userId, filterId, jsonObj) { - const filter = new Filter(userId, filterId); - filter.setDefinition(jsonObj); - return filter; -}; diff --git a/src/filter.ts b/src/filter.ts new file mode 100644 index 000000000..859aeb0c7 --- /dev/null +++ b/src/filter.ts @@ -0,0 +1,224 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module filter + */ + +import { FilterComponent, IFilterComponent } from "./filter-component"; +import { MatrixEvent } from "./models/event"; + +/** + * @param {Object} obj + * @param {string} keyNesting + * @param {*} val + */ +function setProp(obj: object, keyNesting: string, val: any) { + const nestedKeys = keyNesting.split("."); + let currentObj = obj; + for (let i = 0; i < (nestedKeys.length - 1); i++) { + if (!currentObj[nestedKeys[i]]) { + currentObj[nestedKeys[i]] = {}; + } + currentObj = currentObj[nestedKeys[i]]; + } + currentObj[nestedKeys[nestedKeys.length - 1]] = val; +} + +/* eslint-disable camelcase */ +interface IFilterDefinition { + event_fields?: string[]; + event_format?: "client" | "federation"; + presence?: IFilterComponent; + account_data?: IFilterComponent; + room?: IRoomFilter; +} + +interface IRoomEventFilter extends IFilterComponent { + lazy_load_members?: boolean; + include_redundant_members?: boolean; +} + +interface IStateFilter extends IRoomEventFilter {} + +interface IRoomFilter { + not_rooms?: string[]; + rooms?: string[]; + ephemeral?: IRoomEventFilter; + include_leave?: boolean; + state?: IStateFilter; + timeline?: IRoomEventFilter; + account_data?: IRoomEventFilter; +} +/* eslint-enable camelcase */ + +/** + * Construct a new Filter. + * @constructor + * @param {string} userId The user ID for this filter. + * @param {string=} filterId The filter ID if known. + * @prop {string} userId The user ID of the filter + * @prop {?string} filterId The filter ID + */ +export class Filter { + static LAZY_LOADING_MESSAGES_FILTER = { + lazy_load_members: true, + }; + + /** + * Create a filter from existing data. + * @static + * @param {string} userId + * @param {string} filterId + * @param {Object} jsonObj + * @return {Filter} + */ + static fromJson(userId: string, filterId: string, jsonObj: IFilterDefinition): Filter { + const filter = new Filter(userId, filterId); + filter.setDefinition(jsonObj); + return filter; + } + + private definition: IFilterDefinition = {}; + private roomFilter: FilterComponent; + private roomTimelineFilter: FilterComponent; + + constructor(public readonly userId: string, public filterId?: string) {} + + /** + * Get the ID of this filter on your homeserver (if known) + * @return {?string} The filter ID + */ + getFilterId(): string | null { + return this.filterId; + } + + /** + * Get the JSON body of the filter. + * @return {Object} The filter definition + */ + getDefinition(): IFilterDefinition { + return this.definition; + } + + /** + * Set the JSON body of the filter + * @param {Object} definition The filter definition + */ + setDefinition(definition: IFilterDefinition) { + this.definition = definition; + + // This is all ported from synapse's FilterCollection() + + // definitions look something like: + // { + // "room": { + // "rooms": ["!abcde:example.com"], + // "not_rooms": ["!123456:example.com"], + // "state": { + // "types": ["m.room.*"], + // "not_rooms": ["!726s6s6q:example.com"], + // "lazy_load_members": true, + // }, + // "timeline": { + // "limit": 10, + // "types": ["m.room.message"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // "contains_url": true + // }, + // "ephemeral": { + // "types": ["m.receipt", "m.typing"], + // "not_rooms": ["!726s6s6q:example.com"], + // "not_senders": ["@spam:example.com"] + // } + // }, + // "presence": { + // "types": ["m.presence"], + // "not_senders": ["@alice:example.com"] + // }, + // "event_format": "client", + // "event_fields": ["type", "content", "sender"] + // } + + const roomFilterJson = definition.room; + + // consider the top level rooms/not_rooms filter + const roomFilterFields: IRoomFilter = {}; + if (roomFilterJson) { + if (roomFilterJson.rooms) { + roomFilterFields.rooms = roomFilterJson.rooms; + } + if (roomFilterJson.rooms) { + roomFilterFields.not_rooms = roomFilterJson.not_rooms; + } + } + + this.roomFilter = new FilterComponent(roomFilterFields); + this.roomTimelineFilter = new FilterComponent(roomFilterJson?.timeline || {}); + + // don't bother porting this from synapse yet: + // this._room_state_filter = + // new FilterComponent(roomFilterJson.state || {}); + // this._room_ephemeral_filter = + // new FilterComponent(roomFilterJson.ephemeral || {}); + // this._room_account_data_filter = + // new FilterComponent(roomFilterJson.account_data || {}); + // this._presence_filter = + // new FilterComponent(definition.presence || {}); + // this._account_data_filter = + // new FilterComponent(definition.account_data || {}); + } + + /** + * Get the room.timeline filter component of the filter + * @return {FilterComponent} room timeline filter component + */ + getRoomTimelineFilterComponent(): FilterComponent { + return this.roomTimelineFilter; + } + + /** + * Filter the list of events based on whether they are allowed in a timeline + * based on this filter + * @param {MatrixEvent[]} events the list of events being filtered + * @return {MatrixEvent[]} the list of events which match the filter + */ + filterRoomTimeline(events: MatrixEvent[]): MatrixEvent[] { + return this.roomTimelineFilter.filter(this.roomFilter.filter(events)); + } + + /** + * Set the max number of events to return for each room's timeline. + * @param {Number} limit The max number of events to return for each room. + */ + setTimelineLimit(limit: number) { + setProp(this.definition, "room.timeline.limit", limit); + } + + setLazyLoadMembers(enabled: boolean) { + setProp(this.definition, "room.state.lazy_load_members", !!enabled); + } + + /** + * Control whether left rooms should be included in responses. + * @param {boolean} includeLeave True to make rooms the user has left appear + * in responses. + */ + setIncludeLeaveRooms(includeLeave: boolean) { + setProp(this.definition, "room.include_leave", includeLeave); + } +} diff --git a/src/matrix.ts b/src/matrix.ts index c9192f2ea..2cce6867a 100644 --- a/src/matrix.ts +++ b/src/matrix.ts @@ -19,6 +19,7 @@ import { MemoryStore } from "./store/memory"; import { MatrixScheduler } from "./scheduler"; import { MatrixClient } from "./client"; import { ICreateClientOpts } from "./client"; +import { DeviceTrustLevel } from "./crypto/CrossSigning"; export * from "./client"; export * from "./http-api"; @@ -99,7 +100,7 @@ export function setCryptoStoreFactory(fac) { } export interface ICryptoCallbacks { - getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise; + getCrossSigningKey?: (keyType: string, pubKey: string) => Promise; saveCrossSigningKeys?: (keys: Record) => void; shouldUpgradeDeviceVerifications?: ( users: Record @@ -112,7 +113,7 @@ export interface ICryptoCallbacks { ) => void; onSecretRequested?: ( userId: string, deviceId: string, - requestId: string, secretName: string, deviceTrust: IDeviceTrustLevel + requestId: string, secretName: string, deviceTrust: DeviceTrustLevel ) => Promise; getDehydrationKey?: ( keyInfo: ISecretStorageKeyInfo, @@ -132,14 +133,6 @@ export interface ISecretStorageKeyInfo { mac?: string; } -// TODO: Move this to `CrossSigning` once converted -export interface IDeviceTrustLevel { - isVerified(): boolean; - isCrossSigningVerified(): boolean; - isLocallyVerified(): boolean; - isTofu(): boolean; -} - /** * Construct a Matrix Client. Similar to {@link module:client.MatrixClient} * except that the 'request', 'store' and 'scheduler' dependencies are satisfied. diff --git a/src/models/MSC3089Branch.ts b/src/models/MSC3089Branch.ts new file mode 100644 index 000000000..87acee0f8 --- /dev/null +++ b/src/models/MSC3089Branch.ts @@ -0,0 +1,102 @@ +/* +Copyright 2021 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 { MatrixClient } from "../client"; +import { IEncryptedFile, UNSTABLE_MSC3089_BRANCH } from "../@types/event"; +import { MatrixEvent } from "./event"; + +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) branch - a reference + * to a file (leaf) in the tree. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +export class MSC3089Branch { + public constructor(private client: MatrixClient, public readonly indexEvent: MatrixEvent) { + // Nothing to do + } + + /** + * The file ID. + */ + public get id(): string { + return this.indexEvent.getStateKey(); + } + + /** + * Whether this branch is active/valid. + */ + public get isActive(): boolean { + return this.indexEvent.getContent()["active"] === true; + } + + private get roomId(): string { + return this.indexEvent.getRoomId(); + } + + /** + * Deletes the file from the tree. + * @returns {Promise} Resolves when complete. + */ + public async delete(): Promise { + await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, {}, this.id); + await this.client.redactEvent(this.roomId, this.id); + + // TODO: Delete edit history as well + } + + /** + * Gets the name for this file. + * @returns {string} The name, or "Unnamed File" if unknown. + */ + public getName(): string { + return this.indexEvent.getContent()['name'] || "Unnamed File"; + } + + /** + * Sets the name for this file. + * @param {string} name The new name for this file. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + return this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { + ...this.indexEvent.getContent(), + name: name, + }, this.id); + } + + /** + * Gets information about the file needed to download it. + * @returns {Promise<{info: IEncryptedFile, httpUrl: string}>} Information about the file. + */ + public async getFileInfo(): Promise<{ info: IEncryptedFile, httpUrl: string }> { + const room = this.client.getRoom(this.roomId); + if (!room) throw new Error("Unknown room"); + + const timeline = await this.client.getEventTimeline(room.getUnfilteredTimelineSet(), this.id); + if (!timeline) throw new Error("Failed to get timeline for room event"); + + const event = timeline.getEvents().find(e => e.getId() === this.id); + if (!event) throw new Error("Failed to find event"); + + // Sometimes the event context doesn't decrypt for us, so do that. + await this.client.decryptEventIfNeeded(event, { emit: false, isRetry: false }); + + const file = event.getContent()['file']; + const httpUrl = this.client.mxcUrlToHttp(file['url']); + + return { info: file, httpUrl: httpUrl }; + } +} diff --git a/src/models/MSC3089TreeSpace.ts b/src/models/MSC3089TreeSpace.ts new file mode 100644 index 000000000..77966d9fb --- /dev/null +++ b/src/models/MSC3089TreeSpace.ts @@ -0,0 +1,476 @@ +/* +Copyright 2021 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 { MatrixClient } from "../client"; +import { EventType, IEncryptedFile, MsgType, UNSTABLE_MSC3089_BRANCH, UNSTABLE_MSC3089_LEAF } from "../@types/event"; +import { Room } from "./room"; +import { logger } from "../logger"; +import { MatrixEvent } from "./event"; +import { + averageBetweenStrings, + DEFAULT_ALPHABET, + lexicographicCompare, + nextString, + prevString, + simpleRetryOperation, +} from "../utils"; +import { MSC3089Branch } from "./MSC3089Branch"; +import promiseRetry from "p-retry"; +import { isRoomSharedHistory } from "../crypto/algorithms/megolm"; + +/** + * The recommended defaults for a tree space's power levels. Note that this + * is UNSTABLE and subject to breaking changes without notice. + */ +export const DEFAULT_TREE_POWER_LEVELS_TEMPLATE = { + // Owner + invite: 100, + kick: 100, + ban: 100, + + // Editor + redact: 50, + state_default: 50, + events_default: 50, + + // Viewer + users_default: 0, + + // Mixed + events: { + [EventType.RoomPowerLevels]: 100, + [EventType.RoomHistoryVisibility]: 100, + [EventType.RoomTombstone]: 100, + [EventType.RoomEncryption]: 100, + [EventType.RoomName]: 50, + [EventType.RoomMessage]: 50, + [EventType.RoomMessageEncrypted]: 50, + [EventType.Sticker]: 50, + }, + + users: {}, // defined by calling code +}; + +/** + * Ease-of-use representation for power levels represented as simple roles. + * Note that this is UNSTABLE and subject to breaking changes without notice. + */ +export enum TreePermissions { + Viewer = "viewer", // Default + Editor = "editor", // "Moderator" or ~PL50 + Owner = "owner", // "Admin" or PL100 +} + +/** + * Represents a [MSC3089](https://github.com/matrix-org/matrix-doc/pull/3089) + * file tree Space. Note that this is UNSTABLE and subject to breaking changes + * without notice. + */ +export class MSC3089TreeSpace { + public readonly room: Room; + + public constructor(private client: MatrixClient, public readonly roomId: string) { + this.room = this.client.getRoom(this.roomId); + + if (!this.room) throw new Error("Unknown room"); + } + + /** + * Syntactic sugar for room ID of the Space. + */ + public get id(): string { + return this.roomId; + } + + /** + * Whether or not this is a top level space. + */ + public get isTopLevel(): boolean { + // XXX: This is absolutely not how you find out if the space is top level + // but is safe for a managed usecase like we offer in the SDK. + const parentEvents = this.room.currentState.getStateEvents(EventType.SpaceParent); + if (!parentEvents?.length) return true; + return parentEvents.every(e => !e.getContent()?.['via']); + } + + /** + * Sets the name of the tree space. + * @param {string} name The new name for the space. + * @returns {Promise} Resolves when complete. + */ + public setName(name: string): Promise { + return this.client.sendStateEvent(this.roomId, EventType.RoomName, { name }, ""); + } + + /** + * Invites a user to the tree space. They will be given the default Viewer + * permission level unless specified elsewhere. + * @param {string} userId The user ID to invite. + * @param {boolean} andSubspaces True (default) to invite the user to all + * directories/subspaces too, recursively. + * @param {boolean} shareHistoryKeys True (default) to share encryption keys + * with the invited user. This will allow them to decrypt the events (files) + * in the tree. Keys will not be shared if the room is lacking appropriate + * history visibility (by default, history visibility is "shared" in trees, + * which is an appropriate visibility for these purposes). + * @returns {Promise} Resolves when complete. + */ + public async invite(userId: string, andSubspaces = true, shareHistoryKeys = true): Promise { + const promises: Promise[] = [this.retryInvite(userId)]; + if (andSubspaces) { + promises.push(...this.getDirectories().map(d => d.invite(userId, andSubspaces, shareHistoryKeys))); + } + return Promise.all(promises).then(() => { + // Note: key sharing is default on because for file trees it is relatively important that the invite + // target can actually decrypt the files. The implied use case is that by inviting a user to the tree + // it means the sender would like the receiver to view/download the files contained within, much like + // sharing a folder in other circles. + if (shareHistoryKeys && isRoomSharedHistory(this.room)) { + // noinspection JSIgnoredPromiseFromCall - we aren't concerned as much if this fails. + this.client.sendSharedHistoryKeys(this.roomId, [userId]); + } + }); + } + + private retryInvite(userId: string): Promise { + return simpleRetryOperation(async () => { + await this.client.invite(this.roomId, userId).catch(e => { + // We don't want to retry permission errors forever... + if (e?.errcode === "M_FORBIDDEN") { + throw new promiseRetry.AbortError(e); + } + throw e; + }); + }); + } + + /** + * Sets the permissions of a user to the given role. Note that if setting a user + * to Owner then they will NOT be able to be demoted. If the user does not have + * permission to change the power level of the target, an error will be thrown. + * @param {string} userId The user ID to change the role of. + * @param {TreePermissions} role The role to assign. + * @returns {Promise} Resolves when complete. + */ + public async setPermissions(userId: string, role: TreePermissions): Promise { + const currentPls = this.room.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + if (Array.isArray(currentPls)) throw new Error("Unexpected return type for power levels"); + + const pls = currentPls.getContent() || {}; + const viewLevel = pls['users_default'] || 0; + const editLevel = pls['events_default'] || 50; + const adminLevel = pls['events']?.[EventType.RoomPowerLevels] || 100; + + const users = pls['users'] || {}; + switch (role) { + case TreePermissions.Viewer: + users[userId] = viewLevel; + break; + case TreePermissions.Editor: + users[userId] = editLevel; + break; + case TreePermissions.Owner: + users[userId] = adminLevel; + break; + default: + throw new Error("Invalid role: " + role); + } + pls['users'] = users; + + return this.client.sendStateEvent(this.roomId, EventType.RoomPowerLevels, pls, ""); + } + + /** + * Creates a directory under this tree space, represented as another tree space. + * @param {string} name The name for the directory. + * @returns {Promise} Resolves to the created directory. + */ + public async createDirectory(name: string): Promise { + const directory = await this.client.unstableCreateFileTree(name); + + await this.client.sendStateEvent(this.roomId, EventType.SpaceChild, { + via: [this.client.getDomain()], + }, directory.roomId); + + await this.client.sendStateEvent(directory.roomId, EventType.SpaceParent, { + via: [this.client.getDomain()], + }, this.roomId); + + return directory; + } + + /** + * Gets a list of all known immediate subdirectories to this tree space. + * @returns {MSC3089TreeSpace[]} The tree spaces (directories). May be empty, but not null. + */ + public getDirectories(): MSC3089TreeSpace[] { + const trees: MSC3089TreeSpace[] = []; + const children = this.room.currentState.getStateEvents(EventType.SpaceChild); + for (const child of children) { + try { + const tree = this.client.unstableGetFileTreeSpace(child.getStateKey()); + if (tree) trees.push(tree); + } catch (e) { + logger.warn("Unable to create tree space instance for listing. Are we joined?", e); + } + } + return trees; + } + + /** + * Gets a subdirectory of a given ID under this tree space. Note that this will not recurse + * into children and instead only look one level deep. + * @param {string} roomId The room ID (directory ID) to find. + * @returns {MSC3089TreeSpace} The directory, or falsy if not found. + */ + public getDirectory(roomId: string): MSC3089TreeSpace { + return this.getDirectories().find(r => r.roomId === roomId); + } + + /** + * Deletes the tree, kicking all members and deleting **all subdirectories**. + * @returns {Promise} Resolves when complete. + */ + public async delete(): Promise { + const subdirectories = this.getDirectories(); + for (const dir of subdirectories) { + await dir.delete(); + } + + const kickMemberships = ["invite", "knock", "join"]; + const members = this.room.currentState.getStateEvents(EventType.RoomMember); + for (const member of members) { + const isNotUs = member.getStateKey() !== this.client.getUserId(); + if (isNotUs && kickMemberships.includes(member.getContent()['membership'])) { + await this.client.kick(this.roomId, member.getStateKey(), "Room deleted"); + } + } + + await this.client.leave(this.roomId); + } + + private getOrderedChildren(children: MatrixEvent[]): { roomId: string, order: string }[] { + const ordered: { roomId: string, order: string }[] = children + .map(c => ({ roomId: c.getStateKey(), order: c.getContent()['order'] })); + ordered.sort((a, b) => { + if (a.order && !b.order) { + return -1; + } else if (!a.order && b.order) { + return 1; + } else if (!a.order && !b.order) { + const roomA = this.client.getRoom(a.roomId); + const roomB = this.client.getRoom(b.roomId); + if (!roomA || !roomB) { // just don't bother trying to do more partial sorting + return lexicographicCompare(a.roomId, b.roomId); + } + + const createTsA = roomA.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + const createTsB = roomB.currentState.getStateEvents(EventType.RoomCreate, "")?.getTs() ?? 0; + if (createTsA === createTsB) { + return lexicographicCompare(a.roomId, b.roomId); + } + return createTsA - createTsB; + } else { // both not-null orders + return lexicographicCompare(a.order, b.order); + } + }); + return ordered; + } + + private getParentRoom(): Room { + const parents = this.room.currentState.getStateEvents(EventType.SpaceParent); + const parent = parents[0]; // XXX: Wild assumption + if (!parent) throw new Error("Expected to have a parent in a non-top level space"); + + // XXX: We are assuming the parent is a valid tree space. + // We probably don't need to validate the parent room state for this usecase though. + const parentRoom = this.client.getRoom(parent.getStateKey()); + if (!parentRoom) throw new Error("Unable to locate room for parent"); + + return parentRoom; + } + + /** + * Gets the current order index for this directory. Note that if this is the top level space + * then -1 will be returned. + * @returns {number} The order index of this space. + */ + public getOrder(): number { + if (this.isTopLevel) return -1; + + const parentRoom = this.getParentRoom(); + const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild); + const ordered = this.getOrderedChildren(children); + + return ordered.findIndex(c => c.roomId === this.roomId); + } + + /** + * Sets the order index for this directory within its parent. Note that if this is a top level + * space then an error will be thrown. -1 can be used to move the child to the start, and numbers + * larger than the number of children can be used to move the child to the end. + * @param {number} index The new order index for this space. + * @returns {Promise} Resolves when complete. + * @throws Throws if this is a top level space. + */ + public async setOrder(index: number): Promise { + if (this.isTopLevel) throw new Error("Cannot set order of top level spaces currently"); + + const parentRoom = this.getParentRoom(); + const children = parentRoom.currentState.getStateEvents(EventType.SpaceChild); + const ordered = this.getOrderedChildren(children); + index = Math.max(Math.min(index, ordered.length - 1), 0); + + const currentIndex = this.getOrder(); + const movingUp = currentIndex < index; + if (movingUp && index === (ordered.length - 1)) { + index--; + } else if (!movingUp && index === 0) { + index++; + } + + const prev = ordered[movingUp ? index : (index - 1)]; + const next = ordered[movingUp ? (index + 1) : index]; + + let newOrder = DEFAULT_ALPHABET[0]; + let ensureBeforeIsSane = false; + if (!prev) { + // Move to front + if (next?.order) { + newOrder = prevString(next.order); + } + } else if (index === (ordered.length - 1)) { + // Move to back + if (next?.order) { + newOrder = nextString(next.order); + } + } else { + // Move somewhere in the middle + const startOrder = prev?.order; + const endOrder = next?.order; + if (startOrder && endOrder) { + if (startOrder === endOrder) { + // Error case: just move +1 to break out of awful math + newOrder = nextString(startOrder); + } else { + newOrder = averageBetweenStrings(startOrder, endOrder); + } + } else { + if (startOrder) { + // We're at the end (endOrder is null, so no explicit order) + newOrder = nextString(startOrder); + } else if (endOrder) { + // We're at the start (startOrder is null, so nothing before us) + newOrder = prevString(endOrder); + } else { + // Both points are unknown. We're likely in a range where all the children + // don't have particular order values, so we may need to update them too. + // The other possibility is there's only us as a child, but we should have + // shown up in the other states. + ensureBeforeIsSane = true; + } + } + } + + if (ensureBeforeIsSane) { + // We were asked by the order algorithm to prepare the moving space for a landing + // in the undefined order part of the order array, which means we need to update the + // spaces that come before it with a stable order value. + let lastOrder: string; + for (let i = 0; i <= index; i++) { + const target = ordered[i]; + if (i === 0) { + lastOrder = target.order; + } + if (!target.order) { + // XXX: We should be creating gaps to avoid conflicts + lastOrder = lastOrder ? nextString(lastOrder) : DEFAULT_ALPHABET[0]; + const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, target.roomId); + const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; + await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { + ...content, + order: lastOrder, + }, target.roomId); + } else { + lastOrder = target.order; + } + } + newOrder = nextString(lastOrder); + } + + // TODO: Deal with order conflicts by reordering + + // Now we can finally update our own order state + const currentChild = parentRoom.currentState.getStateEvents(EventType.SpaceChild, this.roomId); + const content = currentChild?.getContent() ?? { via: [this.client.getDomain()] }; + await this.client.sendStateEvent(parentRoom.roomId, EventType.SpaceChild, { + ...content, + + // TODO: Safely constrain to 50 character limit required by spaces. + order: newOrder, + }, this.roomId); + } + + /** + * Creates (uploads) a new file to this tree. The file must have already been encrypted for the room. + * @param {string} name The name of the file. + * @param {ArrayBuffer} encryptedContents The encrypted contents. + * @param {Partial} info The encrypted file information. + * @returns {Promise} Resolves when uploaded. + */ + public async createFile( + name: string, + encryptedContents: ArrayBuffer, info: Partial, + ): Promise { + const mxc = await this.client.uploadContent(new Blob([encryptedContents]), { + includeFilename: false, + onlyContentUri: true, + }); + info.url = mxc; + + const res = await this.client.sendMessage(this.roomId, { + msgtype: MsgType.File, + body: name, + url: mxc, + file: info, + [UNSTABLE_MSC3089_LEAF.name]: {}, + }); + + await this.client.sendStateEvent(this.roomId, UNSTABLE_MSC3089_BRANCH.name, { + active: true, + name: name, + }, res['event_id']); + } + + /** + * Retrieves a file from the tree. + * @param {string} fileEventId The event ID of the file. + * @returns {MSC3089Branch} The file, or falsy if not found. + */ + public getFile(fileEventId: string): MSC3089Branch { + const branch = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name, fileEventId); + return branch ? new MSC3089Branch(this.client, branch) : null; + } + + /** + * Gets an array of all known files for the tree. + * @returns {MSC3089Branch[]} The known files. May be empty, but not null. + */ + public listFiles(): MSC3089Branch[] { + const branches = this.room.currentState.getStateEvents(UNSTABLE_MSC3089_BRANCH.name) ?? []; + return branches.map(e => new MSC3089Branch(this.client, e)).filter(b => b.isActive); + } +} diff --git a/src/models/event-context.js b/src/models/event-context.js deleted file mode 100644 index b04018aba..000000000 --- a/src/models/event-context.js +++ /dev/null @@ -1,115 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/event-context - */ - -/** - * Construct a new EventContext - * - * An eventcontext is used for circumstances such as search results, when we - * have a particular event of interest, and a bunch of events before and after - * it. - * - * It also stores pagination tokens for going backwards and forwards in the - * timeline. - * - * @param {MatrixEvent} ourEvent the event at the centre of this context - * - * @constructor - */ -export function EventContext(ourEvent) { - this._timeline = [ourEvent]; - this._ourEventIndex = 0; - this._paginateTokens = { b: null, f: null }; - - // this is used by MatrixClient to keep track of active requests - this._paginateRequests = { b: null, f: null }; -} - -/** - * Get the main event of interest - * - * This is a convenience function for getTimeline()[getOurEventIndex()]. - * - * @return {MatrixEvent} The event at the centre of this context. - */ -EventContext.prototype.getEvent = function() { - return this._timeline[this._ourEventIndex]; -}; - -/** - * Get the list of events in this context - * - * @return {Array} An array of MatrixEvents - */ -EventContext.prototype.getTimeline = function() { - return this._timeline; -}; - -/** - * Get the index in the timeline of our event - * - * @return {Number} - */ -EventContext.prototype.getOurEventIndex = function() { - return this._ourEventIndex; -}; - -/** - * Get a pagination token. - * - * @param {boolean} backwards true to get the pagination token for going - * backwards in time - * @return {string} - */ -EventContext.prototype.getPaginateToken = function(backwards) { - return this._paginateTokens[backwards ? 'b' : 'f']; -}; - -/** - * Set a pagination token. - * - * Generally this will be used only by the matrix js sdk. - * - * @param {string} token pagination token - * @param {boolean} backwards true to set the pagination token for going - * backwards in time - */ -EventContext.prototype.setPaginateToken = function(token, backwards) { - this._paginateTokens[backwards ? 'b' : 'f'] = token; -}; - -/** - * Add more events to the timeline - * - * @param {Array} events new events, in timeline order - * @param {boolean} atStart true to insert new events at the start - */ -EventContext.prototype.addEvents = function(events, atStart) { - // TODO: should we share logic with Room.addEventsToTimeline? - // Should Room even use EventContext? - - if (atStart) { - this._timeline = events.concat(this._timeline); - this._ourEventIndex += events.length; - } else { - this._timeline = this._timeline.concat(events); - } -}; - diff --git a/src/models/event-context.ts b/src/models/event-context.ts new file mode 100644 index 000000000..18c64afee --- /dev/null +++ b/src/models/event-context.ts @@ -0,0 +1,119 @@ +/* +Copyright 2015 - 2021 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 { MatrixEvent } from "./event"; +import { Direction } from "./event-timeline"; + +/** + * @module models/event-context + */ +export class EventContext { + private timeline: MatrixEvent[]; + private ourEventIndex = 0; + private paginateTokens: Record = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventContext + * + * An eventcontext is used for circumstances such as search results, when we + * have a particular event of interest, and a bunch of events before and after + * it. + * + * It also stores pagination tokens for going backwards and forwards in the + * timeline. + * + * @param {MatrixEvent} ourEvent the event at the centre of this context + * + * @constructor + */ + constructor(ourEvent: MatrixEvent) { + this.timeline = [ourEvent]; + } + + /** + * Get the main event of interest + * + * This is a convenience function for getTimeline()[getOurEventIndex()]. + * + * @return {MatrixEvent} The event at the centre of this context. + */ + public getEvent(): MatrixEvent { + return this.timeline[this.ourEventIndex]; + } + + /** + * Get the list of events in this context + * + * @return {Array} An array of MatrixEvents + */ + public getTimeline(): MatrixEvent[] { + return this.timeline; + } + + /** + * Get the index in the timeline of our event + * + * @return {Number} + */ + public getOurEventIndex(): number { + return this.ourEventIndex; + } + + /** + * Get a pagination token. + * + * @param {boolean} backwards true to get the pagination token for going + * backwards in time + * @return {string} + */ + public getPaginateToken(backwards = false): string { + return this.paginateTokens[backwards ? Direction.Backward : Direction.Forward]; + } + + /** + * Set a pagination token. + * + * Generally this will be used only by the matrix js sdk. + * + * @param {string} token pagination token + * @param {boolean} backwards true to set the pagination token for going + * backwards in time + */ + public setPaginateToken(token: string, backwards = false): void { + this.paginateTokens[backwards ? Direction.Backward : Direction.Forward] = token; + } + + /** + * Add more events to the timeline + * + * @param {Array} events new events, in timeline order + * @param {boolean} atStart true to insert new events at the start + */ + public addEvents(events: MatrixEvent[], atStart = false): void { + // TODO: should we share logic with Room.addEventsToTimeline? + // Should Room even use EventContext? + + if (atStart) { + this.timeline = events.concat(this.timeline); + this.ourEventIndex += events.length; + } else { + this.timeline = this.timeline.concat(events); + } + } +} diff --git a/src/models/event-timeline-set.js b/src/models/event-timeline-set.js deleted file mode 100644 index 5835e5343..000000000 --- a/src/models/event-timeline-set.js +++ /dev/null @@ -1,848 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/event-timeline-set - */ - -import { EventEmitter } from "events"; -import { EventTimeline } from "./event-timeline"; -import { EventStatus } from "./event"; -import * as utils from "../utils"; -import { logger } from '../logger'; -import { Relations } from './relations'; - -// var DEBUG = false; -const DEBUG = true; - -let debuglog; -if (DEBUG) { - // using bind means that we get to keep useful line numbers in the console - debuglog = logger.log.bind(logger); -} else { - debuglog = function() {}; -} - -/** - * Construct a set of EventTimeline objects, typically on behalf of a given - * room. A room may have multiple EventTimelineSets for different levels - * of filtering. The global notification list is also an EventTimelineSet, but - * lacks a room. - * - *

This is an ordered sequence of timelines, which may or may not - * be continuous. Each timeline lists a series of events, as well as tracking - * the room state at the start and the end of the timeline (if appropriate). - * It also tracks forward and backward pagination tokens, as well as containing - * links to the next timeline in the sequence. - * - *

There is one special timeline - the 'live' timeline, which represents the - * timeline to which events are being added in real-time as they are received - * from the /sync API. Note that you should not retain references to this - * timeline - even if it is the current timeline right now, it may not remain - * so if the server gives us a timeline gap in /sync. - * - *

In order that we can find events from their ids later, we also maintain a - * map from event_id to timeline and index. - * - * @constructor - * @param {?Room} room - * Room for this timelineSet. May be null for non-room cases, such as the - * notification timeline. - * @param {Object} opts Options inherited from Room. - * - * @param {boolean} [opts.timelineSupport = false] - * Set to true to enable improved timeline support. - * @param {Object} [opts.filter = null] - * The filter object, if any, for this timelineSet. - * @param {boolean} [opts.unstableClientRelationAggregation = false] - * Optional. Set to true to enable client-side aggregation of event relations - * via `getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - */ -export function EventTimelineSet(room, opts) { - this.room = room; - - this._timelineSupport = Boolean(opts.timelineSupport); - this._liveTimeline = new EventTimeline(this); - this._unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; - - // just a list - *not* ordered. - this._timelines = [this._liveTimeline]; - this._eventIdToTimeline = {}; - - this._filter = opts.filter || null; - - if (this._unstableClientRelationAggregation) { - // A tree of objects to access a set of relations for an event, as in: - // this._relations[relatesToEventId][relationType][relationEventType] - this._relations = {}; - } -} -utils.inherits(EventTimelineSet, EventEmitter); - -/** - * Get all the timelines in this set - * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set - */ -EventTimelineSet.prototype.getTimelines = function() { - return this._timelines; -}; -/** - * Get the filter object this timeline set is filtered on, if any - * @return {?Filter} the optional filter for this timelineSet - */ -EventTimelineSet.prototype.getFilter = function() { - return this._filter; -}; - -/** - * Set the filter object this timeline set is filtered on - * (passed to the server when paginating via /messages). - * @param {Filter} filter the filter for this timelineSet - */ -EventTimelineSet.prototype.setFilter = function(filter) { - this._filter = filter; -}; - -/** - * Get the list of pending sent events for this timelineSet's room, filtered - * by the timelineSet's filter if appropriate. - * - * @return {module:models/event.MatrixEvent[]} A list of the sent events - * waiting for remote echo. - * - * @throws If opts.pendingEventOrdering was not 'detached' - */ -EventTimelineSet.prototype.getPendingEvents = function() { - if (!this.room) { - return []; - } - - if (this._filter) { - return this._filter.filterRoomTimeline(this.room.getPendingEvents()); - } else { - return this.room.getPendingEvents(); - } -}; - -/** - * Get the live timeline for this room. - * - * @return {module:models/event-timeline~EventTimeline} live timeline - */ -EventTimelineSet.prototype.getLiveTimeline = function() { - return this._liveTimeline; -}; - -/** - * Return the timeline (if any) this event is in. - * @param {String} eventId the eventId being sought - * @return {module:models/event-timeline~EventTimeline} timeline - */ -EventTimelineSet.prototype.eventIdToTimeline = function(eventId) { - return this._eventIdToTimeline[eventId]; -}; - -/** - * Track a new event as if it were in the same timeline as an old event, - * replacing it. - * @param {String} oldEventId event ID of the original event - * @param {String} newEventId event ID of the replacement event - */ -EventTimelineSet.prototype.replaceEventId = function(oldEventId, newEventId) { - const existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; - } -}; - -/** - * Reset the live timeline, and start a new one. - * - *

This is used when /sync returns a 'limited' timeline. - * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, - * if absent or null, all timelines are reset. - * - * @fires module:client~MatrixClient#event:"Room.timelineReset" - */ -EventTimelineSet.prototype.resetLiveTimeline = function( - backPaginationToken, forwardPaginationToken, -) { - // Each EventTimeline has RoomState objects tracking the state at the start - // and end of that timeline. The copies at the end of the live timeline are - // special because they will have listeners attached to monitor changes to - // the current room state, so we move this RoomState from the end of the - // current live timeline to the end of the new one and, if necessary, - // replace it with a newly created one. We also make a copy for the start - // of the new timeline. - - // if timeline support is disabled, forget about the old timelines - const resetAllTimelines = !this._timelineSupport || !forwardPaginationToken; - - const oldTimeline = this._liveTimeline; - const newTimeline = resetAllTimelines ? - oldTimeline.forkLive(EventTimeline.FORWARDS) : - oldTimeline.fork(EventTimeline.FORWARDS); - - if (resetAllTimelines) { - this._timelines = [newTimeline]; - this._eventIdToTimeline = {}; - } else { - this._timelines.push(newTimeline); - } - - if (forwardPaginationToken) { - // Now set the forward pagination token on the old live timeline - // so it can be forward-paginated. - oldTimeline.setPaginationToken( - forwardPaginationToken, EventTimeline.FORWARDS, - ); - } - - // make sure we set the pagination token before firing timelineReset, - // otherwise clients which start back-paginating will fail, and then get - // stuck without realising that they *can* back-paginate. - newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); - - // Now we can swap the live timeline to the new one. - this._liveTimeline = newTimeline; - this.emit("Room.timelineReset", this.room, this, resetAllTimelines); -}; - -/** - * Get the timeline which contains the given event, if any - * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing - * the given event, or null if unknown - */ -EventTimelineSet.prototype.getTimelineForEvent = function(eventId) { - const res = this._eventIdToTimeline[eventId]; - return (res === undefined) ? null : res; -}; - -/** - * Get an event which is stored in our timelines - * - * @param {string} eventId event ID to look for - * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown - */ -EventTimelineSet.prototype.findEventById = function(eventId) { - const tl = this.getTimelineForEvent(eventId); - if (!tl) { - return undefined; - } - return tl.getEvents().find(function(ev) { - return ev.getId() == eventId; - }); -}; - -/** - * Add a new timeline to this timeline list - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline - */ -EventTimelineSet.prototype.addTimeline = function() { - if (!this._timelineSupport) { - throw new Error("timeline support is disabled. Set the 'timelineSupport'" + - " parameter to true when creating MatrixClient to enable" + - " it."); - } - - const timeline = new EventTimeline(this); - this._timelines.push(timeline); - return timeline; -}; - -/** - * Add events to a timeline - * - *

Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {boolean} toStartOfTimeline True to add these events to the start - * (oldest) instead of the end (newest) of the timeline. If true, the oldest - * event will be the last element of 'events'. - * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to - * add events to. - * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" - * - */ -EventTimelineSet.prototype.addEventsToTimeline = function(events, toStartOfTimeline, - timeline, paginationToken) { - if (!timeline) { - throw new Error( - "'timeline' not specified for EventTimelineSet.addEventsToTimeline", - ); - } - - if (!toStartOfTimeline && timeline == this._liveTimeline) { - throw new Error( - "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + - "the live timeline - use Room.addLiveEvents instead", - ); - } - - if (this._filter) { - events = this._filter.filterRoomTimeline(events); - if (!events.length) { - return; - } - } - - const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : - EventTimeline.FORWARDS; - const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : - EventTimeline.BACKWARDS; - - // Adding events to timelines can be quite complicated. The following - // illustrates some of the corner-cases. - // - // Let's say we start by knowing about four timelines. timeline3 and - // timeline4 are neighbours: - // - // timeline1 timeline2 timeline3 timeline4 - // [M] [P] [S] <------> [T] - // - // Now we paginate timeline1, and get the following events from the server: - // [M, N, P, R, S, T, U]. - // - // 1. First, we ignore event M, since we already know about it. - // - // 2. Next, we append N to timeline 1. - // - // 3. Next, we don't add event P, since we already know about it, - // but we do link together the timelines. We now have: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P] [S] <------> [T] - // - // 4. Now we add event R to timeline2: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] [S] <------> [T] - // - // Note that we have switched the timeline we are working on from - // timeline1 to timeline2. - // - // 5. We ignore event S, but again join the timelines: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T] - // - // 6. We ignore event T, and the timelines are already joined, so there - // is nothing to do. - // - // 7. Finally, we add event U to timeline4: - // - // timeline1 timeline2 timeline3 timeline4 - // [M, N] <---> [P, R] <---> [S] <------> [T, U] - // - // The important thing to note in the above is what happened when we - // already knew about a given event: - // - // - if it was appropriate, we joined up the timelines (steps 3, 5). - // - in any case, we started adding further events to the timeline which - // contained the event we knew about (steps 3, 5, 6). - // - // - // So much for adding events to the timeline. But what do we want to do - // with the pagination token? - // - // In the case above, we will be given a pagination token which tells us how to - // get events beyond 'U' - in this case, it makes sense to store this - // against timeline4. But what if timeline4 already had 'U' and beyond? in - // that case, our best bet is to throw away the pagination token we were - // given and stick with whatever token timeline4 had previously. In short, - // we want to only store the pagination token if the last event we receive - // is one we didn't previously know about. - // - // We make an exception for this if it turns out that we already knew about - // *all* of the events, and we weren't able to join up any timelines. When - // that happens, it means our existing pagination token is faulty, since it - // is only telling us what we already know. Rather than repeatedly - // paginating with the same token, we might as well use the new pagination - // token in the hope that we eventually work our way out of the mess. - - let didUpdate = false; - let lastEventWasNew = false; - for (let i = 0; i < events.length; i++) { - const event = events[i]; - const eventId = event.getId(); - - const existingTimeline = this._eventIdToTimeline[eventId]; - - if (!existingTimeline) { - // we don't know about this event yet. Just add it to the timeline. - this.addEventToTimeline(event, timeline, toStartOfTimeline); - lastEventWasNew = true; - didUpdate = true; - continue; - } - - lastEventWasNew = false; - - if (existingTimeline == timeline) { - debuglog("Event " + eventId + " already in timeline " + timeline); - continue; - } - - const neighbour = timeline.getNeighbouringTimeline(direction); - if (neighbour) { - // this timeline already has a neighbour in the relevant direction; - // let's assume the timelines are already correctly linked up, and - // skip over to it. - // - // there's probably some edge-case here where we end up with an - // event which is in a timeline a way down the chain, and there is - // a break in the chain somewhere. But I can't really imagine how - // that would happen, so I'm going to ignore it for now. - // - if (existingTimeline == neighbour) { - debuglog("Event " + eventId + " in neighbouring timeline - " + - "switching to " + existingTimeline); - } else { - debuglog("Event " + eventId + " already in a different " + - "timeline " + existingTimeline); - } - timeline = existingTimeline; - continue; - } - - // time to join the timelines. - logger.info("Already have timeline for " + eventId + - " - joining timeline " + timeline + " to " + - existingTimeline); - - // Variables to keep the line length limited below. - const existingIsLive = existingTimeline === this._liveTimeline; - const timelineIsLive = timeline === this._liveTimeline; - - const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; - const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; - - if (backwardsIsLive || forwardsIsLive) { - // The live timeline should never be spliced into a non-live position. - // We use independent logging to better discover the problem at a glance. - if (backwardsIsLive) { - logger.warn( - "Refusing to set a preceding existingTimeLine on our " + - "timeline as the existingTimeLine is live (" + existingTimeline + ")", - ); - } - if (forwardsIsLive) { - logger.warn( - "Refusing to set our preceding timeline on a existingTimeLine " + - "as our timeline is live (" + timeline + ")", - ); - } - continue; // abort splicing - try next event - } - - timeline.setNeighbouringTimeline(existingTimeline, direction); - existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); - - timeline = existingTimeline; - didUpdate = true; - } - - // see above - if the last event was new to us, or if we didn't find any - // new information, we update the pagination token for whatever - // timeline we ended up on. - if (lastEventWasNew || !didUpdate) { - if (direction === EventTimeline.FORWARDS && timeline === this._liveTimeline) { - logger.warn({ lastEventWasNew, didUpdate }); // for debugging - logger.warn( - `Refusing to set forwards pagination token of live timeline ` + - `${timeline} to ${paginationToken}`, - ); - return; - } - timeline.setPaginationToken(paginationToken, direction); - } -}; - -/** - * Add an event to the end of this live timeline. - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - */ -EventTimelineSet.prototype.addLiveEvent = function(event, duplicateStrategy, fromCache) { - if (this._filter) { - const events = this._filter.filterRoomTimeline([event]); - if (!events.length) { - return; - } - } - - const timeline = this._eventIdToTimeline[event.getId()]; - if (timeline) { - if (duplicateStrategy === "replace") { - debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + - event.getId()); - const tlEvents = timeline.getEvents(); - for (let j = 0; j < tlEvents.length; j++) { - if (tlEvents[j].getId() === event.getId()) { - // still need to set the right metadata on this event - EventTimeline.setEventMetadata( - event, - timeline.getState(EventTimeline.FORWARDS), - false, - ); - - if (!tlEvents[j].encryptedType) { - tlEvents[j] = event; - } - - // XXX: we need to fire an event when this happens. - break; - } - } - } else { - debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + - event.getId()); - } - return; - } - - this.addEventToTimeline(event, this._liveTimeline, false, fromCache); -}; - -/** - * Add event to the given timeline, and emit Room.timeline. Assumes - * we have already checked we don't know about this event. - * - * Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent} event - * @param {EventTimeline} timeline - * @param {boolean} toStartOfTimeline - * @param {boolean} fromCache whether the sync response came from cache - * - * @fires module:client~MatrixClient#event:"Room.timeline" - */ -EventTimelineSet.prototype.addEventToTimeline = function(event, timeline, - toStartOfTimeline, fromCache) { - const eventId = event.getId(); - timeline.addEvent(event, toStartOfTimeline); - this._eventIdToTimeline[eventId] = timeline; - - this.setRelationsTarget(event); - this.aggregateRelations(event); - - const data = { - timeline: timeline, - liveEvent: !toStartOfTimeline && timeline == this._liveTimeline && !fromCache, - }; - this.emit("Room.timeline", event, this.room, - Boolean(toStartOfTimeline), false, data); -}; - -/** - * Replaces event with ID oldEventId with one with newEventId, if oldEventId is - * recognised. Otherwise, add to the live timeline. Used to handle remote echos. - * - * @param {MatrixEvent} localEvent the new event to be added to the timeline - * @param {String} oldEventId the ID of the original event - * @param {boolean} newEventId the ID of the replacement event - * - * @fires module:client~MatrixClient#event:"Room.timeline" - */ -EventTimelineSet.prototype.handleRemoteEcho = function(localEvent, oldEventId, - newEventId) { - // XXX: why don't we infer newEventId from localEvent? - const existingTimeline = this._eventIdToTimeline[oldEventId]; - if (existingTimeline) { - delete this._eventIdToTimeline[oldEventId]; - this._eventIdToTimeline[newEventId] = existingTimeline; - } else { - if (this._filter) { - if (this._filter.filterRoomTimeline([localEvent]).length) { - this.addEventToTimeline(localEvent, this._liveTimeline, false); - } - } else { - this.addEventToTimeline(localEvent, this._liveTimeline, false); - } - } -}; - -/** - * Removes a single event from this room. - * - * @param {String} eventId The id of the event to remove - * - * @return {?MatrixEvent} the removed event, or null if the event was not found - * in this room. - */ -EventTimelineSet.prototype.removeEvent = function(eventId) { - const timeline = this._eventIdToTimeline[eventId]; - if (!timeline) { - return null; - } - - const removed = timeline.removeEvent(eventId); - if (removed) { - delete this._eventIdToTimeline[eventId]; - const data = { - timeline: timeline, - }; - this.emit("Room.timeline", removed, this.room, undefined, true, data); - } - return removed; -}; - -/** - * Determine where two events appear in the timeline relative to one another - * - * @param {string} eventId1 The id of the first event - * @param {string} eventId2 The id of the second event - - * @return {?number} a number less than zero if eventId1 precedes eventId2, and - * greater than zero if eventId1 succeeds eventId2. zero if they are the - * same event; null if we can't tell (either because we don't know about one - * of the events, or because they are in separate timelines which don't join - * up). - */ -EventTimelineSet.prototype.compareEventOrdering = function(eventId1, eventId2) { - if (eventId1 == eventId2) { - // optimise this case - return 0; - } - - const timeline1 = this._eventIdToTimeline[eventId1]; - const timeline2 = this._eventIdToTimeline[eventId2]; - - if (timeline1 === undefined) { - return null; - } - if (timeline2 === undefined) { - return null; - } - - if (timeline1 === timeline2) { - // both events are in the same timeline - figure out their - // relative indices - let idx1; - let idx2; - const events = timeline1.getEvents(); - for (let idx = 0; idx < events.length && - (idx1 === undefined || idx2 === undefined); idx++) { - const evId = events[idx].getId(); - if (evId == eventId1) { - idx1 = idx; - } - if (evId == eventId2) { - idx2 = idx; - } - } - return idx1 - idx2; - } - - // the events are in different timelines. Iterate through the - // linkedlist to see which comes first. - - // first work forwards from timeline1 - let tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline1 is before timeline2 - return -1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - - // now try backwards from timeline1 - tl = timeline1; - while (tl) { - if (tl === timeline2) { - // timeline2 is before timeline1 - return 1; - } - tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - // the timelines are not contiguous. - return null; -}; - -/** - * Get a collection of relations to a given event in this timeline set. - * - * @param {String} eventId - * The ID of the event that you'd like to access relation events for. - * For example, with annotations, this would be the ID of the event being annotated. - * @param {String} relationType - * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. - * @param {String} eventType - * The relation event's type, such as "m.reaction", etc. - * @throws If eventId, relationType or eventType - * are not valid. - * - * @returns {?Relations} - * A container for relation events or undefined if there are no relation events for - * the relationType. - */ -EventTimelineSet.prototype.getRelationsForEvent = function( - eventId, relationType, eventType, -) { - if (!this._unstableClientRelationAggregation) { - throw new Error("Client-side relation aggregation is disabled"); - } - - if (!eventId || !relationType || !eventType) { - throw new Error("Invalid arguments for `getRelationsForEvent`"); - } - - // debuglog("Getting relations for: ", eventId, relationType, eventType); - - const relationsForEvent = this._relations[eventId] || {}; - const relationsWithRelType = relationsForEvent[relationType] || {}; - return relationsWithRelType[eventType]; -}; - -/** - * Set an event as the target event if any Relations exist for it already - * - * @param {MatrixEvent} event - * The event to check as relation target. - */ -EventTimelineSet.prototype.setRelationsTarget = function(event) { - if (!this._unstableClientRelationAggregation) { - return; - } - - const relationsForEvent = this._relations[event.getId()]; - if (!relationsForEvent) { - return; - } - - for (const relationsWithRelType of Object.values(relationsForEvent)) { - for (const relationsWithEventType of Object.values(relationsWithRelType)) { - relationsWithEventType.setTargetEvent(event); - } - } -}; - -/** - * Add relation events to the relevant relation collection. - * - * @param {MatrixEvent} event - * The new relation event to be aggregated. - */ -EventTimelineSet.prototype.aggregateRelations = function(event) { - if (!this._unstableClientRelationAggregation) { - return; - } - - if (event.isRedacted() || event.status === EventStatus.CANCELLED) { - return; - } - - // If the event is currently encrypted, wait until it has been decrypted. - if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { - event.once("Event.decrypted", () => { - this.aggregateRelations(event); - }); - return; - } - - const relation = event.getRelation(); - if (!relation) { - return; - } - - const relatesToEventId = relation.event_id; - const relationType = relation.rel_type; - const eventType = event.getType(); - - // debuglog("Aggregating relation: ", event.getId(), eventType, relation); - - let relationsForEvent = this._relations[relatesToEventId]; - if (!relationsForEvent) { - relationsForEvent = this._relations[relatesToEventId] = {}; - } - let relationsWithRelType = relationsForEvent[relationType]; - if (!relationsWithRelType) { - relationsWithRelType = relationsForEvent[relationType] = {}; - } - let relationsWithEventType = relationsWithRelType[eventType]; - - let relatesToEvent; - if (!relationsWithEventType) { - relationsWithEventType = relationsWithRelType[eventType] = new Relations( - relationType, - eventType, - this.room, - ); - relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); - if (relatesToEvent) { - relationsWithEventType.setTargetEvent(relatesToEvent); - } - } - - relationsWithEventType.addEvent(event); -}; - -/** - * Fires whenever the timeline in a room is updated. - * @event module:client~MatrixClient#"Room.timeline" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {?Room} room The room, if any, whose timeline was updated. - * @param {boolean} toStartOfTimeline True if this event was added to the start - * @param {boolean} removed True if this event has just been removed from the timeline - * (beginning; oldest) of the timeline e.g. due to pagination. - * - * @param {object} data more data about the event - * - * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the - * event was added to/removed from - * - * @param {boolean} data.liveEvent true if the event was a real-time event - * added to the end of the live timeline - * - * @example - * matrixClient.on("Room.timeline", - * function(event, room, toStartOfTimeline, removed, data) { - * if (!toStartOfTimeline && data.liveEvent) { - * var messageToAppend = room.timeline.[room.timeline.length - 1]; - * } - * }); - */ - -/** - * Fires whenever the live timeline in a room is reset. - * - * When we get a 'limited' sync (for example, after a network outage), we reset - * the live timeline to be empty before adding the recent events to the new - * timeline. This event is fired after the timeline is reset, and before the - * new events are added. - * - * @event module:client~MatrixClient#"Room.timelineReset" - * @param {Room} room The room whose live timeline was reset, if any - * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset - * @param {boolean} resetAllTimelines True if all timelines were reset. - */ diff --git a/src/models/event-timeline-set.ts b/src/models/event-timeline-set.ts new file mode 100644 index 000000000..d24252516 --- /dev/null +++ b/src/models/event-timeline-set.ts @@ -0,0 +1,874 @@ +/* +Copyright 2016 - 2021 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. +*/ + +/** + * @module models/event-timeline-set + */ + +import { EventEmitter } from "events"; + +import { EventTimeline } from "./event-timeline"; +import { EventStatus, MatrixEvent } from "./event"; +import { logger } from '../logger'; +import { Relations } from './relations'; +import { Room } from "./room"; +import { Filter } from "../filter"; +import { EventType, RelationType } from "../@types/event"; + +// var DEBUG = false; +const DEBUG = true; + +let debuglog; +if (DEBUG) { + // using bind means that we get to keep useful line numbers in the console + debuglog = logger.log.bind(logger); +} else { + debuglog = function() {}; +} + +interface IOpts { + timelineSupport?: boolean; + filter?: Filter; + unstableClientRelationAggregation?: boolean; +} + +export class EventTimelineSet extends EventEmitter { + private readonly timelineSupport: boolean; + private unstableClientRelationAggregation: boolean; + private liveTimeline: EventTimeline; + private timelines: EventTimeline[]; + private _eventIdToTimeline: Record; + private filter?: Filter; + private relations: Record>>; + + /** + * Construct a set of EventTimeline objects, typically on behalf of a given + * room. A room may have multiple EventTimelineSets for different levels + * of filtering. The global notification list is also an EventTimelineSet, but + * lacks a room. + * + *

This is an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline (if appropriate). + * It also tracks forward and backward pagination tokens, as well as containing + * links to the next timeline in the sequence. + * + *

There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + *

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @constructor + * @param {?Room} room + * Room for this timelineSet. May be null for non-room cases, such as the + * notification timeline. + * @param {Object} opts Options inherited from Room. + * + * @param {boolean} [opts.timelineSupport = false] + * Set to true to enable improved timeline support. + * @param {Object} [opts.filter = null] + * The filter object, if any, for this timelineSet. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. + */ + constructor(public readonly room: Room, opts: IOpts) { + super(); + + this.timelineSupport = Boolean(opts.timelineSupport); + this.liveTimeline = new EventTimeline(this); + this.unstableClientRelationAggregation = !!opts.unstableClientRelationAggregation; + + // just a list - *not* ordered. + this.timelines = [this.liveTimeline]; + this._eventIdToTimeline = {}; + + this.filter = opts.filter; + + if (this.unstableClientRelationAggregation) { + // A tree of objects to access a set of relations for an event, as in: + // this.relations[relatesToEventId][relationType][relationEventType] + this.relations = {}; + } + } + + /** + * Get all the timelines in this set + * @return {module:models/event-timeline~EventTimeline[]} the timelines in this set + */ + public getTimelines(): EventTimeline[] { + return this.timelines; + } + + /** + * Get the filter object this timeline set is filtered on, if any + * @return {?Filter} the optional filter for this timelineSet + */ + public getFilter(): Filter | undefined { + return this.filter; + } + + /** + * Set the filter object this timeline set is filtered on + * (passed to the server when paginating via /messages). + * @param {Filter} filter the filter for this timelineSet + */ + public setFilter(filter?: Filter): void { + this.filter = filter; + } + + /** + * Get the list of pending sent events for this timelineSet's room, filtered + * by the timelineSet's filter if appropriate. + * + * @return {module:models/event.MatrixEvent[]} A list of the sent events + * waiting for remote echo. + * + * @throws If opts.pendingEventOrdering was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (!this.room) { + return []; + } + + if (this.filter) { + return this.filter.filterRoomTimeline(this.room.getPendingEvents()); + } else { + return this.room.getPendingEvents(); + } + } + + /** + * Get the live timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.liveTimeline; + } + + /** + * Return the timeline (if any) this event is in. + * @param {String} eventId the eventId being sought + * @return {module:models/event-timeline~EventTimeline} timeline + */ + public eventIdToTimeline(eventId: string): EventTimeline { + return this._eventIdToTimeline[eventId]; + } + + /** + * Track a new event as if it were in the same timeline as an old event, + * replacing it. + * @param {String} oldEventId event ID of the original event + * @param {String} newEventId event ID of the replacement event + */ + public replaceEventId(oldEventId: string, newEventId: string): void { + const existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } + } + + /** + * Reset the live timeline, and start a new one. + * + *

This is used when /sync returns a 'limited' timeline. + * + * @param {string=} backPaginationToken token for back-paginating the new timeline + * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset. + * + * @fires module:client~MatrixClient#event:"Room.timelineReset" + */ + public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken?: string): void { + // Each EventTimeline has RoomState objects tracking the state at the start + // and end of that timeline. The copies at the end of the live timeline are + // special because they will have listeners attached to monitor changes to + // the current room state, so we move this RoomState from the end of the + // current live timeline to the end of the new one and, if necessary, + // replace it with a newly created one. We also make a copy for the start + // of the new timeline. + + // if timeline support is disabled, forget about the old timelines + const resetAllTimelines = !this.timelineSupport || !forwardPaginationToken; + + const oldTimeline = this.liveTimeline; + const newTimeline = resetAllTimelines ? + oldTimeline.forkLive(EventTimeline.FORWARDS) : + oldTimeline.fork(EventTimeline.FORWARDS); + + if (resetAllTimelines) { + this.timelines = [newTimeline]; + this._eventIdToTimeline = {}; + } else { + this.timelines.push(newTimeline); + } + + if (forwardPaginationToken) { + // Now set the forward pagination token on the old live timeline + // so it can be forward-paginated. + oldTimeline.setPaginationToken( + forwardPaginationToken, EventTimeline.FORWARDS, + ); + } + + // make sure we set the pagination token before firing timelineReset, + // otherwise clients which start back-paginating will fail, and then get + // stuck without realising that they *can* back-paginate. + newTimeline.setPaginationToken(backPaginationToken, EventTimeline.BACKWARDS); + + // Now we can swap the live timeline to the new one. + this.liveTimeline = newTimeline; + this.emit("Room.timelineReset", this.room, this, resetAllTimelines); + } + + /** + * Get the timeline which contains the given event, if any + * + * @param {string} eventId event ID to look for + * @return {?module:models/event-timeline~EventTimeline} timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId: string): EventTimeline | null { + const res = this._eventIdToTimeline[eventId]; + return (res === undefined) ? null : res; + } + + /** + * Get an event which is stored in our timelines + * + * @param {string} eventId event ID to look for + * @return {?module:models/event~MatrixEvent} the given event, or undefined if unknown + */ + public findEventById(eventId: string): MatrixEvent | undefined { + const tl = this.getTimelineForEvent(eventId); + if (!tl) { + return undefined; + } + return tl.getEvents().find(function(ev) { + return ev.getId() == eventId; + }); + } + + /** + * Add a new timeline to this timeline list + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ + public addTimeline(): EventTimeline { + if (!this.timelineSupport) { + throw new Error("timeline support is disabled. Set the 'timelineSupport'" + + " parameter to true when creating MatrixClient to enable" + + " it."); + } + + const timeline = new EventTimeline(this); + this.timelines.push(timeline); + return timeline; + } + + /** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken: string, + ): void { + if (!timeline) { + throw new Error( + "'timeline' not specified for EventTimelineSet.addEventsToTimeline", + ); + } + + if (!toStartOfTimeline && timeline == this.liveTimeline) { + throw new Error( + "EventTimelineSet.addEventsToTimeline cannot be used for adding events to " + + "the live timeline - use Room.addLiveEvents instead", + ); + } + + if (this.filter) { + events = this.filter.filterRoomTimeline(events); + if (!events.length) { + return; + } + } + + const direction = toStartOfTimeline ? EventTimeline.BACKWARDS : + EventTimeline.FORWARDS; + const inverseDirection = toStartOfTimeline ? EventTimeline.FORWARDS : + EventTimeline.BACKWARDS; + + // Adding events to timelines can be quite complicated. The following + // illustrates some of the corner-cases. + // + // Let's say we start by knowing about four timelines. timeline3 and + // timeline4 are neighbours: + // + // timeline1 timeline2 timeline3 timeline4 + // [M] [P] [S] <------> [T] + // + // Now we paginate timeline1, and get the following events from the server: + // [M, N, P, R, S, T, U]. + // + // 1. First, we ignore event M, since we already know about it. + // + // 2. Next, we append N to timeline 1. + // + // 3. Next, we don't add event P, since we already know about it, + // but we do link together the timelines. We now have: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P] [S] <------> [T] + // + // 4. Now we add event R to timeline2: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] [S] <------> [T] + // + // Note that we have switched the timeline we are working on from + // timeline1 to timeline2. + // + // 5. We ignore event S, but again join the timelines: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T] + // + // 6. We ignore event T, and the timelines are already joined, so there + // is nothing to do. + // + // 7. Finally, we add event U to timeline4: + // + // timeline1 timeline2 timeline3 timeline4 + // [M, N] <---> [P, R] <---> [S] <------> [T, U] + // + // The important thing to note in the above is what happened when we + // already knew about a given event: + // + // - if it was appropriate, we joined up the timelines (steps 3, 5). + // - in any case, we started adding further events to the timeline which + // contained the event we knew about (steps 3, 5, 6). + // + // + // So much for adding events to the timeline. But what do we want to do + // with the pagination token? + // + // In the case above, we will be given a pagination token which tells us how to + // get events beyond 'U' - in this case, it makes sense to store this + // against timeline4. But what if timeline4 already had 'U' and beyond? in + // that case, our best bet is to throw away the pagination token we were + // given and stick with whatever token timeline4 had previously. In short, + // we want to only store the pagination token if the last event we receive + // is one we didn't previously know about. + // + // We make an exception for this if it turns out that we already knew about + // *all* of the events, and we weren't able to join up any timelines. When + // that happens, it means our existing pagination token is faulty, since it + // is only telling us what we already know. Rather than repeatedly + // paginating with the same token, we might as well use the new pagination + // token in the hope that we eventually work our way out of the mess. + + let didUpdate = false; + let lastEventWasNew = false; + for (let i = 0; i < events.length; i++) { + const event = events[i]; + const eventId = event.getId(); + + const existingTimeline = this._eventIdToTimeline[eventId]; + + if (!existingTimeline) { + // we don't know about this event yet. Just add it to the timeline. + this.addEventToTimeline(event, timeline, toStartOfTimeline); + lastEventWasNew = true; + didUpdate = true; + continue; + } + + lastEventWasNew = false; + + if (existingTimeline == timeline) { + debuglog("Event " + eventId + " already in timeline " + timeline); + continue; + } + + const neighbour = timeline.getNeighbouringTimeline(direction); + if (neighbour) { + // this timeline already has a neighbour in the relevant direction; + // let's assume the timelines are already correctly linked up, and + // skip over to it. + // + // there's probably some edge-case here where we end up with an + // event which is in a timeline a way down the chain, and there is + // a break in the chain somewhere. But I can't really imagine how + // that would happen, so I'm going to ignore it for now. + // + if (existingTimeline == neighbour) { + debuglog("Event " + eventId + " in neighbouring timeline - " + + "switching to " + existingTimeline); + } else { + debuglog("Event " + eventId + " already in a different " + + "timeline " + existingTimeline); + } + timeline = existingTimeline; + continue; + } + + // time to join the timelines. + logger.info("Already have timeline for " + eventId + + " - joining timeline " + timeline + " to " + + existingTimeline); + + // Variables to keep the line length limited below. + const existingIsLive = existingTimeline === this.liveTimeline; + const timelineIsLive = timeline === this.liveTimeline; + + const backwardsIsLive = direction === EventTimeline.BACKWARDS && existingIsLive; + const forwardsIsLive = direction === EventTimeline.FORWARDS && timelineIsLive; + + if (backwardsIsLive || forwardsIsLive) { + // The live timeline should never be spliced into a non-live position. + // We use independent logging to better discover the problem at a glance. + if (backwardsIsLive) { + logger.warn( + "Refusing to set a preceding existingTimeLine on our " + + "timeline as the existingTimeLine is live (" + existingTimeline + ")", + ); + } + if (forwardsIsLive) { + logger.warn( + "Refusing to set our preceding timeline on a existingTimeLine " + + "as our timeline is live (" + timeline + ")", + ); + } + continue; // abort splicing - try next event + } + + timeline.setNeighbouringTimeline(existingTimeline, direction); + existingTimeline.setNeighbouringTimeline(timeline, inverseDirection); + + timeline = existingTimeline; + didUpdate = true; + } + + // see above - if the last event was new to us, or if we didn't find any + // new information, we update the pagination token for whatever + // timeline we ended up on. + if (lastEventWasNew || !didUpdate) { + if (direction === EventTimeline.FORWARDS && timeline === this.liveTimeline) { + logger.warn({ lastEventWasNew, didUpdate }); // for debugging + logger.warn( + `Refusing to set forwards pagination token of live timeline ` + + `${timeline} to ${paginationToken}`, + ); + return; + } + timeline.setPaginationToken(paginationToken, direction); + } + } + + /** + * Add an event to the end of this live timeline. + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + */ + public addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void { + if (this.filter) { + const events = this.filter.filterRoomTimeline([event]); + if (!events.length) { + return; + } + } + + const timeline = this._eventIdToTimeline[event.getId()]; + if (timeline) { + if (duplicateStrategy === "replace") { + debuglog("EventTimelineSet.addLiveEvent: replacing duplicate event " + + event.getId()); + const tlEvents = timeline.getEvents(); + for (let j = 0; j < tlEvents.length; j++) { + if (tlEvents[j].getId() === event.getId()) { + // still need to set the right metadata on this event + EventTimeline.setEventMetadata( + event, + timeline.getState(EventTimeline.FORWARDS), + false, + ); + tlEvents[j] = event; + + // XXX: we need to fire an event when this happens. + break; + } + } + } else { + debuglog("EventTimelineSet.addLiveEvent: ignoring duplicate event " + + event.getId()); + } + return; + } + + this.addEventToTimeline(event, this.liveTimeline, false, fromCache); + } + + /** + * Add event to the given timeline, and emit Room.timeline. Assumes + * we have already checked we don't know about this event. + * + * Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent} event + * @param {EventTimeline} timeline + * @param {boolean} toStartOfTimeline + * @param {boolean} fromCache whether the sync response came from cache + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ + public addEventToTimeline( + event: MatrixEvent, + timeline: EventTimeline, + toStartOfTimeline: boolean, + fromCache = false, + ) { + const eventId = event.getId(); + timeline.addEvent(event, toStartOfTimeline); + this._eventIdToTimeline[eventId] = timeline; + + this.setRelationsTarget(event); + this.aggregateRelations(event); + + const data = { + timeline: timeline, + liveEvent: !toStartOfTimeline && timeline == this.liveTimeline && !fromCache, + }; + this.emit("Room.timeline", event, this.room, + Boolean(toStartOfTimeline), false, data); + } + + /** + * Replaces event with ID oldEventId with one with newEventId, if oldEventId is + * recognised. Otherwise, add to the live timeline. Used to handle remote echos. + * + * @param {MatrixEvent} localEvent the new event to be added to the timeline + * @param {String} oldEventId the ID of the original event + * @param {boolean} newEventId the ID of the replacement event + * + * @fires module:client~MatrixClient#event:"Room.timeline" + */ + public handleRemoteEcho( + localEvent: MatrixEvent, + oldEventId: string, + newEventId: string, + ): void { + // XXX: why don't we infer newEventId from localEvent? + const existingTimeline = this._eventIdToTimeline[oldEventId]; + if (existingTimeline) { + delete this._eventIdToTimeline[oldEventId]; + this._eventIdToTimeline[newEventId] = existingTimeline; + } else { + if (this.filter) { + if (this.filter.filterRoomTimeline([localEvent]).length) { + this.addEventToTimeline(localEvent, this.liveTimeline, false); + } + } else { + this.addEventToTimeline(localEvent, this.liveTimeline, false); + } + } + } + + /** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {?MatrixEvent} the removed event, or null if the event was not found + * in this room. + */ + public removeEvent(eventId: string): MatrixEvent | null { + const timeline = this._eventIdToTimeline[eventId]; + if (!timeline) { + return null; + } + + const removed = timeline.removeEvent(eventId); + if (removed) { + delete this._eventIdToTimeline[eventId]; + const data = { + timeline: timeline, + }; + this.emit("Room.timeline", removed, this.room, undefined, true, data); + } + return removed; + } + + /** + * Determine where two events appear in the timeline relative to one another + * + * @param {string} eventId1 The id of the first event + * @param {string} eventId2 The id of the second event + + * @return {?number} a number less than zero if eventId1 precedes eventId2, and + * greater than zero if eventId1 succeeds eventId2. zero if they are the + * same event; null if we can't tell (either because we don't know about one + * of the events, or because they are in separate timelines which don't join + * up). + */ + public compareEventOrdering(eventId1: string, eventId2: string): number | null { + if (eventId1 == eventId2) { + // optimise this case + return 0; + } + + const timeline1 = this._eventIdToTimeline[eventId1]; + const timeline2 = this._eventIdToTimeline[eventId2]; + + if (timeline1 === undefined) { + return null; + } + if (timeline2 === undefined) { + return null; + } + + if (timeline1 === timeline2) { + // both events are in the same timeline - figure out their + // relative indices + let idx1; + let idx2; + const events = timeline1.getEvents(); + for (let idx = 0; idx < events.length && + (idx1 === undefined || idx2 === undefined); idx++) { + const evId = events[idx].getId(); + if (evId == eventId1) { + idx1 = idx; + } + if (evId == eventId2) { + idx2 = idx; + } + } + return idx1 - idx2; + } + + // the events are in different timelines. Iterate through the + // linkedlist to see which comes first. + + // first work forwards from timeline1 + let tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline1 is before timeline2 + return -1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + + // now try backwards from timeline1 + tl = timeline1; + while (tl) { + if (tl === timeline2) { + // timeline2 is before timeline1 + return 1; + } + tl = tl.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + // the timelines are not contiguous. + return null; + } + + /** + * Get a collection of relations to a given event in this timeline set. + * + * @param {String} eventId + * The ID of the event that you'd like to access relation events for. + * For example, with annotations, this would be the ID of the event being annotated. + * @param {String} relationType + * The type of relation involved, such as "m.annotation", "m.reference", "m.replace", etc. + * @param {String} eventType + * The relation event's type, such as "m.reaction", etc. + * @throws If eventId, relationType or eventType + * are not valid. + * + * @returns {?Relations} + * A container for relation events or undefined if there are no relation events for + * the relationType. + */ + public getRelationsForEvent( + eventId: string, + relationType: RelationType, + eventType: EventType | string, + ): Relations | undefined { + if (!this.unstableClientRelationAggregation) { + throw new Error("Client-side relation aggregation is disabled"); + } + + if (!eventId || !relationType || !eventType) { + throw new Error("Invalid arguments for `getRelationsForEvent`"); + } + + // debuglog("Getting relations for: ", eventId, relationType, eventType); + + const relationsForEvent = this.relations[eventId] || {}; + const relationsWithRelType = relationsForEvent[relationType] || {}; + return relationsWithRelType[eventType]; + } + + /** + * Set an event as the target event if any Relations exist for it already + * + * @param {MatrixEvent} event + * The event to check as relation target. + */ + public setRelationsTarget(event: MatrixEvent): void { + if (!this.unstableClientRelationAggregation) { + return; + } + + const relationsForEvent = this.relations[event.getId()]; + if (!relationsForEvent) { + return; + } + + for (const relationsWithRelType of Object.values(relationsForEvent)) { + for (const relationsWithEventType of Object.values(relationsWithRelType)) { + relationsWithEventType.setTargetEvent(event); + } + } + } + + /** + * Add relation events to the relevant relation collection. + * + * @param {MatrixEvent} event + * The new relation event to be aggregated. + */ + public aggregateRelations(event: MatrixEvent): void { + if (!this.unstableClientRelationAggregation) { + return; + } + + if (event.isRedacted() || event.status === EventStatus.CANCELLED) { + return; + } + + // If the event is currently encrypted, wait until it has been decrypted. + if (event.isBeingDecrypted() || event.shouldAttemptDecryption()) { + event.once("Event.decrypted", () => { + this.aggregateRelations(event); + }); + return; + } + + const relation = event.getRelation(); + if (!relation) { + return; + } + + const relatesToEventId = relation.event_id; + const relationType = relation.rel_type; + const eventType = event.getType(); + + // debuglog("Aggregating relation: ", event.getId(), eventType, relation); + + let relationsForEvent: Record>> = this.relations[relatesToEventId]; + if (!relationsForEvent) { + relationsForEvent = this.relations[relatesToEventId] = {}; + } + let relationsWithRelType = relationsForEvent[relationType]; + if (!relationsWithRelType) { + relationsWithRelType = relationsForEvent[relationType] = {}; + } + let relationsWithEventType = relationsWithRelType[eventType]; + + let relatesToEvent; + if (!relationsWithEventType) { + relationsWithEventType = relationsWithRelType[eventType] = new Relations( + relationType, + eventType, + this.room, + ); + relatesToEvent = this.findEventById(relatesToEventId) || this.room.getPendingEvent(relatesToEventId); + if (relatesToEvent) { + relationsWithEventType.setTargetEvent(relatesToEvent); + } + } + + relationsWithEventType.addEvent(event); + } +} + +/** + * Fires whenever the timeline in a room is updated. + * @event module:client~MatrixClient#"Room.timeline" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {?Room} room The room, if any, whose timeline was updated. + * @param {boolean} toStartOfTimeline True if this event was added to the start + * @param {boolean} removed True if this event has just been removed from the timeline + * (beginning; oldest) of the timeline e.g. due to pagination. + * + * @param {object} data more data about the event + * + * @param {module:models/event-timeline.EventTimeline} data.timeline the timeline the + * event was added to/removed from + * + * @param {boolean} data.liveEvent true if the event was a real-time event + * added to the end of the live timeline + * + * @example + * matrixClient.on("Room.timeline", + * function(event, room, toStartOfTimeline, removed, data) { + * if (!toStartOfTimeline && data.liveEvent) { + * var messageToAppend = room.timeline.[room.timeline.length - 1]; + * } + * }); + */ + +/** + * Fires whenever the live timeline in a room is reset. + * + * When we get a 'limited' sync (for example, after a network outage), we reset + * the live timeline to be empty before adding the recent events to the new + * timeline. This event is fired after the timeline is reset, and before the + * new events are added. + * + * @event module:client~MatrixClient#"Room.timelineReset" + * @param {Room} room The room whose live timeline was reset, if any + * @param {EventTimelineSet} timelineSet timelineSet room whose live timeline was reset + * @param {boolean} resetAllTimelines True if all timelines were reset. + */ diff --git a/src/models/event-timeline.js b/src/models/event-timeline.js deleted file mode 100644 index 288659611..000000000 --- a/src/models/event-timeline.js +++ /dev/null @@ -1,398 +0,0 @@ -/* -Copyright 2016, 2017 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/event-timeline - */ - -import { RoomState } from "./room-state"; - -/** - * Construct a new EventTimeline - * - *

An EventTimeline represents a contiguous sequence of events in a room. - * - *

As well as keeping track of the events themselves, it stores the state of - * the room at the beginning and end of the timeline, and pagination tokens for - * going backwards and forwards in the timeline. - * - *

In order that clients can meaningfully maintain an index into a timeline, - * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is - * incremented when events are prepended to the timeline. The index of an event - * relative to baseIndex therefore remains constant. - * - *

Once a timeline joins up with its neighbour, they are linked together into a - * doubly-linked list. - * - * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of - * @constructor - */ -export function EventTimeline(eventTimelineSet) { - this._eventTimelineSet = eventTimelineSet; - this._roomId = eventTimelineSet.room ? eventTimelineSet.room.roomId : null; - this._events = []; - this._baseIndex = 0; - this._startState = new RoomState(this._roomId); - this._startState.paginationToken = null; - this._endState = new RoomState(this._roomId); - this._endState.paginationToken = null; - - this._prevTimeline = null; - this._nextTimeline = null; - - // this is used by client.js - this._paginationRequests = { 'b': null, 'f': null }; - - this._name = this._roomId + ":" + new Date().toISOString(); -} - -/** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the start of the timeline, or backwards in time. - */ -EventTimeline.BACKWARDS = "b"; - -/** - * Symbolic constant for methods which take a 'direction' argument: - * refers to the end of the timeline, or forwards in time. - */ -EventTimeline.FORWARDS = "f"; - -/** - * Initialise the start and end state with the given events - * - *

This can only be called before any events are added. - * - * @param {MatrixEvent[]} stateEvents list of state events to initialise the - * state with. - * @throws {Error} if an attempt is made to call this after addEvent is called. - */ -EventTimeline.prototype.initialiseState = function(stateEvents) { - if (this._events.length > 0) { - throw new Error("Cannot initialise state after events are added"); - } - - // We previously deep copied events here and used different copies in - // the oldState and state events: this decision seems to date back - // quite a way and was apparently made to fix a bug where modifications - // made to the start state leaked through to the end state. - // This really shouldn't be possible though: the events themselves should - // not change. Duplicating the events uses a lot of extra memory, - // so we now no longer do it. To assert that they really do never change, - // freeze them! Note that we can't do this for events in general: - // although it looks like the only things preventing us are the - // 'status' flag, forwardLooking (which is only set once when adding to the - // timeline) and possibly the sender (which seems like it should never be - // reset but in practice causes a lot of the tests to break). - for (const e of stateEvents) { - Object.freeze(e); - } - - this._startState.setStateEvents(stateEvents); - this._endState.setStateEvents(stateEvents); -}; - -/** - * Forks the (live) timeline, taking ownership of the existing directional state of this timeline. - * All attached listeners will keep receiving state updates from the new live timeline state. - * The end state of this timeline gets replaced with an independent copy of the current RoomState, - * and will need a new pagination token if it ever needs to paginate forwards. - - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {EventTimeline} the new timeline - */ -EventTimeline.prototype.forkLive = function(direction) { - const forkState = this.getState(direction); - const timeline = new EventTimeline(this._eventTimelineSet); - timeline._startState = forkState.clone(); - // Now clobber the end state of the new live timeline with that from the - // previous live timeline. It will be identical except that we'll keep - // using the same RoomMember objects for the 'live' set of members with any - // listeners still attached - timeline._endState = forkState; - // Firstly, we just stole the current timeline's end state, so it needs a new one. - // Make an immutable copy of the state so back pagination will get the correct sentinels. - this._endState = forkState.clone(); - return timeline; -}; - -/** - * Creates an independent timeline, inheriting the directional state from this timeline. - * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {EventTimeline} the new timeline - */ -EventTimeline.prototype.fork = function(direction) { - const forkState = this.getState(direction); - const timeline = new EventTimeline(this._eventTimelineSet); - timeline._startState = forkState.clone(); - timeline._endState = forkState.clone(); - return timeline; -}; - -/** - * Get the ID of the room for this timeline - * @return {string} room ID - */ -EventTimeline.prototype.getRoomId = function() { - return this._roomId; -}; - -/** - * Get the filter for this timeline's timelineSet (if any) - * @return {Filter} filter - */ -EventTimeline.prototype.getFilter = function() { - return this._eventTimelineSet.getFilter(); -}; - -/** - * Get the timelineSet for this timeline - * @return {EventTimelineSet} timelineSet - */ -EventTimeline.prototype.getTimelineSet = function() { - return this._eventTimelineSet; -}; - -/** - * Get the base index. - * - *

This is an index which is incremented when events are prepended to the - * timeline. An individual event therefore stays at the same index in the array - * relative to the base index (although note that a given event's index may - * well be less than the base index, thus giving that event a negative relative - * index). - * - * @return {number} - */ -EventTimeline.prototype.getBaseIndex = function() { - return this._baseIndex; -}; - -/** - * Get the list of events in this context - * - * @return {MatrixEvent[]} An array of MatrixEvents - */ -EventTimeline.prototype.getEvents = function() { - return this._events; -}; - -/** - * Get the room state at the start/end of the timeline - * - * @param {string} direction EventTimeline.BACKWARDS to get the state at the - * start of the timeline; EventTimeline.FORWARDS to get the state at the end - * of the timeline. - * - * @return {RoomState} state at the start/end of the timeline - */ -EventTimeline.prototype.getState = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._startState; - } else if (direction == EventTimeline.FORWARDS) { - return this._endState; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Get a pagination token - * - * @param {string} direction EventTimeline.BACKWARDS to get the pagination - * token for going backwards in time; EventTimeline.FORWARDS to get the - * pagination token for going forwards in time. - * - * @return {?string} pagination token - */ -EventTimeline.prototype.getPaginationToken = function(direction) { - return this.getState(direction).paginationToken; -}; - -/** - * Set a pagination token - * - * @param {?string} token pagination token - * - * @param {string} direction EventTimeline.BACKWARDS to set the pagination - * token for going backwards in time; EventTimeline.FORWARDS to set the - * pagination token for going forwards in time. - */ -EventTimeline.prototype.setPaginationToken = function(token, direction) { - this.getState(direction).paginationToken = token; -}; - -/** - * Get the next timeline in the series - * - * @param {string} direction EventTimeline.BACKWARDS to get the previous - * timeline; EventTimeline.FORWARDS to get the next timeline. - * - * @return {?EventTimeline} previous or following timeline, if they have been - * joined up. - */ -EventTimeline.prototype.getNeighbouringTimeline = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._prevTimeline; - } else if (direction == EventTimeline.FORWARDS) { - return this._nextTimeline; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Set the next timeline in the series - * - * @param {EventTimeline} neighbour previous/following timeline - * - * @param {string} direction EventTimeline.BACKWARDS to set the previous - * timeline; EventTimeline.FORWARDS to set the next timeline. - * - * @throws {Error} if an attempt is made to set the neighbouring timeline when - * it is already set. - */ -EventTimeline.prototype.setNeighbouringTimeline = function(neighbour, direction) { - if (this.getNeighbouringTimeline(direction)) { - throw new Error("timeline already has a neighbouring timeline - " + - "cannot reset neighbour (direction: " + direction + ")"); - } - - if (direction == EventTimeline.BACKWARDS) { - this._prevTimeline = neighbour; - } else if (direction == EventTimeline.FORWARDS) { - this._nextTimeline = neighbour; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } - - // make sure we don't try to paginate this timeline - this.setPaginationToken(null, direction); -}; - -/** - * Add a new event to the timeline, and update the state - * - * @param {MatrixEvent} event new event - * @param {boolean} atStart true to insert new event at the start - */ -EventTimeline.prototype.addEvent = function(event, atStart) { - const stateContext = atStart ? this._startState : this._endState; - - // only call setEventMetadata on the unfiltered timelineSets - const timelineSet = this.getTimelineSet(); - if (timelineSet.room && - timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { - EventTimeline.setEventMetadata(event, stateContext, atStart); - - // modify state - if (event.isState()) { - stateContext.setStateEvents([event]); - // it is possible that the act of setting the state event means we - // can set more metadata (specifically sender/target props), so try - // it again if the prop wasn't previously set. It may also mean that - // the sender/target is updated (if the event set was a room member event) - // so we want to use the *updated* member (new avatar/name) instead. - // - // However, we do NOT want to do this on member events if we're going - // back in time, else we'll set the .sender value for BEFORE the given - // member event, whereas we want to set the .sender value for the ACTUAL - // member event itself. - if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { - EventTimeline.setEventMetadata(event, stateContext, atStart); - } - } - } - - let insertIndex; - - if (atStart) { - insertIndex = 0; - } else { - insertIndex = this._events.length; - } - - this._events.splice(insertIndex, 0, event); // insert element - if (atStart) { - this._baseIndex++; - } -}; - -/** - * Static helper method to set sender and target properties - * - * @param {MatrixEvent} event the event whose metadata is to be set - * @param {RoomState} stateContext the room state to be queried - * @param {bool} toStartOfTimeline if true the event's forwardLooking flag is set false - */ -EventTimeline.setEventMetadata = function(event, stateContext, toStartOfTimeline) { - // set sender and target properties - event.sender = stateContext.getSentinelMember( - event.getSender(), - ); - if (event.getType() === "m.room.member") { - event.target = stateContext.getSentinelMember( - event.getStateKey(), - ); - } - if (event.isState()) { - // room state has no concept of 'old' or 'current', but we want the - // room state to regress back to previous values if toStartOfTimeline - // is set, which means inspecting prev_content if it exists. This - // is done by toggling the forwardLooking flag. - if (toStartOfTimeline) { - event.forwardLooking = false; - } - } -}; - -/** - * Remove an event from the timeline - * - * @param {string} eventId ID of event to be removed - * @return {?MatrixEvent} removed event, or null if not found - */ -EventTimeline.prototype.removeEvent = function(eventId) { - for (let i = this._events.length - 1; i >= 0; i--) { - const ev = this._events[i]; - if (ev.getId() == eventId) { - this._events.splice(i, 1); - if (i < this._baseIndex) { - this._baseIndex--; - } - return ev; - } - } - return null; -}; - -/** - * Return a string to identify this timeline, for debugging - * - * @return {string} name for this timeline - */ -EventTimeline.prototype.toString = function() { - return this._name; -}; - diff --git a/src/models/event-timeline.ts b/src/models/event-timeline.ts new file mode 100644 index 000000000..92a9b9633 --- /dev/null +++ b/src/models/event-timeline.ts @@ -0,0 +1,416 @@ +/* +Copyright 2016 - 2021 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. +*/ + +/** + * @module models/event-timeline + */ + +import { RoomState } from "./room-state"; +import { EventTimelineSet } from "./event-timeline-set"; +import { MatrixEvent } from "./event"; +import { Filter } from "../filter"; + +export enum Direction { + Backward = "b", + Forward = "f", +} + +export class EventTimeline { + /** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the start of the timeline, or backwards in time. + */ + static BACKWARDS = Direction.Backward; + + /** + * Symbolic constant for methods which take a 'direction' argument: + * refers to the end of the timeline, or forwards in time. + */ + static FORWARDS = Direction.Forward; + + /** + * Static helper method to set sender and target properties + * + * @param {MatrixEvent} event the event whose metadata is to be set + * @param {RoomState} stateContext the room state to be queried + * @param {boolean} toStartOfTimeline if true the event's forwardLooking flag is set false + */ + static setEventMetadata(event: MatrixEvent, stateContext: RoomState, toStartOfTimeline: boolean): void { + // set sender and target properties + event.sender = stateContext.getSentinelMember( + event.getSender(), + ); + if (event.getType() === "m.room.member") { + event.target = stateContext.getSentinelMember( + event.getStateKey(), + ); + } + if (event.isState()) { + // room state has no concept of 'old' or 'current', but we want the + // room state to regress back to previous values if toStartOfTimeline + // is set, which means inspecting prev_content if it exists. This + // is done by toggling the forwardLooking flag. + if (toStartOfTimeline) { + event.forwardLooking = false; + } + } + } + + private readonly roomId: string | null; + private readonly name: string; + private events: MatrixEvent[] = []; + private baseIndex = 0; + private startState: RoomState; + private endState: RoomState; + private prevTimeline?: EventTimeline; + private nextTimeline?: EventTimeline; + public paginationRequests: Record> = { + [Direction.Backward]: null, + [Direction.Forward]: null, + }; + + /** + * Construct a new EventTimeline + * + *

An EventTimeline represents a contiguous sequence of events in a room. + * + *

As well as keeping track of the events themselves, it stores the state of + * the room at the beginning and end of the timeline, and pagination tokens for + * going backwards and forwards in the timeline. + * + *

In order that clients can meaningfully maintain an index into a timeline, + * the EventTimeline object tracks a 'baseIndex'. This starts at zero, but is + * incremented when events are prepended to the timeline. The index of an event + * relative to baseIndex therefore remains constant. + * + *

Once a timeline joins up with its neighbour, they are linked together into a + * doubly-linked list. + * + * @param {EventTimelineSet} eventTimelineSet the set of timelines this is part of + * @constructor + */ + constructor(private readonly eventTimelineSet: EventTimelineSet) { + this.roomId = eventTimelineSet.room?.roomId ?? null; + this.startState = new RoomState(this.roomId); + this.startState.paginationToken = null; + this.endState = new RoomState(this.roomId); + this.endState.paginationToken = null; + + this.prevTimeline = null; + this.nextTimeline = null; + + // this is used by client.js + this.paginationRequests = { 'b': null, 'f': null }; + + this.name = this.roomId + ":" + new Date().toISOString(); + } + + /** + * Initialise the start and end state with the given events + * + *

This can only be called before any events are added. + * + * @param {MatrixEvent[]} stateEvents list of state events to initialise the + * state with. + * @throws {Error} if an attempt is made to call this after addEvent is called. + */ + public initialiseState(stateEvents: MatrixEvent[]): void { + if (this.events.length > 0) { + throw new Error("Cannot initialise state after events are added"); + } + + // We previously deep copied events here and used different copies in + // the oldState and state events: this decision seems to date back + // quite a way and was apparently made to fix a bug where modifications + // made to the start state leaked through to the end state. + // This really shouldn't be possible though: the events themselves should + // not change. Duplicating the events uses a lot of extra memory, + // so we now no longer do it. To assert that they really do never change, + // freeze them! Note that we can't do this for events in general: + // although it looks like the only things preventing us are the + // 'status' flag, forwardLooking (which is only set once when adding to the + // timeline) and possibly the sender (which seems like it should never be + // reset but in practice causes a lot of the tests to break). + for (const e of stateEvents) { + Object.freeze(e); + } + + this.startState.setStateEvents(stateEvents); + this.endState.setStateEvents(stateEvents); + } + + /** + * Forks the (live) timeline, taking ownership of the existing directional state of this timeline. + * All attached listeners will keep receiving state updates from the new live timeline state. + * The end state of this timeline gets replaced with an independent copy of the current RoomState, + * and will need a new pagination token if it ever needs to paginate forwards. + + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {EventTimeline} the new timeline + */ + public forkLive(direction: Direction): EventTimeline { + const forkState = this.getState(direction); + const timeline = new EventTimeline(this.eventTimelineSet); + timeline.startState = forkState.clone(); + // Now clobber the end state of the new live timeline with that from the + // previous live timeline. It will be identical except that we'll keep + // using the same RoomMember objects for the 'live' set of members with any + // listeners still attached + timeline.endState = forkState; + // Firstly, we just stole the current timeline's end state, so it needs a new one. + // Make an immutable copy of the state so back pagination will get the correct sentinels. + this.endState = forkState.clone(); + return timeline; + } + + /** + * Creates an independent timeline, inheriting the directional state from this timeline. + * + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {EventTimeline} the new timeline + */ + public fork(direction: Direction): EventTimeline { + const forkState = this.getState(direction); + const timeline = new EventTimeline(this.eventTimelineSet); + timeline.startState = forkState.clone(); + timeline.endState = forkState.clone(); + return timeline; + } + + /** + * Get the ID of the room for this timeline + * @return {string} room ID + */ + public getRoomId(): string { + return this.roomId; + } + + /** + * Get the filter for this timeline's timelineSet (if any) + * @return {Filter} filter + */ + public getFilter(): Filter { + return this.eventTimelineSet.getFilter(); + } + + /** + * Get the timelineSet for this timeline + * @return {EventTimelineSet} timelineSet + */ + public getTimelineSet(): EventTimelineSet { + return this.eventTimelineSet; + } + + /** + * Get the base index. + * + *

This is an index which is incremented when events are prepended to the + * timeline. An individual event therefore stays at the same index in the array + * relative to the base index (although note that a given event's index may + * well be less than the base index, thus giving that event a negative relative + * index). + * + * @return {number} + */ + public getBaseIndex(): number { + return this.baseIndex; + } + + /** + * Get the list of events in this context + * + * @return {MatrixEvent[]} An array of MatrixEvents + */ + public getEvents(): MatrixEvent[] { + return this.events; + } + + /** + * Get the room state at the start/end of the timeline + * + * @param {string} direction EventTimeline.BACKWARDS to get the state at the + * start of the timeline; EventTimeline.FORWARDS to get the state at the end + * of the timeline. + * + * @return {RoomState} state at the start/end of the timeline + */ + public getState(direction: Direction): RoomState { + if (direction == EventTimeline.BACKWARDS) { + return this.startState; + } else if (direction == EventTimeline.FORWARDS) { + return this.endState; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Get a pagination token + * + * @param {string} direction EventTimeline.BACKWARDS to get the pagination + * token for going backwards in time; EventTimeline.FORWARDS to get the + * pagination token for going forwards in time. + * + * @return {?string} pagination token + */ + public getPaginationToken(direction: Direction): string | null { + return this.getState(direction).paginationToken; + } + + /** + * Set a pagination token + * + * @param {?string} token pagination token + * + * @param {string} direction EventTimeline.BACKWARDS to set the pagination + * token for going backwards in time; EventTimeline.FORWARDS to set the + * pagination token for going forwards in time. + */ + public setPaginationToken(token: string, direction: Direction): void { + this.getState(direction).paginationToken = token; + } + + /** + * Get the next timeline in the series + * + * @param {string} direction EventTimeline.BACKWARDS to get the previous + * timeline; EventTimeline.FORWARDS to get the next timeline. + * + * @return {?EventTimeline} previous or following timeline, if they have been + * joined up. + */ + public getNeighbouringTimeline(direction: Direction): EventTimeline { + if (direction == EventTimeline.BACKWARDS) { + return this.prevTimeline; + } else if (direction == EventTimeline.FORWARDS) { + return this.nextTimeline; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Set the next timeline in the series + * + * @param {EventTimeline} neighbour previous/following timeline + * + * @param {string} direction EventTimeline.BACKWARDS to set the previous + * timeline; EventTimeline.FORWARDS to set the next timeline. + * + * @throws {Error} if an attempt is made to set the neighbouring timeline when + * it is already set. + */ + public setNeighbouringTimeline(neighbour: EventTimeline, direction: Direction): void { + if (this.getNeighbouringTimeline(direction)) { + throw new Error("timeline already has a neighbouring timeline - " + + "cannot reset neighbour (direction: " + direction + ")"); + } + + if (direction == EventTimeline.BACKWARDS) { + this.prevTimeline = neighbour; + } else if (direction == EventTimeline.FORWARDS) { + this.nextTimeline = neighbour; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + + // make sure we don't try to paginate this timeline + this.setPaginationToken(null, direction); + } + + /** + * Add a new event to the timeline, and update the state + * + * @param {MatrixEvent} event new event + * @param {boolean} atStart true to insert new event at the start + */ + public addEvent(event: MatrixEvent, atStart: boolean): void { + const stateContext = atStart ? this.startState : this.endState; + + // only call setEventMetadata on the unfiltered timelineSets + const timelineSet = this.getTimelineSet(); + if (timelineSet.room && + timelineSet.room.getUnfilteredTimelineSet() === timelineSet) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + + // modify state + if (event.isState()) { + stateContext.setStateEvents([event]); + // it is possible that the act of setting the state event means we + // can set more metadata (specifically sender/target props), so try + // it again if the prop wasn't previously set. It may also mean that + // the sender/target is updated (if the event set was a room member event) + // so we want to use the *updated* member (new avatar/name) instead. + // + // However, we do NOT want to do this on member events if we're going + // back in time, else we'll set the .sender value for BEFORE the given + // member event, whereas we want to set the .sender value for the ACTUAL + // member event itself. + if (!event.sender || (event.getType() === "m.room.member" && !atStart)) { + EventTimeline.setEventMetadata(event, stateContext, atStart); + } + } + } + + let insertIndex; + + if (atStart) { + insertIndex = 0; + } else { + insertIndex = this.events.length; + } + + this.events.splice(insertIndex, 0, event); // insert element + if (atStart) { + this.baseIndex++; + } + } + + /** + * Remove an event from the timeline + * + * @param {string} eventId ID of event to be removed + * @return {?MatrixEvent} removed event, or null if not found + */ + public removeEvent(eventId: string): MatrixEvent | null { + for (let i = this.events.length - 1; i >= 0; i--) { + const ev = this.events[i]; + if (ev.getId() == eventId) { + this.events.splice(i, 1); + if (i < this.baseIndex) { + this.baseIndex--; + } + return ev; + } + } + return null; + } + + /** + * Return a string to identify this timeline, for debugging + * + * @return {string} name for this timeline + */ + public toString(): string { + return this.name; + } +} diff --git a/src/models/event.js b/src/models/event.ts similarity index 66% rename from src/models/event.js rename to src/models/event.ts index 1b3bec755..9050f4440 100644 --- a/src/models/event.js +++ b/src/models/event.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,153 +21,174 @@ limitations under the License. */ import { EventEmitter } from 'events'; -import * as utils from '../utils'; + import { logger } from '../logger'; +import { VerificationRequest } from "../crypto/verification/request/VerificationRequest"; +import { EventType, MsgType, RelationType } from "../@types/event"; +import { Crypto } from "../crypto"; +import { deepSortedObjectEntries } from "../utils"; +import { RoomMember } from "./room-member"; /** * Enum for event statuses. * @readonly * @enum {string} */ -export const EventStatus = { +export enum EventStatus { /** The event was not sent and will no longer be retried. */ - NOT_SENT: "not_sent", + NOT_SENT = "not_sent", /** The message is being encrypted */ - ENCRYPTING: "encrypting", + ENCRYPTING = "encrypting", /** The event is in the process of being sent. */ - SENDING: "sending", + SENDING = "sending", + /** The event is in a queue waiting to be sent. */ - QUEUED: "queued", - /** The event has been sent to the server, but we have not yet received the - * echo. */ - SENT: "sent", + QUEUED = "queued", + + /** The event has been sent to the server, but we have not yet received the echo. */ + SENT = "sent", /** The event was cancelled before it was successfully sent. */ - CANCELLED: "cancelled", -}; + CANCELLED = "cancelled", +} -const interns = {}; -function intern(str) { +const interns: Record = {}; +function intern(str: string): string { if (!interns[str]) { interns[str] = str; } return interns[str]; } -/** - * Construct a Matrix Event object - * @constructor - * - * @param {Object} event The raw event to be wrapped in this DAO - * - * @prop {Object} event The raw (possibly encrypted) event. Do not access - * this property directly unless you absolutely have to. Prefer the getter - * methods defined on this class. Using the getter methods shields your app - * from changes to event JSON between Matrix versions. - * - * @prop {RoomMember} sender The room member who sent this event, or null e.g. - * this is a presence event. This is only guaranteed to be set for events that - * appear in a timeline, ie. do not guarantee that it will be set on state - * events. - * @prop {RoomMember} target The room member who is the target of this event, e.g. - * the invitee, the person being banned, etc. - * @prop {EventStatus} status The sending status of the event. - * @prop {Error} error most recent error associated with sending the event, if any - * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning - * that getDirectionalContent() will return event.content and not event.prev_content. - * Default: true. This property is experimental and may change. - */ -export const MatrixEvent = function( - event, -) { - // intern the values of matrix events to force share strings and reduce the - // amount of needless string duplication. This can save moderate amounts of - // memory (~10% on a 350MB heap). - // 'membership' at the event level (rather than the content level) is a legacy - // field that Element never otherwise looks at, but it will still take up a lot - // of space if we don't intern it. - ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { - if (!event[prop]) { - return; - } - event[prop] = intern(event[prop]); - }); +/* eslint-disable camelcase */ +export interface IContent { + [key: string]: any; + msgtype?: MsgType | string; + membership?: string; + avatar_url?: string; + displayname?: string; + "m.relates_to"?: IEventRelation; +} - ["membership", "avatar_url", "displayname"].forEach((prop) => { - if (!event.content || !event.content[prop]) { - return; - } - event.content[prop] = intern(event.content[prop]); - }); +type StrippedState = Required>; - ["rel_type"].forEach((prop) => { - if ( - !event.content || - !event.content["m.relates_to"] || - !event.content["m.relates_to"][prop] - ) { - return; - } - event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); - }); +export interface IUnsigned { + age?: number; + prev_sender?: string; + prev_content?: IContent; + redacted_because?: IEvent; + transaction_id?: string; + invite_room_state?: StrippedState[]; +} - this.event = event || {}; +export interface IEvent { + event_id: string; + type: string; + content: IContent; + sender: string; + room_id: string; + origin_server_ts: number; + txn_id?: string; + state_key?: string; + membership?: string; + unsigned: IUnsigned; + redacts?: string; - this.sender = null; - this.target = null; - this.status = null; - this.error = null; - this.forwardLooking = true; - this._pushActions = null; - this._replacingEvent = null; - this._localRedactionEvent = null; - this._isCancelled = false; + // v1 legacy fields + user_id?: string; + prev_content?: IContent; + age?: number; +} - this._clearEvent = {}; +interface IAggregatedRelation { + origin_server_ts: number; + event_id?: string; + sender?: string; + type?: string; + count?: number; + key?: string; +} + +interface IEventRelation { + rel_type: RelationType | string; + event_id: string; + key?: string; +} + +interface IDecryptionResult { + clearEvent: { + room_id?: string; + type: string; + content: IContent; + unsigned?: IUnsigned; + }; + forwardingCurve25519KeyChain?: string[]; + senderCurve25519Key?: string; + claimedEd25519Key?: string; + untrusted?: boolean; +} +/* eslint-enable camelcase */ + +export interface IClearEvent { + type: string; + content: Omit; + unsigned?: IUnsigned; +} + +interface IKeyRequestRecipient { + userId: string; + deviceId: "*" | string; +} + +export interface IDecryptOptions { + emit?: boolean; + isRetry?: boolean; +} + +export class MatrixEvent extends EventEmitter { + private pushActions: object = null; + private _replacingEvent: MatrixEvent = null; + private _localRedactionEvent: MatrixEvent = null; + private _isCancelled = false; + private clearEvent: Partial = {}; /* curve25519 key which we believe belongs to the sender of the event. See * getSenderKey() */ - this._senderCurve25519Key = null; + private senderCurve25519Key: string = null; /* ed25519 key which the sender of this event (for olm) or the creator of * the megolm session (for megolm) claims to own. See getClaimedEd25519Key() */ - this._claimedEd25519Key = null; + private claimedEd25519Key: string = null; /* curve25519 keys of devices involved in telling us about the - * _senderCurve25519Key and _claimedEd25519Key. + * senderCurve25519Key and claimedEd25519Key. * See getForwardingCurve25519KeyChain(). */ - this._forwardingCurve25519KeyChain = []; + private forwardingCurve25519KeyChain: string[] = []; /* where the decryption key is untrusted */ - this._untrusted = null; + private untrusted: boolean = null; /* if we have a process decrypting this event, a Promise which resolves * when it is finished. Normally null. */ - this._decryptionPromise = null; + private _decryptionPromise: Promise = null; /* flag to indicate if we should retry decrypting this event after the * first attempt (eg, we have received new data which means that a second * attempt may succeed) */ - this._retryDecryption = false; - - /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, - * `Crypto` will set this the `VerificationRequest` for the event - * so it can be easily accessed from the timeline. - */ - this.verificationRequest = null; + private retryDecryption = false; /* The txnId with which this event was sent if it was during this session, - allows for a unique ID which does not change when the event comes back down sync. + * allows for a unique ID which does not change when the event comes back down sync. */ - this._txnId = event.txn_id || null; + private txnId: string = null; /* Set an approximate timestamp for the event relative the local clock. * This will inherently be approximate because it doesn't take into account @@ -176,36 +196,97 @@ export const MatrixEvent = function( * it to us and the time we're now constructing this event, but that's better * than assuming the local clock is in sync with the origin HS's clock. */ - this._localTimestamp = Date.now() - this.getAge(); -}; -utils.inherits(MatrixEvent, EventEmitter); + private readonly localTimestamp: number; + + // XXX: these should be read-only + public sender: RoomMember = null; + public target: RoomMember = null; + public status: EventStatus = null; + public error = null; + public forwardLooking = true; + + /* If the event is a `m.key.verification.request` (or to_device `m.key.verification.start`) event, + * `Crypto` will set this the `VerificationRequest` for the event + * so it can be easily accessed from the timeline. + */ + public verificationRequest = null; + + /** + * Construct a Matrix Event object + * @constructor + * + * @param {Object} event The raw event to be wrapped in this DAO + * + * @prop {Object} event The raw (possibly encrypted) event. Do not access + * this property directly unless you absolutely have to. Prefer the getter + * methods defined on this class. Using the getter methods shields your app + * from changes to event JSON between Matrix versions. + * + * @prop {RoomMember} sender The room member who sent this event, or null e.g. + * this is a presence event. This is only guaranteed to be set for events that + * appear in a timeline, ie. do not guarantee that it will be set on state + * events. + * @prop {RoomMember} target The room member who is the target of this event, e.g. + * the invitee, the person being banned, etc. + * @prop {EventStatus} status The sending status of the event. + * @prop {Error} error most recent error associated with sending the event, if any + * @prop {boolean} forwardLooking True if this event is 'forward looking', meaning + * that getDirectionalContent() will return event.content and not event.prev_content. + * Default: true. This property is experimental and may change. + */ + constructor(public event: Partial = {}) { + super(); + + // intern the values of matrix events to force share strings and reduce the + // amount of needless string duplication. This can save moderate amounts of + // memory (~10% on a 350MB heap). + // 'membership' at the event level (rather than the content level) is a legacy + // field that Element never otherwise looks at, but it will still take up a lot + // of space if we don't intern it. + ["state_key", "type", "sender", "room_id", "membership"].forEach((prop) => { + if (typeof event[prop] !== "string") return; + event[prop] = intern(event[prop]); + }); + + ["membership", "avatar_url", "displayname"].forEach((prop) => { + if (typeof event.content?.[prop] !== "string") return; + event.content[prop] = intern(event.content[prop]); + }); + + ["rel_type"].forEach((prop) => { + if (typeof event.content?.["m.relates_to"]?.[prop] !== "string") return; + event.content["m.relates_to"][prop] = intern(event.content["m.relates_to"][prop]); + }); + + this.txnId = event.txn_id || null; + this.localTimestamp = Date.now() - this.getAge(); + } -utils.extend(MatrixEvent.prototype, { /** * Get the event_id for this event. * @return {string} The event ID, e.g. $143350589368169JsLZx:localhost * */ - getId: function() { + public getId(): string { return this.event.event_id; - }, + } /** * Get the user_id for this event. * @return {string} The user ID, e.g. @alice:matrix.org */ - getSender: function() { + public getSender(): string { return this.event.sender || this.event.user_id; // v2 / v1 - }, + } /** * Get the (decrypted, if necessary) type of event. * * @return {string} The event type, e.g. m.room.message */ - getType: function() { - return this._clearEvent.type || this.event.type; - }, + public getType(): EventType | string { + return this.clearEvent.type || this.event.type; + } /** * Get the (possibly encrypted) type of the event that will be sent to the @@ -213,9 +294,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} The event type. */ - getWireType: function() { + public getWireType(): EventType | string { return this.event.type; - }, + } /** * Get the room_id for this event. This will return undefined @@ -223,25 +304,25 @@ utils.extend(MatrixEvent.prototype, { * @return {string} The room ID, e.g. !cURbafjkfsMDVwdRDQ:matrix.org * */ - getRoomId: function() { + public getRoomId(): string { return this.event.room_id; - }, + } /** * Get the timestamp of this event. * @return {Number} The event timestamp, e.g. 1433502692297 */ - getTs: function() { + public getTs(): number { return this.event.origin_server_ts; - }, + } /** * Get the timestamp of this event, as a Date object. * @return {Date} The event date, e.g. new Date(1433502692297) */ - getDate: function() { + public getDate(): Date | null { return this.event.origin_server_ts ? new Date(this.event.origin_server_ts) : null; - }, + } /** * Get the (decrypted, if necessary) event content JSON, even if the event @@ -249,12 +330,12 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getOriginalContent: function() { + public getOriginalContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } - return this._clearEvent.content || this.event.content || {}; - }, + return (this.clearEvent.content || this.event.content || {}) as T; + } /** * Get the (decrypted, if necessary) event content JSON, @@ -263,15 +344,15 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getContent: function() { + public getContent(): T { if (this._localRedactionEvent) { - return {}; + return {} as T; } else if (this._replacingEvent) { return this._replacingEvent.getContent()["m.new_content"] || {}; } else { return this.getOriginalContent(); } - }, + } /** * Get the (possibly encrypted) event content JSON that will be sent to the @@ -279,19 +360,19 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} The event content JSON, or an empty object. */ - getWireContent: function() { + public getWireContent(): IContent { return this.event.content || {}; - }, + } /** * Get the previous event content JSON. This will only return something for * state events which exist in the timeline. * @return {Object} The previous event content JSON, or an empty object. */ - getPrevContent: function() { + public getPrevContent(): IContent { // v2 then v1 then default return this.getUnsigned().prev_content || this.event.prev_content || {}; - }, + } /** * Get either 'content' or 'prev_content' depending on if this event is @@ -302,9 +383,9 @@ utils.extend(MatrixEvent.prototype, { * @return {Object} event.content if this event is forward-looking, else * event.prev_content. */ - getDirectionalContent: function() { + public getDirectionalContent(): IContent { return this.forwardLooking ? this.getContent() : this.getPrevContent(); - }, + } /** * Get the age of this event. This represents the age of the event when the @@ -312,9 +393,9 @@ utils.extend(MatrixEvent.prototype, { * function was called. * @return {Number} The age of this event in milliseconds. */ - getAge: function() { + public getAge(): number { return this.getUnsigned().age || this.event.age; // v2 / v1 - }, + } /** * Get the age of the event when this function was called. @@ -322,26 +403,26 @@ utils.extend(MatrixEvent.prototype, { * had the event. * @return {Number} The age of this event in milliseconds. */ - getLocalAge: function() { - return Date.now() - this._localTimestamp; - }, + public getLocalAge(): number { + return Date.now() - this.localTimestamp; + } /** * Get the event state_key if it has one. This will return undefined * for message events. * @return {string} The event's state_key. */ - getStateKey: function() { + public getStateKey(): string | undefined { return this.event.state_key; - }, + } /** * Check if this event is a state event. * @return {boolean} True if this is a state event. */ - isState: function() { + public isState(): boolean { return this.event.state_key !== undefined; - }, + } /** * Replace the content of this event with encrypted versions. @@ -349,10 +430,10 @@ utils.extend(MatrixEvent.prototype, { * * @internal * - * @param {string} crypto_type type of the encrypted event - typically + * @param {string} cryptoType type of the encrypted event - typically * "m.room.encrypted" * - * @param {object} crypto_content raw 'content' for the encrypted event. + * @param {object} cryptoContent raw 'content' for the encrypted event. * * @param {string} senderCurve25519Key curve25519 key to record for the * sender of this event. @@ -362,28 +443,35 @@ utils.extend(MatrixEvent.prototype, { * sender if this event. * See {@link module:models/event.MatrixEvent#getClaimedEd25519Key} */ - makeEncrypted: function( - crypto_type, crypto_content, senderCurve25519Key, claimedEd25519Key, - ) { + public makeEncrypted( + cryptoType: string, + cryptoContent: object, + senderCurve25519Key: string, + claimedEd25519Key: string, + ): void { // keep the plain-text data for 'view source' - this._clearEvent = { + this.clearEvent = { type: this.event.type, content: this.event.content, }; - this.event.type = crypto_type; - this.event.content = crypto_content; - this._senderCurve25519Key = senderCurve25519Key; - this._claimedEd25519Key = claimedEd25519Key; - }, + this.event.type = cryptoType; + this.event.content = cryptoContent; + this.senderCurve25519Key = senderCurve25519Key; + this.claimedEd25519Key = claimedEd25519Key; + } /** * Check if this event is currently being decrypted. * * @return {boolean} True if this event is currently being decrypted, else false. */ - isBeingDecrypted: function() { + public isBeingDecrypted(): boolean { return this._decryptionPromise != null; - }, + } + + public getDecryptionPromise(): Promise { + return this._decryptionPromise; + } /** * Check if this event is an encrypted event which we failed to decrypt @@ -393,16 +481,13 @@ utils.extend(MatrixEvent.prototype, { * @return {boolean} True if this event is an encrypted event which we * couldn't decrypt. */ - isDecryptionFailure: function() { - return this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype === "m.bad.encrypted"; - }, + public isDecryptionFailure(): boolean { + return this.clearEvent?.content?.msgtype === "m.bad.encrypted"; + } - shouldAttemptDecryption: function() { - return this.isEncrypted() - && !this.isBeingDecrypted() - && this.getClearContent() === null; - }, + public shouldAttemptDecryption() { + return this.isEncrypted() && !this.isBeingDecrypted() && this.getClearContent() === null; + } /** * Start the process of trying to decrypt this event. @@ -413,13 +498,13 @@ utils.extend(MatrixEvent.prototype, { * * @param {module:crypto} crypto crypto module * @param {object} options - * @param {bool} options.isRetry True if this is a retry (enables more logging) - * @param {bool} options.emit Emits "event.decrypted" if set to true + * @param {boolean} options.isRetry True if this is a retry (enables more logging) + * @param {boolean} options.emit Emits "event.decrypted" if set to true * * @returns {Promise} promise which resolves (to undefined) when the decryption * attempt is completed. */ - attemptDecryption: async function(crypto, options = {}) { + public async attemptDecryption(crypto: Crypto, options: IDecryptOptions = {}): Promise { // For backwards compatibility purposes // The function signature used to be attemptDecryption(crypto, isRetry) if (typeof options === "boolean") { @@ -434,8 +519,8 @@ utils.extend(MatrixEvent.prototype, { } if ( - this._clearEvent && this._clearEvent.content && - this._clearEvent.content.msgtype !== "m.bad.encrypted" + this.clearEvent && this.clearEvent.content && + this.clearEvent.content.msgtype !== "m.bad.encrypted" ) { // we may want to just ignore this? let's start with rejecting it. throw new Error( @@ -453,13 +538,13 @@ utils.extend(MatrixEvent.prototype, { logger.log( `Event ${this.getId()} already being decrypted; queueing a retry`, ); - this._retryDecryption = true; + this.retryDecryption = true; return this._decryptionPromise; } - this._decryptionPromise = this._decryptionLoop(crypto, options); + this._decryptionPromise = this.decryptionLoop(crypto, options); return this._decryptionPromise; - }, + } /** * Cancel any room key request for this event and resend another. @@ -469,7 +554,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Promise} a promise that resolves when the request is queued */ - cancelAndResendKeyRequest: function(crypto, userId) { + public cancelAndResendKeyRequest(crypto: Crypto, userId: string): Promise { const wireContent = this.getWireContent(); return crypto.requestRoomKey({ algorithm: wireContent.algorithm, @@ -477,7 +562,7 @@ utils.extend(MatrixEvent.prototype, { session_id: wireContent.session_id, sender_key: wireContent.sender_key, }, this.getKeyRequestRecipients(userId), true); - }, + } /** * Calculate the recipients for keyshare requests. @@ -486,7 +571,7 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Array} array of recipients */ - getKeyRequestRecipients: function(userId) { + public getKeyRequestRecipients(userId: string): IKeyRequestRecipient[] { // send the request to all of our own devices, and the // original sending device if it wasn't us. const wireContent = this.getWireContent(); @@ -500,23 +585,24 @@ utils.extend(MatrixEvent.prototype, { }); } return recipients; - }, + } - _decryptionLoop: async function(crypto, options = {}) { + private async decryptionLoop(crypto: Crypto, options: IDecryptOptions = {}): Promise { // make sure that this method never runs completely synchronously. // (doing so would mean that we would clear _decryptionPromise *before* // it is set in attemptDecryption - and hence end up with a stuck // `_decryptionPromise`). await Promise.resolve(); + // eslint-disable-next-line no-constant-condition while (true) { - this._retryDecryption = false; + this.retryDecryption = false; let res; let err; try { if (!crypto) { - res = this._badEncryptedMessage("Encryption not enabled"); + res = this.badEncryptedMessage("Encryption not enabled"); } else { res = await crypto.decryptEvent(this); if (options.isRetry === true) { @@ -533,7 +619,7 @@ utils.extend(MatrixEvent.prototype, { `(id=${this.getId()}): ${e.stack || e}`, ); this._decryptionPromise = null; - this._retryDecryption = false; + this.retryDecryption = false; return; } @@ -545,15 +631,15 @@ utils.extend(MatrixEvent.prototype, { // event loop as `_decryptionPromise = null` below - otherwise we // risk a race: // - // * A: we check _retryDecryption here and see that it is + // * A: we check retryDecryption here and see that it is // false // * B: we get a second call to attemptDecryption, which sees // that _decryptionPromise is set so sets - // _retryDecryption + // retryDecryption // * A: we continue below, clear _decryptionPromise, and // never do the retry. // - if (this._retryDecryption) { + if (this.retryDecryption) { // decryption error, but we have a retry queued. logger.log( `Got error decrypting event (id=${this.getId()}: ` + @@ -568,7 +654,7 @@ utils.extend(MatrixEvent.prototype, { `Error decrypting event (id=${this.getId()}): ${e.detailedString}`, ); - res = this._badEncryptedMessage(e.message); + res = this.badEncryptedMessage(e.message); } // at this point, we've either successfully decrypted the event, or have given up @@ -579,11 +665,11 @@ utils.extend(MatrixEvent.prototype, { // otherwise the app will be confused to see `isBeingDecrypted` still set when // there isn't an `Event.decrypted` on the way. // - // see also notes on _retryDecryption above. + // see also notes on retryDecryption above. // this._decryptionPromise = null; - this._retryDecryption = false; - this._setClearData(res); + this.retryDecryption = false; + this.setClearData(res); // Before we emit the event, clear the push actions so that they can be recalculated // by relevant code. We do this because the clear event has now changed, making it @@ -599,9 +685,9 @@ utils.extend(MatrixEvent.prototype, { return; } - }, + } - _badEncryptedMessage: function(reason) { + private badEncryptedMessage(reason: string): IDecryptionResult { return { clearEvent: { type: "m.room.message", @@ -611,7 +697,7 @@ utils.extend(MatrixEvent.prototype, { }, }, }; - }, + } /** * Update the cleartext data on this event. @@ -625,16 +711,16 @@ utils.extend(MatrixEvent.prototype, { * @param {module:crypto~EventDecryptionResult} decryptionResult * the decryption result, including the plaintext and some key info */ - _setClearData: function(decryptionResult) { - this._clearEvent = decryptionResult.clearEvent; - this._senderCurve25519Key = + private setClearData(decryptionResult: IDecryptionResult): void { + this.clearEvent = decryptionResult.clearEvent; + this.senderCurve25519Key = decryptionResult.senderCurve25519Key || null; - this._claimedEd25519Key = + this.claimedEd25519Key = decryptionResult.claimedEd25519Key || null; - this._forwardingCurve25519KeyChain = + this.forwardingCurve25519KeyChain = decryptionResult.forwardingCurve25519KeyChain || []; - this._untrusted = decryptionResult.untrusted || false; - }, + this.untrusted = decryptionResult.untrusted || false; + } /** * Gets the cleartext content for this event. If the event is not encrypted, @@ -642,18 +728,18 @@ utils.extend(MatrixEvent.prototype, { * * @returns {Object} The cleartext (decrypted) content for the event */ - getClearContent: function() { - const ev = this._clearEvent; + public getClearContent(): IContent | null { + const ev = this.clearEvent; return ev && ev.content ? ev.content : null; - }, + } /** * Check if the event is encrypted. * @return {boolean} True if this event is encrypted. */ - isEncrypted: function() { + public isEncrypted(): boolean { return !this.isState() && this.event.type === "m.room.encrypted"; - }, + } /** * The curve25519 key for the device that we think sent this event @@ -668,9 +754,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getSenderKey: function() { - return this._senderCurve25519Key; - }, + public getSenderKey(): string | null { + return this.senderCurve25519Key; + } /** * The additional keys the sender of this encrypted event claims to possess. @@ -679,11 +765,11 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - getKeysClaimed: function() { + public getKeysClaimed(): Record<"ed25519", string> { return { - ed25519: this._claimedEd25519Key, + ed25519: this.claimedEd25519Key, }; - }, + } /** * Get the ed25519 the sender of this event claims to own. @@ -702,9 +788,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string} */ - getClaimedEd25519Key: function() { - return this._claimedEd25519Key; - }, + public getClaimedEd25519Key(): string | null { + return this.claimedEd25519Key; + } /** * Get the curve25519 keys of the devices which were involved in telling us @@ -720,9 +806,9 @@ utils.extend(MatrixEvent.prototype, { * * @return {string[]} base64-encoded curve25519 keys, from oldest to newest. */ - getForwardingCurve25519KeyChain: function() { - return this._forwardingCurve25519KeyChain; - }, + public getForwardingCurve25519KeyChain(): string[] { + return this.forwardingCurve25519KeyChain; + } /** * Whether the decryption key was obtained from an untrusted source. If so, @@ -730,51 +816,49 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isKeySourceUntrusted: function() { - return this._untrusted; - }, + public isKeySourceUntrusted(): boolean { + return this.untrusted; + } - getUnsigned: function() { + public getUnsigned(): IUnsigned { return this.event.unsigned || {}; - }, + } - unmarkLocallyRedacted: function() { + public unmarkLocallyRedacted(): boolean { const value = this._localRedactionEvent; this._localRedactionEvent = null; if (this.event.unsigned) { this.event.unsigned.redacted_because = null; } return !!value; - }, + } - markLocallyRedacted: function(redactionEvent) { - if (this._localRedactionEvent) { - return; - } + public markLocallyRedacted(redactionEvent: MatrixEvent): void { + if (this._localRedactionEvent) return; this.emit("Event.beforeRedaction", this, redactionEvent); this._localRedactionEvent = redactionEvent; if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redactionEvent.event; - }, + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; + } /** * Update the content of an event in the same way it would be by the server * if it were redacted before it was sent to us * - * @param {module:models/event.MatrixEvent} redaction_event + * @param {module:models/event.MatrixEvent} redactionEvent * event causing the redaction */ - makeRedacted: function(redaction_event) { + public makeRedacted(redactionEvent: MatrixEvent): void { // quick sanity-check - if (!redaction_event.event) { - throw new Error("invalid redaction_event in makeRedacted"); + if (!redactionEvent.event) { + throw new Error("invalid redactionEvent in makeRedacted"); } this._localRedactionEvent = null; - this.emit("Event.beforeRedaction", this, redaction_event); + this.emit("Event.beforeRedaction", this, redactionEvent); this._replacingEvent = null; // we attempt to replicate what we would see from the server if @@ -786,19 +870,19 @@ utils.extend(MatrixEvent.prototype, { if (!this.event.unsigned) { this.event.unsigned = {}; } - this.event.unsigned.redacted_because = redaction_event.event; + this.event.unsigned.redacted_because = redactionEvent.event as IEvent; let key; for (key in this.event) { if (!this.event.hasOwnProperty(key)) { continue; } - if (!_REDACT_KEEP_KEY_MAP[key]) { + if (!REDACT_KEEP_KEYS.has(key)) { delete this.event[key]; } } - const keeps = _REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; + const keeps = REDACT_KEEP_CONTENT_MAP[this.getType()] || {}; const content = this.getContent(); for (key in content) { if (!content.hasOwnProperty(key)) { @@ -808,25 +892,25 @@ utils.extend(MatrixEvent.prototype, { delete content[key]; } } - }, + } /** * Check if this event has been redacted * * @return {boolean} True if this event has been redacted */ - isRedacted: function() { + public isRedacted(): boolean { return Boolean(this.getUnsigned().redacted_because); - }, + } /** * Check if this event is a redaction of another event * * @return {boolean} True if this event is a redaction */ - isRedaction: function() { + public isRedaction(): boolean { return this.getType() === "m.room.redaction"; - }, + } /** * Get the (decrypted, if necessary) redaction event JSON @@ -834,41 +918,41 @@ utils.extend(MatrixEvent.prototype, { * * @returns {object} The redaction event JSON, or an empty object */ - getRedactionEvent: function() { + public getRedactionEvent(): object | null { if (!this.isRedacted()) return null; - if (this._clearEvent.unsigned) { - return this._clearEvent.unsigned.redacted_because; + if (this.clearEvent.unsigned) { + return this.clearEvent.unsigned.redacted_because; } else if (this.event.unsigned.redacted_because) { return this.event.unsigned.redacted_because; } else { return {}; } - }, + } /** * Get the push actions, if known, for this event * * @return {?Object} push actions */ - getPushActions: function() { - return this._pushActions; - }, + public getPushActions(): object | null { + return this.pushActions; + } /** * Set the push actions for this event. * * @param {Object} pushActions push actions */ - setPushActions: function(pushActions) { - this._pushActions = pushActions; - }, + public setPushActions(pushActions: object): void { + this.pushActions = pushActions; + } /** * Replace the `event` property and recalculate any properties based on it. * @param {Object} event the object to assign to the `event` property */ - handleRemoteEcho: function(event) { + public handleRemoteEcho(event: object): void { const oldUnsigned = this.getUnsigned(); const oldId = this.getId(); this.event = event; @@ -889,7 +973,7 @@ utils.extend(MatrixEvent.prototype, { // emit the event if it changed this.emit("Event.localEventIdReplaced", this); } - }, + } /** * Whether the event is in any phase of sending, send failure, waiting for @@ -897,24 +981,24 @@ utils.extend(MatrixEvent.prototype, { * * @return {boolean} */ - isSending() { + public isSending(): boolean { return !!this.status; - }, + } /** * Update the event's sending status and emit an event as well. * * @param {String} status The new status */ - setStatus(status) { + public setStatus(status: EventStatus): void { this.status = status; this.emit("Event.status", this, status); - }, + } - replaceLocalEventId(eventId) { + public replaceLocalEventId(eventId: string): void { this.event.event_id = eventId; this.emit("Event.localEventIdReplaced", this); - }, + } /** * Get whether the event is a relation event, and of a given type if @@ -924,26 +1008,26 @@ utils.extend(MatrixEvent.prototype, { * given type * @return {boolean} */ - isRelation(relType = undefined) { + public isRelation(relType: string = undefined): boolean { // Relation info is lifted out of the encrypted content when sent to // encrypted rooms, so we have to check `getWireContent` for this. const content = this.getWireContent(); const relation = content && content["m.relates_to"]; return relation && relation.rel_type && relation.event_id && ((relType && relation.rel_type === relType) || !relType); - }, + } /** * Get relation info for the event, if any. * * @return {Object} */ - getRelation() { + public getRelation(): IEventRelation | null { if (!this.isRelation()) { return null; } return this.getWireContent()["m.relates_to"]; - }, + } /** * Set an event that replaces the content of this event, through an m.replace relation. @@ -952,7 +1036,7 @@ utils.extend(MatrixEvent.prototype, { * * @param {MatrixEvent?} newEvent the event with the replacing content, if any. */ - makeReplaced(newEvent) { + public makeReplaced(newEvent?: MatrixEvent): void { // don't allow redacted events to be replaced. // if newEvent is null we allow to go through though, // as with local redaction, the replacing event might get @@ -964,44 +1048,44 @@ utils.extend(MatrixEvent.prototype, { this._replacingEvent = newEvent; this.emit("Event.replaced", this); } - }, + } /** * Returns the status of any associated edit or redaction - * (not for reactions/annotations as their local echo doesn't affect the orignal event), + * (not for reactions/annotations as their local echo doesn't affect the original event), * or else the status of the event. * * @return {EventStatus} */ - getAssociatedStatus() { + public getAssociatedStatus(): EventStatus | undefined { if (this._replacingEvent) { return this._replacingEvent.status; } else if (this._localRedactionEvent) { return this._localRedactionEvent.status; } return this.status; - }, + } - getServerAggregatedRelation(relType) { + public getServerAggregatedRelation(relType: RelationType): IAggregatedRelation { const relations = this.getUnsigned()["m.relations"]; if (relations) { return relations[relType]; } - }, + } /** * Returns the event ID of the event replacing the content of this event, if any. * * @return {string?} */ - replacingEventId() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventId(): string | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { return replaceRelation.event_id; } else if (this._replacingEvent) { return this._replacingEvent.getId(); } - }, + } /** * Returns the event replacing the content of this event, if any. @@ -1010,17 +1094,17 @@ utils.extend(MatrixEvent.prototype, { * * @return {MatrixEvent?} */ - replacingEvent() { + public replacingEvent(): MatrixEvent | undefined { return this._replacingEvent; - }, + } /** * Returns the origin_server_ts of the event replacing the content of this event, if any. * * @return {Date?} */ - replacingEventDate() { - const replaceRelation = this.getServerAggregatedRelation("m.replace"); + public replacingEventDate(): Date | undefined { + const replaceRelation = this.getServerAggregatedRelation(RelationType.Replace); if (replaceRelation) { const ts = replaceRelation.origin_server_ts; if (Number.isFinite(ts)) { @@ -1029,38 +1113,38 @@ utils.extend(MatrixEvent.prototype, { } else if (this._replacingEvent) { return this._replacingEvent.getDate(); } - }, + } /** * Returns the event that wants to redact this event, but hasn't been sent yet. * @return {MatrixEvent} the event */ - localRedactionEvent() { + public localRedactionEvent(): MatrixEvent | undefined { return this._localRedactionEvent; - }, + } /** * For relations and redactions, returns the event_id this event is referring to. * * @return {string?} */ - getAssociatedId() { + public getAssociatedId(): string | undefined { const relation = this.getRelation(); if (relation) { return relation.event_id; } else if (this.isRedaction()) { return this.event.redacts; } - }, + } /** * Checks if this event is associated with another event. See `getAssociatedId`. * - * @return {bool} + * @return {boolean} */ - hasAssocation() { + public hasAssocation(): boolean { return !!this.getAssociatedId(); - }, + } /** * Update the related id with a new one. @@ -1070,14 +1154,14 @@ utils.extend(MatrixEvent.prototype, { * * @param {string} eventId the new event id */ - updateAssociatedId(eventId) { + public updateAssociatedId(eventId: string): void { const relation = this.getRelation(); if (relation) { relation.event_id = eventId; } else if (this.isRedaction()) { this.event.redacts = eventId; } - }, + } /** * Flags an event as cancelled due to future conditions. For example, a verification @@ -1085,18 +1169,57 @@ utils.extend(MatrixEvent.prototype, { * listeners that a cancellation event is coming down the same pipe shortly. * @param {boolean} cancelled Whether the event is to be cancelled or not. */ - flagCancelled(cancelled = true) { + public flagCancelled(cancelled = true): void { this._isCancelled = cancelled; - }, + } /** * Gets whether or not the event is flagged as cancelled. See flagCancelled() for * more information. * @returns {boolean} True if the event is cancelled, false otherwise. */ - isCancelled() { + isCancelled(): boolean { return this._isCancelled; - }, + } + + /** + * Get a copy/snapshot of this event. The returned copy will be loosely linked + * back to this instance, though will have "frozen" event information. Other + * properties of this MatrixEvent instance will be copied verbatim, which can + * mean they are in reference to this instance despite being on the copy too. + * The reference the snapshot uses does not change, however members aside from + * the underlying event will not be deeply cloned, thus may be mutated internally. + * For example, the sender profile will be copied over at snapshot time, and + * the sender profile internally may mutate without notice to the consumer. + * + * This is meant to be used to snapshot the event details themselves, not the + * features (such as sender) surrounding the event. + * @returns {MatrixEvent} A snapshot of this event. + */ + toSnapshot(): MatrixEvent { + const ev = new MatrixEvent(JSON.parse(JSON.stringify(this.event))); + for (const [p, v] of Object.entries(this)) { + if (p !== "event") { // exclude the thing we just cloned + ev[p] = v; + } + } + return ev; + } + + /** + * Determines if this event is equivalent to the given event. This only checks + * the event object itself, not the other properties of the event. Intended for + * use with toSnapshot() to identify events changing. + * @param {MatrixEvent} otherEvent The other event to check against. + * @returns {boolean} True if the events are the same, false otherwise. + */ + isEquivalentTo(otherEvent: MatrixEvent): boolean { + if (!otherEvent) return false; + if (otherEvent === this) return true; + const myProps = deepSortedObjectEntries(this.event); + const theirProps = deepSortedObjectEntries(otherEvent.event); + return JSON.stringify(myProps) === JSON.stringify(theirProps); + } /** * Summarise the event as JSON for debugging. If encrypted, include both the @@ -1106,8 +1229,8 @@ utils.extend(MatrixEvent.prototype, { * * @return {Object} */ - toJSON() { - const event = { + public toJSON(): object { + const event: any = { type: this.getType(), sender: this.getSender(), content: this.getContent(), @@ -1130,22 +1253,22 @@ utils.extend(MatrixEvent.prototype, { decrypted: event, encrypted: this.event, }; - }, + } - setVerificationRequest: function(request) { + public setVerificationRequest(request: VerificationRequest): void { this.verificationRequest = request; - }, + } - setTxnId(txnId) { - this._txnId = txnId; - }, + public setTxnId(txnId: string): void { + this.txnId = txnId; + } - getTxnId() { - return this._txnId; - }, -}); + public getTxnId(): string | undefined { + return this.txnId; + } +} -/* _REDACT_KEEP_KEY_MAP gives the keys we keep when an event is redacted +/* REDACT_KEEP_KEYS gives the keys we keep when an event is redacted * * This is specified here: * http://matrix.org/speculator/spec/HEAD/client_server/latest.html#redactions @@ -1154,22 +1277,21 @@ utils.extend(MatrixEvent.prototype, { * - We keep 'unsigned' since that is created by the local server * - We keep user_id for backwards-compat with v1 */ -const _REDACT_KEEP_KEY_MAP = [ +const REDACT_KEEP_KEYS = new Set([ 'event_id', 'type', 'room_id', 'user_id', 'sender', 'state_key', 'prev_state', 'content', 'unsigned', 'origin_server_ts', -].reduce(function(ret, val) { - ret[val] = 1; return ret; -}, {}); +]); // a map from event type to the .content keys we keep when an event is redacted -const _REDACT_KEEP_CONTENT_MAP = { +const REDACT_KEEP_CONTENT_MAP = { 'm.room.member': { 'membership': 1 }, 'm.room.create': { 'creator': 1 }, 'm.room.join_rules': { 'join_rule': 1 }, - 'm.room.power_levels': { 'ban': 1, 'events': 1, 'events_default': 1, - 'kick': 1, 'redact': 1, 'state_default': 1, - 'users': 1, 'users_default': 1, - }, + 'm.room.power_levels': { + 'ban': 1, 'events': 1, 'events_default': 1, + 'kick': 1, 'redact': 1, 'state_default': 1, + 'users': 1, 'users_default': 1, + }, 'm.room.aliases': { 'aliases': 1 }, }; @@ -1181,6 +1303,6 @@ const _REDACT_KEEP_CONTENT_MAP = { * @param {module:models/event.MatrixEvent} event * The matrix event which has been decrypted * @param {module:crypto/algorithms/base.DecryptionError?} err - * The error that occured during decryption, or `undefined` if no - * error occured. + * The error that occurred during decryption, or `undefined` if no + * error occurred. */ diff --git a/src/models/relations.js b/src/models/relations.ts similarity index 65% rename from src/models/relations.js rename to src/models/relations.ts index 50b4ffd65..37beeb31d 100644 --- a/src/models/relations.js +++ b/src/models/relations.ts @@ -1,5 +1,5 @@ /* -Copyright 2019 New Vector Ltd +Copyright 2019, 2021 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. @@ -15,8 +15,11 @@ limitations under the License. */ import { EventEmitter } from 'events'; -import { EventStatus } from '../models/event'; + +import { EventStatus, MatrixEvent } from './event'; +import { Room } from './room'; import { logger } from '../logger'; +import { RelationType } from "../@types/event"; /** * A container for relation events that supports easy access to common ways of @@ -27,8 +30,16 @@ import { logger } from '../logger'; * EventTimelineSet#getRelationsForEvent. */ export class Relations extends EventEmitter { + private relationEventIds = new Set(); + private relations = new Set(); + private annotationsByKey: Record> = {}; + private annotationsBySender: Record> = {}; + private sortedAnnotationsByKey: [string, Set][] = []; + private targetEvent: MatrixEvent = null; + private creationEmitted = false; + /** - * @param {String} relationType + * @param {RelationType} relationType * The type of relation involved, such as "m.annotation", "m.reference", * "m.replace", etc. * @param {String} eventType @@ -37,18 +48,12 @@ export class Relations extends EventEmitter { * Room for this container. May be null for non-room cases, such as the * notification timeline. */ - constructor(relationType, eventType, room) { + constructor( + public readonly relationType: RelationType | string, + public readonly eventType: string, + private readonly room: Room, + ) { super(); - this.relationType = relationType; - this.eventType = eventType; - this._relationEventIds = new Set(); - this._relations = new Set(); - this._annotationsByKey = {}; - this._annotationsBySender = {}; - this._sortedAnnotationsByKey = []; - this._targetEvent = null; - this._room = room; - this._creationEmitted = false; } /** @@ -57,8 +62,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The new relation event to be added. */ - async addEvent(event) { - if (this._relationEventIds.has(event.getId())) { + public async addEvent(event: MatrixEvent) { + if (this.relationEventIds.has(event.getId())) { return; } @@ -79,24 +84,24 @@ export class Relations extends EventEmitter { // If the event is in the process of being sent, listen for cancellation // so we can remove the event from the collection. if (event.isSending()) { - event.on("Event.status", this._onEventStatus); + event.on("Event.status", this.onEventStatus); } - this._relations.add(event); - this._relationEventIds.add(event.getId()); + this.relations.add(event); + this.relationEventIds.add(event.getId()); - if (this.relationType === "m.annotation") { - this._addAnnotationToAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.addAnnotationToAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - event.on("Event.beforeRedaction", this._onBeforeRedaction); + event.on("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.add", event); - this._maybeEmitCreated(); + this.maybeEmitCreated(); } /** @@ -105,8 +110,8 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event * The relation event to remove. */ - async _removeEvent(event) { - if (!this._relations.has(event)) { + private async removeEvent(event: MatrixEvent) { + if (!this.relations.has(event)) { return; } @@ -124,13 +129,13 @@ export class Relations extends EventEmitter { return; } - this._relations.delete(event); + this.relations.delete(event); - if (this.relationType === "m.annotation") { - this._removeAnnotationFromAggregation(event); - } else if (this.relationType === "m.replace" && this._targetEvent) { + if (this.relationType === RelationType.Annotation) { + this.removeAnnotationFromAggregation(event); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } this.emit("Relations.remove", event); @@ -142,19 +147,19 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} event The event whose status has changed * @param {EventStatus} status The new status */ - _onEventStatus = (event, status) => { + private onEventStatus = (event: MatrixEvent, status: EventStatus) => { if (!event.isSending()) { // Sending is done, so we don't need to listen anymore - event.removeListener("Event.status", this._onEventStatus); + event.removeListener("Event.status", this.onEventStatus); return; } if (status !== EventStatus.CANCELLED) { return; } // Event was cancelled, remove from the collection - event.removeListener("Event.status", this._onEventStatus); - this._removeEvent(event); - } + event.removeListener("Event.status", this.onEventStatus); + this.removeEvent(event); + }; /** * Get all relation events in this collection. @@ -166,51 +171,51 @@ export class Relations extends EventEmitter { * @return {Array} * Relation events in insertion order. */ - getRelations() { - return [...this._relations]; + public getRelations() { + return [...this.relations]; } - _addAnnotationToAggregation(event) { + private addAnnotationToAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - let eventsForKey = this._annotationsByKey[key]; + let eventsForKey = this.annotationsByKey[key]; if (!eventsForKey) { - eventsForKey = this._annotationsByKey[key] = new Set(); - this._sortedAnnotationsByKey.push([key, eventsForKey]); + eventsForKey = this.annotationsByKey[key] = new Set(); + this.sortedAnnotationsByKey.push([key, eventsForKey]); } // Add the new event to the set for this key eventsForKey.add(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; }); const sender = event.getSender(); - let eventsFromSender = this._annotationsBySender[sender]; + let eventsFromSender = this.annotationsBySender[sender]; if (!eventsFromSender) { - eventsFromSender = this._annotationsBySender[sender] = new Set(); + eventsFromSender = this.annotationsBySender[sender] = new Set(); } // Add the new event to the set for this sender eventsFromSender.add(event); } - _removeAnnotationFromAggregation(event) { + private removeAnnotationFromAggregation(event: MatrixEvent) { const { key } = event.getRelation(); if (!key) { return; } - const eventsForKey = this._annotationsByKey[key]; + const eventsForKey = this.annotationsByKey[key]; if (eventsForKey) { eventsForKey.delete(event); // Re-sort the [key, events] pairs in descending order of event count - this._sortedAnnotationsByKey.sort((a, b) => { + this.sortedAnnotationsByKey.sort((a, b) => { const aEvents = a[1]; const bEvents = b[1]; return bEvents.size - aEvents.size; @@ -218,7 +223,7 @@ export class Relations extends EventEmitter { } const sender = event.getSender(); - const eventsFromSender = this._annotationsBySender[sender]; + const eventsFromSender = this.annotationsBySender[sender]; if (eventsFromSender) { eventsFromSender.delete(event); } @@ -235,25 +240,25 @@ export class Relations extends EventEmitter { * @param {MatrixEvent} redactedEvent * The original relation event that is about to be redacted. */ - _onBeforeRedaction = async (redactedEvent) => { - if (!this._relations.has(redactedEvent)) { + private onBeforeRedaction = async (redactedEvent: MatrixEvent) => { + if (!this.relations.has(redactedEvent)) { return; } - this._relations.delete(redactedEvent); + this.relations.delete(redactedEvent); - if (this.relationType === "m.annotation") { + if (this.relationType === RelationType.Annotation) { // Remove the redacted annotation from aggregation by key - this._removeAnnotationFromAggregation(redactedEvent); - } else if (this.relationType === "m.replace" && this._targetEvent) { + this.removeAnnotationFromAggregation(redactedEvent); + } else if (this.relationType === RelationType.Replace && this.targetEvent) { const lastReplacement = await this.getLastReplacement(); - this._targetEvent.makeReplaced(lastReplacement); + this.targetEvent.makeReplaced(lastReplacement); } - redactedEvent.removeListener("Event.beforeRedaction", this._onBeforeRedaction); + redactedEvent.removeListener("Event.beforeRedaction", this.onBeforeRedaction); this.emit("Relations.redaction", redactedEvent); - } + }; /** * Get all events in this collection grouped by key and sorted by descending @@ -265,13 +270,13 @@ export class Relations extends EventEmitter { * An array of [key, events] pairs sorted by descending event count. * The events are stored in a Set (which preserves insertion order). */ - getSortedAnnotationsByKey() { - if (this.relationType !== "m.annotation") { + public getSortedAnnotationsByKey() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._sortedAnnotationsByKey; + return this.sortedAnnotationsByKey; } /** @@ -283,13 +288,13 @@ export class Relations extends EventEmitter { * An object with each relation sender as a key and the matching Set of * events for that sender as a value. */ - getAnnotationsBySender() { - if (this.relationType !== "m.annotation") { + public getAnnotationsBySender() { + if (this.relationType !== RelationType.Annotation) { // Other relation types are not grouped currently. return null; } - return this._annotationsBySender; + return this.annotationsBySender; } /** @@ -300,12 +305,12 @@ export class Relations extends EventEmitter { * * @return {MatrixEvent?} */ - async getLastReplacement() { - if (this.relationType !== "m.replace") { + public async getLastReplacement(): Promise { + if (this.relationType !== RelationType.Replace) { // Aggregating on last only makes sense for this relation type return null; } - if (!this._targetEvent) { + if (!this.targetEvent) { // Don't know which replacements to accept yet. // This method shouldn't be called before the original // event is known anyway. @@ -314,12 +319,11 @@ export class Relations extends EventEmitter { // the all-knowning server tells us that the event at some point had // this timestamp for its replacement, so any following replacement should definitely not be less - const replaceRelation = - this._targetEvent.getServerAggregatedRelation("m.replace"); + const replaceRelation = this.targetEvent.getServerAggregatedRelation(RelationType.Replace); const minTs = replaceRelation && replaceRelation.origin_server_ts; const lastReplacement = this.getRelations().reduce((last, event) => { - if (event.getSender() !== this._targetEvent.getSender()) { + if (event.getSender() !== this.targetEvent.getSender()) { return last; } if (minTs && minTs > event.getTs()) { @@ -332,9 +336,9 @@ export class Relations extends EventEmitter { }, null); if (lastReplacement?.shouldAttemptDecryption()) { - await lastReplacement.attemptDecryption(this._room._client.crypto); + await lastReplacement.attemptDecryption(this.room.client.crypto); } else if (lastReplacement?.isBeingDecrypted()) { - await lastReplacement._decryptionPromise; + await lastReplacement.getDecryptionPromise(); } return lastReplacement; @@ -343,38 +347,34 @@ export class Relations extends EventEmitter { /* * @param {MatrixEvent} targetEvent the event the relations are related to. */ - async setTargetEvent(event) { - if (this._targetEvent) { + public async setTargetEvent(event: MatrixEvent) { + if (this.targetEvent) { return; } - this._targetEvent = event; + this.targetEvent = event; - if (this.relationType === "m.replace") { + if (this.relationType === RelationType.Replace) { const replacement = await this.getLastReplacement(); // this is the initial update, so only call it if we already have something // to not emit Event.replaced needlessly if (replacement) { - this._targetEvent.makeReplaced(replacement); + this.targetEvent.makeReplaced(replacement); } } - this._maybeEmitCreated(); + this.maybeEmitCreated(); } - _maybeEmitCreated() { - if (this._creationEmitted) { + private maybeEmitCreated() { + if (this.creationEmitted) { return; } // Only emit we're "created" once we have a target event instance _and_ // at least one related event. - if (!this._targetEvent || !this._relations.size) { + if (!this.targetEvent || !this.relations.size) { return; } - this._creationEmitted = true; - this._targetEvent.emit( - "Event.relationsCreated", - this.relationType, - this.eventType, - ); + this.creationEmitted = true; + this.targetEvent.emit("Event.relationsCreated", this.relationType, this.eventType); } } diff --git a/src/models/room-member.js b/src/models/room-member.js deleted file mode 100644 index 32ed9dc5d..000000000 --- a/src/models/room-member.js +++ /dev/null @@ -1,382 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/room-member - */ - -import { EventEmitter } from "events"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; - -/** - * Construct a new room member. - * - * @constructor - * @alias module:models/room-member - * - * @param {string} roomId The room ID of the member. - * @param {string} userId The user ID of the member. - * @prop {string} roomId The room ID for this member. - * @prop {string} userId The user ID of this member. - * @prop {boolean} typing True if the room member is currently typing. - * @prop {string} name The human-readable name for this room member. This will be - * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the - * same displayname. - * @prop {string} rawDisplayName The ambiguous displayname of this room member. - * @prop {Number} powerLevel The power level for this room member. - * @prop {Number} powerLevelNorm The normalised power level (0-100) for this - * room member. - * @prop {User} user The User object for this room member, if one exists. - * @prop {string} membership The membership state for this room member e.g. 'join'. - * @prop {Object} events The events describing this RoomMember. - * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember. - */ -export function RoomMember(roomId, userId) { - this.roomId = roomId; - this.userId = userId; - this.typing = false; - this.name = userId; - this.rawDisplayName = userId; - this.powerLevel = 0; - this.powerLevelNorm = 0; - this.user = null; - this.membership = null; - this.events = { - member: null, - }; - this._isOutOfBand = false; - this._updateModifiedTime(); -} -utils.inherits(RoomMember, EventEmitter); - -/** - * Mark the member as coming from a channel that is not sync - */ -RoomMember.prototype.markOutOfBand = function() { - this._isOutOfBand = true; -}; - -/** - * @return {bool} does the member come from a channel that is not sync? - * This is used to store the member seperately - * from the sync state so it available across browser sessions. - */ -RoomMember.prototype.isOutOfBand = function() { - return this._isOutOfBand; -}; - -/** - * Update this room member's membership event. May fire "RoomMember.name" if - * this event updates this member's name. - * @param {MatrixEvent} event The m.room.member event - * @param {RoomState} roomState Optional. The room state to take into account - * when calculating (e.g. for disambiguating users with the same name). - * @fires module:client~MatrixClient#event:"RoomMember.name" - * @fires module:client~MatrixClient#event:"RoomMember.membership" - */ -RoomMember.prototype.setMembershipEvent = function(event, roomState) { - if (event.getType() !== "m.room.member") { - return; - } - - this._isOutOfBand = false; - - this.events.member = event; - - const oldMembership = this.membership; - this.membership = event.getDirectionalContent().membership; - - const oldName = this.name; - this.name = calculateDisplayName( - this.userId, - event.getDirectionalContent().displayname, - roomState); - - this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; - if (oldMembership !== this.membership) { - this._updateModifiedTime(); - this.emit("RoomMember.membership", event, this, oldMembership); - } - if (oldName !== this.name) { - this._updateModifiedTime(); - this.emit("RoomMember.name", event, this, oldName); - } -}; - -/** - * Update this room member's power level event. May fire - * "RoomMember.powerLevel" if this event updates this member's power levels. - * @param {MatrixEvent} powerLevelEvent The m.room.power_levels - * event - * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" - */ -RoomMember.prototype.setPowerLevelEvent = function(powerLevelEvent) { - if (powerLevelEvent.getType() !== "m.room.power_levels") { - return; - } - - const evContent = powerLevelEvent.getDirectionalContent(); - - let maxLevel = evContent.users_default || 0; - const users = evContent.users || {}; - Object.values(users).forEach(function(lvl) { - maxLevel = Math.max(maxLevel, lvl); - }); - const oldPowerLevel = this.powerLevel; - const oldPowerLevelNorm = this.powerLevelNorm; - - if (users[this.userId] !== undefined) { - this.powerLevel = users[this.userId]; - } else if (evContent.users_default !== undefined) { - this.powerLevel = evContent.users_default; - } else { - this.powerLevel = 0; - } - this.powerLevelNorm = 0; - if (maxLevel > 0) { - this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; - } - - // emit for changes in powerLevelNorm as well (since the app will need to - // redraw everyone's level if the max has changed) - if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { - this._updateModifiedTime(); - this.emit("RoomMember.powerLevel", powerLevelEvent, this); - } -}; - -/** - * Update this room member's typing event. May fire "RoomMember.typing" if - * this event changes this member's typing state. - * @param {MatrixEvent} event The typing event - * @fires module:client~MatrixClient#event:"RoomMember.typing" - */ -RoomMember.prototype.setTypingEvent = function(event) { - if (event.getType() !== "m.typing") { - return; - } - const oldTyping = this.typing; - this.typing = false; - const typingList = event.getContent().user_ids; - if (!Array.isArray(typingList)) { - // malformed event :/ bail early. TODO: whine? - return; - } - if (typingList.indexOf(this.userId) !== -1) { - this.typing = true; - } - if (oldTyping !== this.typing) { - this._updateModifiedTime(); - this.emit("RoomMember.typing", event, this); - } -}; - -/** - * Update the last modified time to the current time. - */ -RoomMember.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this RoomMember was last updated. This timestamp is - * updated when properties on this RoomMember are updated. - * It is updated before firing events. - * @return {number} The timestamp - */ -RoomMember.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -RoomMember.prototype.isKicked = function() { - return this.membership === "leave" && - this.events.member.getSender() !== this.events.member.getStateKey(); -}; - -/** - * If this member was invited with the is_direct flag set, return - * the user that invited this member - * @return {string} user id of the inviter - */ -RoomMember.prototype.getDMInviter = function() { - // when not available because that room state hasn't been loaded in, - // we don't really know, but more likely to not be a direct chat - if (this.events.member) { - // TODO: persist the is_direct flag on the member as more member events - // come in caused by displayName changes. - - // the is_direct flag is set on the invite member event. - // This is copied on the prev_content section of the join member event - // when the invite is accepted. - - const memberEvent = this.events.member; - let memberContent = memberEvent.getContent(); - let inviteSender = memberEvent.getSender(); - - if (memberContent.membership === "join") { - memberContent = memberEvent.getPrevContent(); - inviteSender = memberEvent.getUnsigned().prev_sender; - } - - if (memberContent.membership === "invite" && memberContent.is_direct) { - return inviteSender; - } - } -}; - -/** - * Get the avatar URL for a room member. - * @param {string} baseUrl The base homeserver URL See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either - * "crop" or "scale". - * @param {Boolean} allowDefault (optional) Passing false causes this method to - * return null if the user has no avatar image. Otherwise, a default image URL - * will be returned. Default: true. (Deprecated) - * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be - * returned even if it is a direct hyperlink rather than a matrix content URL. - * If false, any non-matrix content URLs will be ignored. Setting this option to - * true will expose URLs that, if fetched, will leak information about the user - * to anyone who they share a room with. - * @return {?string} the avatar URL or null. - */ -RoomMember.prototype.getAvatarUrl = - function(baseUrl, width, height, resizeMethod, allowDefault, allowDirectLinks) { - if (allowDefault === undefined) { - allowDefault = true; - } - - const rawUrl = this.getMxcAvatarUrl(); - - if (!rawUrl && !allowDefault) { - return null; - } - const httpUrl = getHttpUriForMxc( - baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks, - ); - if (httpUrl) { - return httpUrl; - } - return null; -}; -/** - * get the mxc avatar url, either from a state event, or from a lazily loaded member - * @return {string} the mxc avatar url - */ -RoomMember.prototype.getMxcAvatarUrl = function() { - if (this.events.member) { - return this.events.member.getDirectionalContent().avatar_url; - } else if (this.user) { - return this.user.avatarUrl; - } - return null; -}; - -const MXID_PATTERN = /@.+:.+/; -const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; - -function calculateDisplayName(selfUserId, displayName, roomState) { - if (!displayName || displayName === selfUserId) { - return selfUserId; - } - - // First check if the displayname is something we consider truthy - // after stripping it of zero width characters and padding spaces - if (!utils.removeHiddenChars(displayName)) { - return selfUserId; - } - - if (!roomState) { - return displayName; - } - - // Next check if the name contains something that look like a mxid - // If it does, it may be someone trying to impersonate someone else - // Show full mxid in this case - let disambiguate = MXID_PATTERN.test(displayName); - - if (!disambiguate) { - // Also show mxid if the display name contains any LTR/RTL characters as these - // make it very difficult for us to find similar *looking* display names - // E.g "Mark" could be cloned by writing "kraM" but in RTL. - disambiguate = LTR_RTL_PATTERN.test(displayName); - } - - if (!disambiguate) { - // Also show mxid if there are other people with the same or similar - // displayname, after hidden character removal. - const userIds = roomState.getUserIdsWithDisplayName(displayName); - disambiguate = userIds.some((u) => u !== selfUserId); - } - - if (disambiguate) { - return displayName + " (" + selfUserId + ")"; - } - return displayName; -} - -/** - * Fires whenever any room member's name changes. - * @event module:client~MatrixClient#"RoomMember.name" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.name changed. - * @param {string?} oldName The previous name. Null if the member didn't have a - * name previously. - * @example - * matrixClient.on("RoomMember.name", function(event, member){ - * var newName = member.name; - * }); - */ - -/** - * Fires whenever any room member's membership state changes. - * @event module:client~MatrixClient#"RoomMember.membership" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.membership changed. - * @param {string?} oldMembership The previous membership state. Null if it's a - * new member. - * @example - * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ - * var newState = member.membership; - * }); - */ - -/** - * Fires whenever any room member's typing state changes. - * @event module:client~MatrixClient#"RoomMember.typing" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.typing changed. - * @example - * matrixClient.on("RoomMember.typing", function(event, member){ - * var isTyping = member.typing; - * }); - */ - -/** - * Fires whenever any room member's power level changes. - * @event module:client~MatrixClient#"RoomMember.powerLevel" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomMember} member The member whose RoomMember.powerLevel changed. - * @example - * matrixClient.on("RoomMember.powerLevel", function(event, member){ - * var newPowerLevel = member.powerLevel; - * var newNormPowerLevel = member.powerLevelNorm; - * }); - */ diff --git a/src/models/room-member.ts b/src/models/room-member.ts new file mode 100644 index 000000000..e7a98257b --- /dev/null +++ b/src/models/room-member.ts @@ -0,0 +1,412 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/room-member + */ + +import { EventEmitter } from "events"; + +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { User } from "./user"; +import { MatrixEvent } from "./event"; +import { RoomState } from "./room-state"; + +export class RoomMember extends EventEmitter { + private _isOutOfBand = false; + private _modified: number; + public _requestedProfileInfo: boolean; // used by sync.ts + + // XXX these should be read-only + public typing = false; + public name: string; + public rawDisplayName: string; + public powerLevel = 0; + public powerLevelNorm = 0; + public user?: User = null; + public membership: string = null; + public disambiguate = false; + public events: { + member?: MatrixEvent; + } = { + member: null, + }; + + /** + * Construct a new room member. + * + * @constructor + * @alias module:models/room-member + * + * @param {string} roomId The room ID of the member. + * @param {string} userId The user ID of the member. + * @prop {string} roomId The room ID for this member. + * @prop {string} userId The user ID of this member. + * @prop {boolean} typing True if the room member is currently typing. + * @prop {string} name The human-readable name for this room member. This will be + * disambiguated with a suffix of " (@user_id:matrix.org)" if another member shares the + * same displayname. + * @prop {string} rawDisplayName The ambiguous displayname of this room member. + * @prop {Number} powerLevel The power level for this room member. + * @prop {Number} powerLevelNorm The normalised power level (0-100) for this + * room member. + * @prop {User} user The User object for this room member, if one exists. + * @prop {string} membership The membership state for this room member e.g. 'join'. + * @prop {Object} events The events describing this RoomMember. + * @prop {MatrixEvent} events.member The m.room.member event for this RoomMember. + * @prop {boolean} disambiguate True if the member's name is disambiguated. + */ + constructor(public readonly roomId: string, public readonly userId: string) { + super(); + + this.name = userId; + this.rawDisplayName = userId; + this.updateModifiedTime(); + } + + /** + * Mark the member as coming from a channel that is not sync + */ + public markOutOfBand(): void { + this._isOutOfBand = true; + } + + /** + * @return {boolean} does the member come from a channel that is not sync? + * This is used to store the member seperately + * from the sync state so it available across browser sessions. + */ + public isOutOfBand(): boolean { + return this._isOutOfBand; + } + + /** + * Update this room member's membership event. May fire "RoomMember.name" if + * this event updates this member's name. + * @param {MatrixEvent} event The m.room.member event + * @param {RoomState} roomState Optional. The room state to take into account + * when calculating (e.g. for disambiguating users with the same name). + * @fires module:client~MatrixClient#event:"RoomMember.name" + * @fires module:client~MatrixClient#event:"RoomMember.membership" + */ + public setMembershipEvent(event: MatrixEvent, roomState: RoomState): void { + const displayName = event.getDirectionalContent().displayname; + + if (event.getType() !== "m.room.member") { + return; + } + + this._isOutOfBand = false; + + this.events.member = event; + + const oldMembership = this.membership; + this.membership = event.getDirectionalContent().membership; + + this.disambiguate = shouldDisambiguate( + this.userId, + displayName, + roomState, + ); + + const oldName = this.name; + this.name = calculateDisplayName( + this.userId, + displayName, + roomState, + this.disambiguate, + ); + + this.rawDisplayName = event.getDirectionalContent().displayname || this.userId; + if (oldMembership !== this.membership) { + this.updateModifiedTime(); + this.emit("RoomMember.membership", event, this, oldMembership); + } + if (oldName !== this.name) { + this.updateModifiedTime(); + this.emit("RoomMember.name", event, this, oldName); + } + } + + /** + * Update this room member's power level event. May fire + * "RoomMember.powerLevel" if this event updates this member's power levels. + * @param {MatrixEvent} powerLevelEvent The m.room.power_levels + * event + * @fires module:client~MatrixClient#event:"RoomMember.powerLevel" + */ + public setPowerLevelEvent(powerLevelEvent: MatrixEvent): void { + if (powerLevelEvent.getType() !== "m.room.power_levels") { + return; + } + + const evContent = powerLevelEvent.getDirectionalContent(); + + let maxLevel = evContent.users_default || 0; + const users = evContent.users || {}; + Object.values(users).forEach(function(lvl: number) { + maxLevel = Math.max(maxLevel, lvl); + }); + const oldPowerLevel = this.powerLevel; + const oldPowerLevelNorm = this.powerLevelNorm; + + if (users[this.userId] !== undefined && Number.isInteger(users[this.userId])) { + this.powerLevel = users[this.userId]; + } else if (evContent.users_default !== undefined) { + this.powerLevel = evContent.users_default; + } else { + this.powerLevel = 0; + } + this.powerLevelNorm = 0; + if (maxLevel > 0) { + this.powerLevelNorm = (this.powerLevel * 100) / maxLevel; + } + + // emit for changes in powerLevelNorm as well (since the app will need to + // redraw everyone's level if the max has changed) + if (oldPowerLevel !== this.powerLevel || oldPowerLevelNorm !== this.powerLevelNorm) { + this.updateModifiedTime(); + this.emit("RoomMember.powerLevel", powerLevelEvent, this); + } + } + + /** + * Update this room member's typing event. May fire "RoomMember.typing" if + * this event changes this member's typing state. + * @param {MatrixEvent} event The typing event + * @fires module:client~MatrixClient#event:"RoomMember.typing" + */ + public setTypingEvent(event: MatrixEvent): void { + if (event.getType() !== "m.typing") { + return; + } + const oldTyping = this.typing; + this.typing = false; + const typingList = event.getContent().user_ids; + if (!Array.isArray(typingList)) { + // malformed event :/ bail early. TODO: whine? + return; + } + if (typingList.indexOf(this.userId) !== -1) { + this.typing = true; + } + if (oldTyping !== this.typing) { + this.updateModifiedTime(); + this.emit("RoomMember.typing", event, this); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime() { + this._modified = Date.now(); + } + + /** + * Get the timestamp when this RoomMember was last updated. This timestamp is + * updated when properties on this RoomMember are updated. + * It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this._modified; + } + + public isKicked(): boolean { + return this.membership === "leave" && + this.events.member.getSender() !== this.events.member.getStateKey(); + } + + /** + * If this member was invited with the is_direct flag set, return + * the user that invited this member + * @return {string} user id of the inviter + */ + public getDMInviter(): string { + // when not available because that room state hasn't been loaded in, + // we don't really know, but more likely to not be a direct chat + if (this.events.member) { + // TODO: persist the is_direct flag on the member as more member events + // come in caused by displayName changes. + + // the is_direct flag is set on the invite member event. + // This is copied on the prev_content section of the join member event + // when the invite is accepted. + + const memberEvent = this.events.member; + let memberContent = memberEvent.getContent(); + let inviteSender = memberEvent.getSender(); + + if (memberContent.membership === "join") { + memberContent = memberEvent.getPrevContent(); + inviteSender = memberEvent.getUnsigned().prev_sender; + } + + if (memberContent.membership === "invite" && memberContent.is_direct) { + return inviteSender; + } + } + } + + /** + * Get the avatar URL for a room member. + * @param {string} baseUrl The base homeserver URL See + * {@link module:client~MatrixClient#getHomeserverUrl}. + * @param {Number} width The desired width of the thumbnail. + * @param {Number} height The desired height of the thumbnail. + * @param {string} resizeMethod The thumbnail resize method to use, either + * "crop" or "scale". + * @param {Boolean} allowDefault (optional) Passing false causes this method to + * return null if the user has no avatar image. Otherwise, a default image URL + * will be returned. Default: true. (Deprecated) + * @param {Boolean} allowDirectLinks (optional) If true, the avatar URL will be + * returned even if it is a direct hyperlink rather than a matrix content URL. + * If false, any non-matrix content URLs will be ignored. Setting this option to + * true will expose URLs that, if fetched, will leak information about the user + * to anyone who they share a room with. + * @return {?string} the avatar URL or null. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: string, + allowDefault = true, + allowDirectLinks: boolean, + ): string | null { + const rawUrl = this.getMxcAvatarUrl(); + + if (!rawUrl && !allowDefault) { + return null; + } + const httpUrl = getHttpUriForMxc(baseUrl, rawUrl, width, height, resizeMethod, allowDirectLinks); + if (httpUrl) { + return httpUrl; + } + return null; + } + + /** + * get the mxc avatar url, either from a state event, or from a lazily loaded member + * @return {string} the mxc avatar url + */ + public getMxcAvatarUrl(): string | null { + if (this.events.member) { + return this.events.member.getDirectionalContent().avatar_url; + } else if (this.user) { + return this.user.avatarUrl; + } + return null; + } +} + +const MXID_PATTERN = /@.+:.+/; +const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/; + +function shouldDisambiguate(selfUserId: string, displayName: string, roomState: RoomState): boolean { + if (!displayName || displayName === selfUserId) return false; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!utils.removeHiddenChars(displayName)) return false; + + if (!roomState) return false; + + // Next check if the name contains something that look like a mxid + // If it does, it may be someone trying to impersonate someone else + // Show full mxid in this case + if (MXID_PATTERN.test(displayName)) return true; + + // Also show mxid if the display name contains any LTR/RTL characters as these + // make it very difficult for us to find similar *looking* display names + // E.g "Mark" could be cloned by writing "kraM" but in RTL. + if (LTR_RTL_PATTERN.test(displayName)) return true; + + // Also show mxid if there are other people with the same or similar + // displayname, after hidden character removal. + const userIds = roomState.getUserIdsWithDisplayName(displayName); + if (userIds.some((u) => u !== selfUserId)) return true; + + return false; +} + +function calculateDisplayName( + selfUserId: string, + displayName: string, + roomState: RoomState, + disambiguate: boolean, +): string { + if (disambiguate) return displayName + " (" + selfUserId + ")"; + + if (!displayName || displayName === selfUserId) return selfUserId; + + // First check if the displayname is something we consider truthy + // after stripping it of zero width characters and padding spaces + if (!utils.removeHiddenChars(displayName)) return selfUserId; + + return displayName; +} + +/** + * Fires whenever any room member's name changes. + * @event module:client~MatrixClient#"RoomMember.name" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.name changed. + * @param {string?} oldName The previous name. Null if the member didn't have a + * name previously. + * @example + * matrixClient.on("RoomMember.name", function(event, member){ + * var newName = member.name; + * }); + */ + +/** + * Fires whenever any room member's membership state changes. + * @event module:client~MatrixClient#"RoomMember.membership" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.membership changed. + * @param {string?} oldMembership The previous membership state. Null if it's a + * new member. + * @example + * matrixClient.on("RoomMember.membership", function(event, member, oldMembership){ + * var newState = member.membership; + * }); + */ + +/** + * Fires whenever any room member's typing state changes. + * @event module:client~MatrixClient#"RoomMember.typing" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.typing changed. + * @example + * matrixClient.on("RoomMember.typing", function(event, member){ + * var isTyping = member.typing; + * }); + */ + +/** + * Fires whenever any room member's power level changes. + * @event module:client~MatrixClient#"RoomMember.powerLevel" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomMember} member The member whose RoomMember.powerLevel changed. + * @example + * matrixClient.on("RoomMember.powerLevel", function(event, member){ + * var newPowerLevel = member.powerLevel; + * var newNormPowerLevel = member.powerLevelNorm; + * }); + */ diff --git a/src/models/room-state.js b/src/models/room-state.js deleted file mode 100644 index f9e76cfc3..000000000 --- a/src/models/room-state.js +++ /dev/null @@ -1,832 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/room-state - */ - -import { EventEmitter } from "events"; -import { RoomMember } from "./room-member"; -import { logger } from '../logger'; -import * as utils from "../utils"; -import { EventType } from "../@types/event"; - -// possible statuses for out-of-band member loading -const OOB_STATUS_NOTSTARTED = 1; -const OOB_STATUS_INPROGRESS = 2; -const OOB_STATUS_FINISHED = 3; - -/** - * Construct room state. - * - * Room State represents the state of the room at a given point. - * It can be mutated by adding state events to it. - * There are two types of room member associated with a state event: - * normal member objects (accessed via getMember/getMembers) which mutate - * with the state to represent the current state of that room/user, eg. - * the object returned by getMember('@bob:example.com') will mutate to - * get a different display name if Bob later changes his display name - * in the room. - * There are also 'sentinel' members (accessed via getSentinelMember). - * These also represent the state of room members at the point in time - * represented by the RoomState object, but unlike objects from getMember, - * sentinel objects will always represent the room state as at the time - * getSentinelMember was called, so if Bob subsequently changes his display - * name, a room member object previously acquired with getSentinelMember - * will still have his old display name. Calling getSentinelMember again - * after the display name change will return a new RoomMember object - * with Bob's new display name. - * - * @constructor - * @param {?string} roomId Optional. The ID of the room which has this state. - * If none is specified it just tracks paginationTokens, useful for notifTimelineSet - * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. - * As the timeline might get reset while they are loading, this state needs to be inherited - * and shared when the room state is cloned for the new timeline. - * This should only be passed from clone. - * @prop {Object.} members The room member dictionary, keyed - * on the user's ID. - * @prop {Object.>} events The state - * events dictionary, keyed on the event type and then the state_key value. - * @prop {string} paginationToken The pagination token for this state. - */ -export function RoomState(roomId, oobMemberFlags = undefined) { - this.roomId = roomId; - this.members = { - // userId: RoomMember - }; - this.events = new Map(); // Map> - this.paginationToken = null; - - this._sentinels = { - // userId: RoomMember - }; - this._updateModifiedTime(); - - // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) - this._displayNameToUserIds = {}; - this._userIdsToDisplayNames = {}; - this._tokenToInvite = {}; // 3pid invite state_key to m.room.member invite - this._joinedMemberCount = null; // cache of the number of joined members - // joined members count from summary api - // once set, we know the server supports the summary api - // and we should only trust that - // we could also only trust that before OOB members - // are loaded but doesn't seem worth the hassle atm - this._summaryJoinedMemberCount = null; - // same for invited member count - this._invitedMemberCount = null; - this._summaryInvitedMemberCount = null; - - if (!oobMemberFlags) { - oobMemberFlags = { - status: OOB_STATUS_NOTSTARTED, - }; - } - this._oobMemberFlags = oobMemberFlags; -} -utils.inherits(RoomState, EventEmitter); - -/** - * Returns the number of joined members in this room - * This method caches the result. - * @return {integer} The number of members in this room whose membership is 'join' - */ -RoomState.prototype.getJoinedMemberCount = function() { - if (this._summaryJoinedMemberCount !== null) { - return this._summaryJoinedMemberCount; - } - if (this._joinedMemberCount === null) { - this._joinedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'join' ? count + 1 : count; - }, 0); - } - return this._joinedMemberCount; -}; - -/** - * Set the joined member count explicitly (like from summary part of the sync response) - * @param {number} count the amount of joined members - */ -RoomState.prototype.setJoinedMemberCount = function(count) { - this._summaryJoinedMemberCount = count; -}; -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -RoomState.prototype.getInvitedMemberCount = function() { - if (this._summaryInvitedMemberCount !== null) { - return this._summaryInvitedMemberCount; - } - if (this._invitedMemberCount === null) { - this._invitedMemberCount = this.getMembers().reduce((count, m) => { - return m.membership === 'invite' ? count + 1 : count; - }, 0); - } - return this._invitedMemberCount; -}; - -/** - * Set the amount of invited members in this room - * @param {number} count the amount of invited members - */ -RoomState.prototype.setInvitedMemberCount = function(count) { - this._summaryInvitedMemberCount = count; -}; - -/** - * Get all RoomMembers in this room. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembers = function() { - return Object.values(this.members); -}; - -/** - * Get all RoomMembers in this room, excluding the user IDs provided. - * @param {Array} excludedIds The user IDs to exclude. - * @return {Array} A list of RoomMembers. - */ -RoomState.prototype.getMembersExcept = function(excludedIds) { - return Object.values(this.members) - .filter((m) => !excludedIds.includes(m.userId)); -}; - -/** - * Get a room member by their user ID. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getMember = function(userId) { - return this.members[userId] || null; -}; - -/** - * Get a room member whose properties will not change with this room state. You - * typically want this if you want to attach a RoomMember to a MatrixEvent which - * may no longer be represented correctly by Room.currentState or Room.oldState. - * The term 'sentinel' refers to the fact that this RoomMember is an unchanging - * guardian for state at this particular point in time. - * @param {string} userId The room member's user ID. - * @return {RoomMember} The member or null if they do not exist. - */ -RoomState.prototype.getSentinelMember = function(userId) { - if (!userId) return null; - let sentinel = this._sentinels[userId]; - - if (sentinel === undefined) { - sentinel = new RoomMember(this.roomId, userId); - const member = this.members[userId]; - if (member) { - sentinel.setMembershipEvent(member.events.member, this); - } - this._sentinels[userId] = sentinel; - } - return sentinel; -}; - -/** - * Get state events from the state of the room. - * @param {string} eventType The event type of the state event. - * @param {string} stateKey Optional. The state_key of the state event. If - * this is undefined then all matching state events will be - * returned. - * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was - * undefined, else a single event (or null if no match found). - */ -RoomState.prototype.getStateEvents = function(eventType, stateKey) { - if (!this.events.has(eventType)) { - // no match - return stateKey === undefined ? [] : null; - } - if (stateKey === undefined) { // return all values - return Array.from(this.events.get(eventType).values()); - } - const event = this.events.get(eventType).get(stateKey); - return event ? event : null; -}; - -/** - * Creates a copy of this room state so that mutations to either won't affect the other. - * @return {RoomState} the copy of the room state - */ -RoomState.prototype.clone = function() { - const copy = new RoomState(this.roomId, this._oobMemberFlags); - - // Ugly hack: because setStateEvents will mark - // members as susperseding future out of bound members - // if loading is in progress (through _oobMemberFlags) - // since these are not new members, we're merely copying them - // set the status to not started - // after copying, we set back the status - const status = this._oobMemberFlags.status; - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; - - Array.from(this.events.values()).forEach((eventsByStateKey) => { - copy.setStateEvents(Array.from(eventsByStateKey.values())); - }); - - // Ugly hack: see above - this._oobMemberFlags.status = status; - - if (this._summaryInvitedMemberCount !== null) { - copy.setInvitedMemberCount(this.getInvitedMemberCount()); - } - if (this._summaryJoinedMemberCount !== null) { - copy.setJoinedMemberCount(this.getJoinedMemberCount()); - } - - // copy out of band flags if needed - if (this._oobMemberFlags.status == OOB_STATUS_FINISHED) { - // copy markOutOfBand flags - this.getMembers().forEach((member) => { - if (member.isOutOfBand()) { - const copyMember = copy.getMember(member.userId); - copyMember.markOutOfBand(); - } - }); - } - - return copy; -}; - -/** - * Add previously unknown state events. - * When lazy loading members while back-paginating, - * the relevant room state for the timeline chunk at the end - * of the chunk can be set with this method. - * @param {MatrixEvent[]} events state events to prepend - */ -RoomState.prototype.setUnknownStateEvents = function(events) { - const unknownStateEvents = events.filter((event) => { - return !this.events.has(event.getType()) || - !this.events.get(event.getType()).has(event.getStateKey()); - }); - - this.setStateEvents(unknownStateEvents); -}; - -/** - * Add an array of one or more state MatrixEvents, overwriting - * any existing state with the same {type, stateKey} tuple. Will fire - * "RoomState.events" for every event added. May fire "RoomState.members" - * if there are m.room.member events. - * @param {MatrixEvent[]} stateEvents a list of state events for this room. - * @fires module:client~MatrixClient#event:"RoomState.members" - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @fires module:client~MatrixClient#event:"RoomState.events" - */ -RoomState.prototype.setStateEvents = function(stateEvents) { - const self = this; - this._updateModifiedTime(); - - // update the core event dict - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - const lastStateEvent = self._getStateEventMatching(event); - self._setStateEvent(event); - if (event.getType() === "m.room.member") { - _updateDisplayNameCache( - self, event.getStateKey(), event.getContent().displayname, - ); - _updateThirdPartyTokenCache(self, event); - } - self.emit("RoomState.events", event, self, lastStateEvent); - }); - - // update higher level data structures. This needs to be done AFTER the - // core event dict as these structures may depend on other state events in - // the given array (e.g. disambiguating display names in one go to do both - // clashing names rather than progressively which only catches 1 of them). - stateEvents.forEach(function(event) { - if (event.getRoomId() !== self.roomId) { - return; - } - if (!event.isState()) { - return; - } - - if (event.getType() === "m.room.member") { - const userId = event.getStateKey(); - - // leave events apparently elide the displayname or avatar_url, - // so let's fake one up so that we don't leak user ids - // into the timeline - if (event.getContent().membership === "leave" || - event.getContent().membership === "ban") { - event.getContent().avatar_url = - event.getContent().avatar_url || - event.getPrevContent().avatar_url; - event.getContent().displayname = - event.getContent().displayname || - event.getPrevContent().displayname; - } - - const member = self._getOrCreateMember(userId, event); - member.setMembershipEvent(event, self); - - self._updateMember(member); - self.emit("RoomState.members", event, self, member); - } else if (event.getType() === "m.room.power_levels") { - // events with unknown state keys should be ignored - // and should not aggregate onto members power levels - if (event.getStateKey() !== "") { - return; - } - const members = Object.values(self.members); - members.forEach(function(member) { - // We only propagate `RoomState.members` event if the - // power levels has been changed - // large room suffer from large re-rendering especially when not needed - const oldLastModified = member.getLastModifiedTime(); - member.setPowerLevelEvent(event); - if (oldLastModified !== member.getLastModifiedTime()) { - self.emit("RoomState.members", event, self, member); - } - }); - - // assume all our sentinels are now out-of-date - self._sentinels = {}; - } - }); -}; - -/** - * Looks up a member by the given userId, and if it doesn't exist, - * create it and emit the `RoomState.newMember` event. - * This method makes sure the member is added to the members dictionary - * before emitting, as this is done from setStateEvents and _setOutOfBandMember. - * @param {string} userId the id of the user to look up - * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. - * @fires module:client~MatrixClient#event:"RoomState.newMember" - * @returns {RoomMember} the member, existing or newly created. - */ -RoomState.prototype._getOrCreateMember = function(userId, event) { - let member = this.members[userId]; - if (!member) { - member = new RoomMember(this.roomId, userId); - // add member to members before emitting any events, - // as event handlers often lookup the member - this.members[userId] = member; - this.emit("RoomState.newMember", event, this, member); - } - return member; -}; - -RoomState.prototype._setStateEvent = function(event) { - if (!this.events.has(event.getType())) { - this.events.set(event.getType(), new Map()); - } - this.events.get(event.getType()).set(event.getStateKey(), event); -}; - -RoomState.prototype._getStateEventMatching = function(event) { - if (!this.events.has(event.getType())) return null; - return this.events.get(event.getType()).get(event.getStateKey()); -}; - -RoomState.prototype._updateMember = function(member) { - // this member may have a power level already, so set it. - const pwrLvlEvent = this.getStateEvents("m.room.power_levels", ""); - if (pwrLvlEvent) { - member.setPowerLevelEvent(pwrLvlEvent); - } - - // blow away the sentinel which is now outdated - delete this._sentinels[member.userId]; - - this.members[member.userId] = member; - this._joinedMemberCount = null; - this._invitedMemberCount = null; -}; - -/** - * Get the out-of-band members loading state, whether loading is needed or not. - * Note that loading might be in progress and hence isn't needed. - * @return {bool} whether or not the members of this room need to be loaded - */ -RoomState.prototype.needsOutOfBandMembers = function() { - return this._oobMemberFlags.status === OOB_STATUS_NOTSTARTED; -}; - -/** - * Mark this room state as waiting for out-of-band members, - * ensuring it doesn't ask for them to be requested again - * through needsOutOfBandMembers - */ -RoomState.prototype.markOutOfBandMembersStarted = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_NOTSTARTED) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_INPROGRESS; -}; - -/** - * Mark this room state as having failed to fetch out-of-band members - */ -RoomState.prototype.markOutOfBandMembersFailed = function() { - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Clears the loaded out-of-band members - */ -RoomState.prototype.clearOutOfBandMembers = function() { - let count = 0; - Object.keys(this.members).forEach((userId) => { - const member = this.members[userId]; - if (member.isOutOfBand()) { - ++count; - delete this.members[userId]; - } - }); - logger.log(`LL: RoomState removed ${count} members...`); - this._oobMemberFlags.status = OOB_STATUS_NOTSTARTED; -}; - -/** - * Sets the loaded out-of-band members. - * @param {MatrixEvent[]} stateEvents array of membership state events - */ -RoomState.prototype.setOutOfBandMembers = function(stateEvents) { - logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); - if (this._oobMemberFlags.status !== OOB_STATUS_INPROGRESS) { - return; - } - logger.log(`LL: RoomState put in OOB_STATUS_FINISHED state ...`); - this._oobMemberFlags.status = OOB_STATUS_FINISHED; - stateEvents.forEach((e) => this._setOutOfBandMember(e)); -}; - -/** - * Sets a single out of band member, used by both setOutOfBandMembers and clone - * @param {MatrixEvent} stateEvent membership state event - */ -RoomState.prototype._setOutOfBandMember = function(stateEvent) { - if (stateEvent.getType() !== 'm.room.member') { - return; - } - const userId = stateEvent.getStateKey(); - const existingMember = this.getMember(userId); - // never replace members received as part of the sync - if (existingMember && !existingMember.isOutOfBand()) { - return; - } - - const member = this._getOrCreateMember(userId, stateEvent); - member.setMembershipEvent(stateEvent, this); - // needed to know which members need to be stored seperately - // as they are not part of the sync accumulator - // this is cleared by setMembershipEvent so when it's updated through /sync - member.markOutOfBand(); - - _updateDisplayNameCache(this, member.userId, member.name); - - this._setStateEvent(stateEvent); - this._updateMember(member); - this.emit("RoomState.members", stateEvent, this, member); -}; - -/** - * Set the current typing event for this room. - * @param {MatrixEvent} event The typing event - */ -RoomState.prototype.setTypingEvent = function(event) { - Object.values(this.members).forEach(function(member) { - member.setTypingEvent(event); - }); -}; - -/** - * Get the m.room.member event which has the given third party invite token. - * - * @param {string} token The token - * @return {?MatrixEvent} The m.room.member event or null - */ -RoomState.prototype.getInviteForThreePidToken = function(token) { - return this._tokenToInvite[token] || null; -}; - -/** - * Update the last modified time to the current time. - */ -RoomState.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this room state was last updated. This timestamp is - * updated when this object has received new state events. - * @return {number} The timestamp - */ -RoomState.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get user IDs with the specified or similar display names. - * @param {string} displayName The display name to get user IDs from. - * @return {string[]} An array of user IDs or an empty array. - */ -RoomState.prototype.getUserIdsWithDisplayName = function(displayName) { - return this._displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; -}; - -/** - * Returns true if userId is in room, event is not redacted and either sender of - * mxEvent or has power level sufficient to redact events other than their own. - * @param {MatrixEvent} mxEvent The event to test permission for - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given used ID can redact given event - */ -RoomState.prototype.maySendRedactionForEvent = function(mxEvent, userId) { - const member = this.getMember(userId); - if (!member || member.membership === 'leave') return false; - - if (mxEvent.status || mxEvent.isRedacted()) return false; - - // The user may have been the sender, but they can't redact their own message - // if redactions are blocked. - const canRedact = this.maySendEvent("m.room.redaction", userId); - if (mxEvent.getSender() === userId) return canRedact; - - return this._hasSufficientPowerLevelFor('redact', member.powerLevel); -}; - -/** - * Returns true if the given power level is sufficient for action - * @param {string} action The type of power level to check - * @param {number} powerLevel The power level of the member - * @return {boolean} true if the given power level is sufficient - */ -RoomState.prototype._hasSufficientPowerLevelFor = function(action, powerLevel) { - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let powerLevels = {}; - if (powerLevelsEvent) { - powerLevels = powerLevelsEvent.getContent(); - } - - let requiredLevel = 50; - if (utils.isNumber(powerLevels[action])) { - requiredLevel = powerLevels[action]; - } - - return powerLevel >= requiredLevel; -}; - -/** - * Short-form for maySendEvent('m.room.message', userId) - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * message events into the given room. - */ -RoomState.prototype.maySendMessage = function(userId) { - return this._maySendEventOfType('m.room.message', userId, false); -}; - -/** - * Returns true if the given user ID has permission to send a normal - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendEvent = function(eventType, userId) { - return this._maySendEventOfType(eventType, userId, false); -}; - -/** - * Returns true if the given MatrixClient has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {MatrixClient} cli The client to test permission for - * @return {boolean} true if the given client should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.mayClientSendStateEvent = function(stateEventType, cli) { - if (cli.isGuest()) { - return false; - } - return this.maySendStateEvent(stateEventType, cli.credentials.userId); -}; - -/** - * Returns true if the given user ID has permission to send a state - * event of type `stateEventType` into this room. - * @param {string} stateEventType The type of state events to test - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID should be permitted to send - * the given type of state event into this room, - * according to the room's state. - */ -RoomState.prototype.maySendStateEvent = function(stateEventType, userId) { - return this._maySendEventOfType(stateEventType, userId, true); -}; - -/** - * Returns true if the given user ID has permission to send a normal or state - * event of type `eventType` into this room. - * @param {string} eventType The type of event to test - * @param {string} userId The user ID of the user to test permission for - * @param {boolean} state If true, tests if the user may send a state - event of this type. Otherwise tests whether - they may send a regular event. - * @return {boolean} true if the given user ID should be permitted to send - * the given type of event into this room, - * according to the room's state. - */ -RoomState.prototype._maySendEventOfType = function(eventType, userId, state) { - const power_levels_event = this.getStateEvents('m.room.power_levels', ''); - - let power_levels; - let events_levels = {}; - - let state_default = 0; - let events_default = 0; - let powerLevel = 0; - if (power_levels_event) { - power_levels = power_levels_event.getContent(); - events_levels = power_levels.events || {}; - - if (Number.isFinite(power_levels.state_default)) { - state_default = power_levels.state_default; - } else { - state_default = 50; - } - - const userPowerLevel = power_levels.users && power_levels.users[userId]; - if (Number.isFinite(userPowerLevel)) { - powerLevel = userPowerLevel; - } else if (Number.isFinite(power_levels.users_default)) { - powerLevel = power_levels.users_default; - } - - if (Number.isFinite(power_levels.events_default)) { - events_default = power_levels.events_default; - } - } - - let required_level = state ? state_default : events_default; - if (Number.isFinite(events_levels[eventType])) { - required_level = events_levels[eventType]; - } - return powerLevel >= required_level; -}; - -/** - * Returns true if the given user ID has permission to trigger notification - * of type `notifLevelKey` - * @param {string} notifLevelKey The level of notification to test (eg. 'room') - * @param {string} userId The user ID of the user to test permission for - * @return {boolean} true if the given user ID has permission to trigger a - * notification of this type. - */ -RoomState.prototype.mayTriggerNotifOfType = function(notifLevelKey, userId) { - const member = this.getMember(userId); - if (!member) { - return false; - } - - const powerLevelsEvent = this.getStateEvents('m.room.power_levels', ''); - - let notifLevel = 50; - if ( - powerLevelsEvent && - powerLevelsEvent.getContent() && - powerLevelsEvent.getContent().notifications && - utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) - ) { - notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; - } - - return member.powerLevel >= notifLevel; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -RoomState.prototype.getJoinRule = function() { - const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); - const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; - return joinRuleContent["join_rule"] || "invite"; -}; - -function _updateThirdPartyTokenCache(roomState, memberEvent) { - if (!memberEvent.getContent().third_party_invite) { - return; - } - const token = (memberEvent.getContent().third_party_invite.signed || {}).token; - if (!token) { - return; - } - const threePidInvite = roomState.getStateEvents( - "m.room.third_party_invite", token, - ); - if (!threePidInvite) { - return; - } - roomState._tokenToInvite[token] = memberEvent; -} - -function _updateDisplayNameCache(roomState, userId, displayName) { - const oldName = roomState._userIdsToDisplayNames[userId]; - delete roomState._userIdsToDisplayNames[userId]; - if (oldName) { - // Remove the old name from the cache. - // We clobber the user_id > name lookup but the name -> [user_id] lookup - // means we need to remove that user ID from that array rather than nuking - // the lot. - const strippedOldName = utils.removeHiddenChars(oldName); - - const existingUserIds = roomState._displayNameToUserIds[strippedOldName]; - if (existingUserIds) { - // remove this user ID from this array - const filteredUserIDs = existingUserIds.filter((id) => id !== userId); - roomState._displayNameToUserIds[strippedOldName] = filteredUserIDs; - } - } - - roomState._userIdsToDisplayNames[userId] = displayName; - - const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); - // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js - if (strippedDisplayname) { - if (!roomState._displayNameToUserIds[strippedDisplayname]) { - roomState._displayNameToUserIds[strippedDisplayname] = []; - } - roomState._displayNameToUserIds[strippedDisplayname].push(userId); - } -} - -/** - * Fires whenever the event dictionary in room state is updated. - * @event module:client~MatrixClient#"RoomState.events" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.events dictionary - * was updated. - * @param {MatrixEvent} prevEvent The event being replaced by the new state, if - * known. Note that this can differ from `getPrevContent()` on the new state event - * as this is the store's view of the last state, not the previous state provided - * by the server. - * @example - * matrixClient.on("RoomState.events", function(event, state, prevEvent){ - * var newStateEvent = event; - * }); - */ - -/** - * Fires whenever a member in the members dictionary is updated in any way. - * @event module:client~MatrixClient#"RoomState.members" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated. - * @param {RoomMember} member The room member that was updated. - * @example - * matrixClient.on("RoomState.members", function(event, state, member){ - * var newMembershipState = member.membership; - * }); - */ - - /** - * Fires whenever a member is added to the members dictionary. The RoomMember - * will not be fully populated yet (e.g. no membership state) but will already - * be available in the members dictionary. - * @event module:client~MatrixClient#"RoomState.newMember" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {RoomState} state The room state whose RoomState.members dictionary - * was updated with a new entry. - * @param {RoomMember} member The room member that was added. - * @example - * matrixClient.on("RoomState.newMember", function(event, state, member){ - * // add event listeners on 'member' - * }); - */ diff --git a/src/models/room-state.ts b/src/models/room-state.ts new file mode 100644 index 000000000..2b67ee79e --- /dev/null +++ b/src/models/room-state.ts @@ -0,0 +1,825 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/room-state + */ + +import { EventEmitter } from "events"; + +import { RoomMember } from "./room-member"; +import { logger } from '../logger'; +import * as utils from "../utils"; +import { EventType } from "../@types/event"; +import { MatrixEvent } from "./event"; +import { MatrixClient } from "../client"; + +// possible statuses for out-of-band member loading +enum OobStatus { + NotStarted, + InProgress, + Finished, +} + +export class RoomState extends EventEmitter { + private sentinels: Record = {}; // userId: RoomMember + // stores fuzzy matches to a list of userIDs (applies utils.removeHiddenChars to keys) + private displayNameToUserIds: Record = {}; + private userIdsToDisplayNames: Record = {}; + private tokenToInvite: Record = {}; // 3pid invite state_key to m.room.member invite + private joinedMemberCount: number = null; // cache of the number of joined members + // joined members count from summary api + // once set, we know the server supports the summary api + // and we should only trust that + // we could also only trust that before OOB members + // are loaded but doesn't seem worth the hassle atm + private summaryJoinedMemberCount: number = null; + // same for invited member count + private invitedMemberCount: number = null; + private summaryInvitedMemberCount: number = null; + private modified: number; + + // XXX: Should be read-only + public members: Record = {}; // userId: RoomMember + public events = new Map>(); // Map> + public paginationToken: string = null; + + /** + * Construct room state. + * + * Room State represents the state of the room at a given point. + * It can be mutated by adding state events to it. + * There are two types of room member associated with a state event: + * normal member objects (accessed via getMember/getMembers) which mutate + * with the state to represent the current state of that room/user, eg. + * the object returned by getMember('@bob:example.com') will mutate to + * get a different display name if Bob later changes his display name + * in the room. + * There are also 'sentinel' members (accessed via getSentinelMember). + * These also represent the state of room members at the point in time + * represented by the RoomState object, but unlike objects from getMember, + * sentinel objects will always represent the room state as at the time + * getSentinelMember was called, so if Bob subsequently changes his display + * name, a room member object previously acquired with getSentinelMember + * will still have his old display name. Calling getSentinelMember again + * after the display name change will return a new RoomMember object + * with Bob's new display name. + * + * @constructor + * @param {?string} roomId Optional. The ID of the room which has this state. + * If none is specified it just tracks paginationTokens, useful for notifTimelineSet + * @param {?object} oobMemberFlags Optional. The state of loading out of bound members. + * As the timeline might get reset while they are loading, this state needs to be inherited + * and shared when the room state is cloned for the new timeline. + * This should only be passed from clone. + * @prop {Object.} members The room member dictionary, keyed + * on the user's ID. + * @prop {Object.>} events The state + * events dictionary, keyed on the event type and then the state_key value. + * @prop {string} paginationToken The pagination token for this state. + */ + constructor(public readonly roomId: string, private oobMemberFlags = { status: OobStatus.NotStarted }) { + super(); + this.updateModifiedTime(); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * @return {number} The number of members in this room whose membership is 'join' + */ + public getJoinedMemberCount(): number { + if (this.summaryJoinedMemberCount !== null) { + return this.summaryJoinedMemberCount; + } + if (this.joinedMemberCount === null) { + this.joinedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'join' ? count + 1 : count; + }, 0); + } + return this.joinedMemberCount; + } + + /** + * Set the joined member count explicitly (like from summary part of the sync response) + * @param {number} count the amount of joined members + */ + public setJoinedMemberCount(count: number): void { + this.summaryJoinedMemberCount = count; + } + + /** + * Returns the number of invited members in this room + * @return {number} The number of members in this room whose membership is 'invite' + */ + public getInvitedMemberCount(): number { + if (this.summaryInvitedMemberCount !== null) { + return this.summaryInvitedMemberCount; + } + if (this.invitedMemberCount === null) { + this.invitedMemberCount = this.getMembers().reduce((count, m) => { + return m.membership === 'invite' ? count + 1 : count; + }, 0); + } + return this.invitedMemberCount; + } + + /** + * Set the amount of invited members in this room + * @param {number} count the amount of invited members + */ + public setInvitedMemberCount(count: number): void { + this.summaryInvitedMemberCount = count; + } + + /** + * Get all RoomMembers in this room. + * @return {Array} A list of RoomMembers. + */ + public getMembers(): RoomMember[] { + return Object.values(this.members); + } + + /** + * Get all RoomMembers in this room, excluding the user IDs provided. + * @param {Array} excludedIds The user IDs to exclude. + * @return {Array} A list of RoomMembers. + */ + public getMembersExcept(excludedIds: string[]): RoomMember[] { + return this.getMembers().filter((m) => !excludedIds.includes(m.userId)); + } + + /** + * Get a room member by their user ID. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getMember(userId: string): RoomMember | null { + return this.members[userId] || null; + } + + /** + * Get a room member whose properties will not change with this room state. You + * typically want this if you want to attach a RoomMember to a MatrixEvent which + * may no longer be represented correctly by Room.currentState or Room.oldState. + * The term 'sentinel' refers to the fact that this RoomMember is an unchanging + * guardian for state at this particular point in time. + * @param {string} userId The room member's user ID. + * @return {RoomMember} The member or null if they do not exist. + */ + public getSentinelMember(userId: string): RoomMember | null { + if (!userId) return null; + let sentinel = this.sentinels[userId]; + + if (sentinel === undefined) { + sentinel = new RoomMember(this.roomId, userId); + const member = this.members[userId]; + if (member) { + sentinel.setMembershipEvent(member.events.member, this); + } + this.sentinels[userId] = sentinel; + } + return sentinel; + } + + /** + * Get state events from the state of the room. + * @param {string} eventType The event type of the state event. + * @param {string} stateKey Optional. The state_key of the state event. If + * this is undefined then all matching state events will be + * returned. + * @return {MatrixEvent[]|MatrixEvent} A list of events if state_key was + * undefined, else a single event (or null if no match found). + */ + public getStateEvents(eventType: string): MatrixEvent[]; + public getStateEvents(eventType: string, stateKey: string): MatrixEvent; + public getStateEvents(eventType: string, stateKey?: string) { + if (!this.events.has(eventType)) { + // no match + return stateKey === undefined ? [] : null; + } + if (stateKey === undefined) { // return all values + return Array.from(this.events.get(eventType).values()); + } + const event = this.events.get(eventType).get(stateKey); + return event ? event : null; + } + + /** + * Creates a copy of this room state so that mutations to either won't affect the other. + * @return {RoomState} the copy of the room state + */ + public clone(): RoomState { + const copy = new RoomState(this.roomId, this.oobMemberFlags); + + // Ugly hack: because setStateEvents will mark + // members as susperseding future out of bound members + // if loading is in progress (through oobMemberFlags) + // since these are not new members, we're merely copying them + // set the status to not started + // after copying, we set back the status + const status = this.oobMemberFlags.status; + this.oobMemberFlags.status = OobStatus.NotStarted; + + Array.from(this.events.values()).forEach((eventsByStateKey) => { + copy.setStateEvents(Array.from(eventsByStateKey.values())); + }); + + // Ugly hack: see above + this.oobMemberFlags.status = status; + + if (this.summaryInvitedMemberCount !== null) { + copy.setInvitedMemberCount(this.getInvitedMemberCount()); + } + if (this.summaryJoinedMemberCount !== null) { + copy.setJoinedMemberCount(this.getJoinedMemberCount()); + } + + // copy out of band flags if needed + if (this.oobMemberFlags.status == OobStatus.Finished) { + // copy markOutOfBand flags + this.getMembers().forEach((member) => { + if (member.isOutOfBand()) { + const copyMember = copy.getMember(member.userId); + copyMember.markOutOfBand(); + } + }); + } + + return copy; + } + + /** + * Add previously unknown state events. + * When lazy loading members while back-paginating, + * the relevant room state for the timeline chunk at the end + * of the chunk can be set with this method. + * @param {MatrixEvent[]} events state events to prepend + */ + public setUnknownStateEvents(events: MatrixEvent[]): void { + const unknownStateEvents = events.filter((event) => { + return !this.events.has(event.getType()) || + !this.events.get(event.getType()).has(event.getStateKey()); + }); + + this.setStateEvents(unknownStateEvents); + } + + /** + * Add an array of one or more state MatrixEvents, overwriting + * any existing state with the same {type, stateKey} tuple. Will fire + * "RoomState.events" for every event added. May fire "RoomState.members" + * if there are m.room.member events. + * @param {MatrixEvent[]} stateEvents a list of state events for this room. + * @fires module:client~MatrixClient#event:"RoomState.members" + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @fires module:client~MatrixClient#event:"RoomState.events" + */ + public setStateEvents(stateEvents: MatrixEvent[]) { + this.updateModifiedTime(); + + // update the core event dict + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + const lastStateEvent = this.getStateEventMatching(event); + this.setStateEvent(event); + if (event.getType() === EventType.RoomMember) { + this.updateDisplayNameCache(event.getStateKey(), event.getContent().displayname); + this.updateThirdPartyTokenCache(event); + } + this.emit("RoomState.events", event, this, lastStateEvent); + }); + + // update higher level data structures. This needs to be done AFTER the + // core event dict as these structures may depend on other state events in + // the given array (e.g. disambiguating display names in one go to do both + // clashing names rather than progressively which only catches 1 of them). + stateEvents.forEach((event) => { + if (event.getRoomId() !== this.roomId) { + return; + } + if (!event.isState()) { + return; + } + + if (event.getType() === EventType.RoomMember) { + const userId = event.getStateKey(); + + // leave events apparently elide the displayname or avatar_url, + // so let's fake one up so that we don't leak user ids + // into the timeline + if (event.getContent().membership === "leave" || + event.getContent().membership === "ban") { + event.getContent().avatar_url = + event.getContent().avatar_url || + event.getPrevContent().avatar_url; + event.getContent().displayname = + event.getContent().displayname || + event.getPrevContent().displayname; + } + + const member = this.getOrCreateMember(userId, event); + member.setMembershipEvent(event, this); + + this.updateMember(member); + this.emit("RoomState.members", event, this, member); + } else if (event.getType() === EventType.RoomPowerLevels) { + // events with unknown state keys should be ignored + // and should not aggregate onto members power levels + if (event.getStateKey() !== "") { + return; + } + const members = Object.values(this.members); + members.forEach((member) => { + // We only propagate `RoomState.members` event if the + // power levels has been changed + // large room suffer from large re-rendering especially when not needed + const oldLastModified = member.getLastModifiedTime(); + member.setPowerLevelEvent(event); + if (oldLastModified !== member.getLastModifiedTime()) { + this.emit("RoomState.members", event, this, member); + } + }); + + // assume all our sentinels are now out-of-date + this.sentinels = {}; + } + }); + } + + /** + * Looks up a member by the given userId, and if it doesn't exist, + * create it and emit the `RoomState.newMember` event. + * This method makes sure the member is added to the members dictionary + * before emitting, as this is done from setStateEvents and setOutOfBandMember. + * @param {string} userId the id of the user to look up + * @param {MatrixEvent} event the membership event for the (new) member. Used to emit. + * @fires module:client~MatrixClient#event:"RoomState.newMember" + * @returns {RoomMember} the member, existing or newly created. + */ + private getOrCreateMember(userId: string, event: MatrixEvent): RoomMember { + let member = this.members[userId]; + if (!member) { + member = new RoomMember(this.roomId, userId); + // add member to members before emitting any events, + // as event handlers often lookup the member + this.members[userId] = member; + this.emit("RoomState.newMember", event, this, member); + } + return member; + } + + private setStateEvent(event: MatrixEvent): void { + if (!this.events.has(event.getType())) { + this.events.set(event.getType(), new Map()); + } + this.events.get(event.getType()).set(event.getStateKey(), event); + } + + private getStateEventMatching(event: MatrixEvent): MatrixEvent | null { + if (!this.events.has(event.getType())) return null; + return this.events.get(event.getType()).get(event.getStateKey()); + } + + private updateMember(member: RoomMember): void { + // this member may have a power level already, so set it. + const pwrLvlEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + if (pwrLvlEvent) { + member.setPowerLevelEvent(pwrLvlEvent); + } + + // blow away the sentinel which is now outdated + delete this.sentinels[member.userId]; + + this.members[member.userId] = member; + this.joinedMemberCount = null; + this.invitedMemberCount = null; + } + + /** + * Get the out-of-band members loading state, whether loading is needed or not. + * Note that loading might be in progress and hence isn't needed. + * @return {boolean} whether or not the members of this room need to be loaded + */ + public needsOutOfBandMembers(): boolean { + return this.oobMemberFlags.status === OobStatus.NotStarted; + } + + /** + * Mark this room state as waiting for out-of-band members, + * ensuring it doesn't ask for them to be requested again + * through needsOutOfBandMembers + */ + public markOutOfBandMembersStarted(): void { + if (this.oobMemberFlags.status !== OobStatus.NotStarted) { + return; + } + this.oobMemberFlags.status = OobStatus.InProgress; + } + + /** + * Mark this room state as having failed to fetch out-of-band members + */ + public markOutOfBandMembersFailed(): void { + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Clears the loaded out-of-band members + */ + public clearOutOfBandMembers(): void { + let count = 0; + Object.keys(this.members).forEach((userId) => { + const member = this.members[userId]; + if (member.isOutOfBand()) { + ++count; + delete this.members[userId]; + } + }); + logger.log(`LL: RoomState removed ${count} members...`); + this.oobMemberFlags.status = OobStatus.NotStarted; + } + + /** + * Sets the loaded out-of-band members. + * @param {MatrixEvent[]} stateEvents array of membership state events + */ + public setOutOfBandMembers(stateEvents: MatrixEvent[]): void { + logger.log(`LL: RoomState about to set ${stateEvents.length} OOB members ...`); + if (this.oobMemberFlags.status !== OobStatus.InProgress) { + return; + } + logger.log(`LL: RoomState put in finished state ...`); + this.oobMemberFlags.status = OobStatus.Finished; + stateEvents.forEach((e) => this.setOutOfBandMember(e)); + } + + /** + * Sets a single out of band member, used by both setOutOfBandMembers and clone + * @param {MatrixEvent} stateEvent membership state event + */ + private setOutOfBandMember(stateEvent: MatrixEvent): void { + if (stateEvent.getType() !== EventType.RoomMember) { + return; + } + const userId = stateEvent.getStateKey(); + const existingMember = this.getMember(userId); + // never replace members received as part of the sync + if (existingMember && !existingMember.isOutOfBand()) { + return; + } + + const member = this.getOrCreateMember(userId, stateEvent); + member.setMembershipEvent(stateEvent, this); + // needed to know which members need to be stored seperately + // as they are not part of the sync accumulator + // this is cleared by setMembershipEvent so when it's updated through /sync + member.markOutOfBand(); + + this.updateDisplayNameCache(member.userId, member.name); + + this.setStateEvent(stateEvent); + this.updateMember(member); + this.emit("RoomState.members", stateEvent, this, member); + } + + /** + * Set the current typing event for this room. + * @param {MatrixEvent} event The typing event + */ + public setTypingEvent(event: MatrixEvent): void { + Object.values(this.members).forEach(function(member) { + member.setTypingEvent(event); + }); + } + + /** + * Get the m.room.member event which has the given third party invite token. + * + * @param {string} token The token + * @return {?MatrixEvent} The m.room.member event or null + */ + public getInviteForThreePidToken(token: string): MatrixEvent | null { + return this.tokenToInvite[token] || null; + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this room state was last updated. This timestamp is + * updated when this object has received new state events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get user IDs with the specified or similar display names. + * @param {string} displayName The display name to get user IDs from. + * @return {string[]} An array of user IDs or an empty array. + */ + public getUserIdsWithDisplayName(displayName: string): string[] { + return this.displayNameToUserIds[utils.removeHiddenChars(displayName)] || []; + } + + /** + * Returns true if userId is in room, event is not redacted and either sender of + * mxEvent or has power level sufficient to redact events other than their own. + * @param {MatrixEvent} mxEvent The event to test permission for + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given used ID can redact given event + */ + public maySendRedactionForEvent(mxEvent: MatrixEvent, userId: string): boolean { + const member = this.getMember(userId); + if (!member || member.membership === 'leave') return false; + + if (mxEvent.status || mxEvent.isRedacted()) return false; + + // The user may have been the sender, but they can't redact their own message + // if redactions are blocked. + const canRedact = this.maySendEvent(EventType.RoomRedaction, userId); + if (mxEvent.getSender() === userId) return canRedact; + + return this.hasSufficientPowerLevelFor('redact', member.powerLevel); + } + + /** + * Returns true if the given power level is sufficient for action + * @param {string} action The type of power level to check + * @param {number} powerLevel The power level of the member + * @return {boolean} true if the given power level is sufficient + */ + private hasSufficientPowerLevelFor(action: string, powerLevel: number): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ""); + + let powerLevels = {}; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + } + + let requiredLevel = 50; + if (utils.isNumber(powerLevels[action])) { + requiredLevel = powerLevels[action]; + } + + return powerLevel >= requiredLevel; + } + + /** + * Short-form for maySendEvent('m.room.message', userId) + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * message events into the given room. + */ + public maySendMessage(userId: string): boolean { + return this.maySendEventOfType(EventType.RoomMessage, userId, false); + } + + /** + * Returns true if the given user ID has permission to send a normal + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + public maySendEvent(eventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(eventType, userId, false); + } + + /** + * Returns true if the given MatrixClient has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {MatrixClient} cli The client to test permission for + * @return {boolean} true if the given client should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public mayClientSendStateEvent(stateEventType: EventType | string, cli: MatrixClient): boolean { + if (cli.isGuest()) { + return false; + } + return this.maySendStateEvent(stateEventType, cli.credentials.userId); + } + + /** + * Returns true if the given user ID has permission to send a state + * event of type `stateEventType` into this room. + * @param {string} stateEventType The type of state events to test + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID should be permitted to send + * the given type of state event into this room, + * according to the room's state. + */ + public maySendStateEvent(stateEventType: EventType | string, userId: string): boolean { + return this.maySendEventOfType(stateEventType, userId, true); + } + + /** + * Returns true if the given user ID has permission to send a normal or state + * event of type `eventType` into this room. + * @param {string} eventType The type of event to test + * @param {string} userId The user ID of the user to test permission for + * @param {boolean} state If true, tests if the user may send a state + event of this type. Otherwise tests whether + they may send a regular event. + * @return {boolean} true if the given user ID should be permitted to send + * the given type of event into this room, + * according to the room's state. + */ + private maySendEventOfType(eventType: EventType | string, userId: string, state: boolean): boolean { + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let powerLevels; + let eventsLevels = {}; + + let stateDefault = 0; + let eventsDefault = 0; + let powerLevel = 0; + if (powerLevelsEvent) { + powerLevels = powerLevelsEvent.getContent(); + eventsLevels = powerLevels.events || {}; + + if (Number.isSafeInteger(powerLevels.state_default)) { + stateDefault = powerLevels.state_default; + } else { + stateDefault = 50; + } + + const userPowerLevel = powerLevels.users && powerLevels.users[userId]; + if (Number.isSafeInteger(userPowerLevel)) { + powerLevel = userPowerLevel; + } else if (Number.isSafeInteger(powerLevels.users_default)) { + powerLevel = powerLevels.users_default; + } + + if (Number.isSafeInteger(powerLevels.events_default)) { + eventsDefault = powerLevels.events_default; + } + } + + let requiredLevel = state ? stateDefault : eventsDefault; + if (Number.isSafeInteger(eventsLevels[eventType])) { + requiredLevel = eventsLevels[eventType]; + } + return powerLevel >= requiredLevel; + } + + /** + * Returns true if the given user ID has permission to trigger notification + * of type `notifLevelKey` + * @param {string} notifLevelKey The level of notification to test (eg. 'room') + * @param {string} userId The user ID of the user to test permission for + * @return {boolean} true if the given user ID has permission to trigger a + * notification of this type. + */ + public mayTriggerNotifOfType(notifLevelKey: string, userId: string): boolean { + const member = this.getMember(userId); + if (!member) { + return false; + } + + const powerLevelsEvent = this.getStateEvents(EventType.RoomPowerLevels, ''); + + let notifLevel = 50; + if ( + powerLevelsEvent && + powerLevelsEvent.getContent() && + powerLevelsEvent.getContent().notifications && + utils.isNumber(powerLevelsEvent.getContent().notifications[notifLevelKey]) + ) { + notifLevel = powerLevelsEvent.getContent().notifications[notifLevelKey]; + } + + return member.powerLevel >= notifLevel; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + const joinRuleEvent = this.getStateEvents(EventType.RoomJoinRules, ""); + const joinRuleContent = joinRuleEvent ? joinRuleEvent.getContent() : {}; + return joinRuleContent["join_rule"] || "invite"; + } + + private updateThirdPartyTokenCache(memberEvent: MatrixEvent): void { + if (!memberEvent.getContent().third_party_invite) { + return; + } + const token = (memberEvent.getContent().third_party_invite.signed || {}).token; + if (!token) { + return; + } + const threePidInvite = this.getStateEvents(EventType.RoomThirdPartyInvite, token); + if (!threePidInvite) { + return; + } + this.tokenToInvite[token] = memberEvent; + } + + private updateDisplayNameCache(userId: string, displayName: string): void { + const oldName = this.userIdsToDisplayNames[userId]; + delete this.userIdsToDisplayNames[userId]; + if (oldName) { + // Remove the old name from the cache. + // We clobber the user_id > name lookup but the name -> [user_id] lookup + // means we need to remove that user ID from that array rather than nuking + // the lot. + const strippedOldName = utils.removeHiddenChars(oldName); + + const existingUserIds = this.displayNameToUserIds[strippedOldName]; + if (existingUserIds) { + // remove this user ID from this array + const filteredUserIDs = existingUserIds.filter((id) => id !== userId); + this.displayNameToUserIds[strippedOldName] = filteredUserIDs; + } + } + + this.userIdsToDisplayNames[userId] = displayName; + + const strippedDisplayname = displayName && utils.removeHiddenChars(displayName); + // an empty stripped displayname (undefined/'') will be set to MXID in room-member.js + if (strippedDisplayname) { + if (!this.displayNameToUserIds[strippedDisplayname]) { + this.displayNameToUserIds[strippedDisplayname] = []; + } + this.displayNameToUserIds[strippedDisplayname].push(userId); + } + } +} + +/** + * Fires whenever the event dictionary in room state is updated. + * @event module:client~MatrixClient#"RoomState.events" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.events dictionary + * was updated. + * @param {MatrixEvent} prevEvent The event being replaced by the new state, if + * known. Note that this can differ from `getPrevContent()` on the new state event + * as this is the store's view of the last state, not the previous state provided + * by the server. + * @example + * matrixClient.on("RoomState.events", function(event, state, prevEvent){ + * var newStateEvent = event; + * }); + */ + +/** + * Fires whenever a member in the members dictionary is updated in any way. + * @event module:client~MatrixClient#"RoomState.members" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated. + * @param {RoomMember} member The room member that was updated. + * @example + * matrixClient.on("RoomState.members", function(event, state, member){ + * var newMembershipState = member.membership; + * }); + */ + +/** + * Fires whenever a member is added to the members dictionary. The RoomMember + * will not be fully populated yet (e.g. no membership state) but will already + * be available in the members dictionary. + * @event module:client~MatrixClient#"RoomState.newMember" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {RoomState} state The room state whose RoomState.members dictionary + * was updated with a new entry. + * @param {RoomMember} member The room member that was added. + * @example + * matrixClient.on("RoomState.newMember", function(event, state, member){ + * // add event listeners on 'member' + * }); + */ diff --git a/src/models/room-summary.js b/src/models/room-summary.ts similarity index 75% rename from src/models/room-summary.js rename to src/models/room-summary.ts index 037fe2bd6..d01b470ae 100644 --- a/src/models/room-summary.js +++ b/src/models/room-summary.ts @@ -1,6 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -19,6 +18,20 @@ limitations under the License. * @module models/room-summary */ +export interface IRoomSummary { + "m.heroes": string[]; + "m.joined_member_count": number; + "m.invited_member_count": number; +} + +interface IInfo { + title: string; + desc?: string; + numMembers?: number; + aliases?: string[]; + timestamp?: number; +} + /** * Construct a new Room Summary. A summary can be used for display on a recent * list, without having to load the entire room list into memory. @@ -32,8 +45,7 @@ limitations under the License. * @param {string[]} info.aliases The list of aliases for this room. * @param {Number} info.timestamp The timestamp for this room. */ -export function RoomSummary(roomId, info) { - this.roomId = roomId; - this.info = info; +export class RoomSummary { + constructor(public readonly roomId: string, info?: IInfo) {} } diff --git a/src/models/room.js b/src/models/room.js deleted file mode 100644 index 2e4229c7f..000000000 --- a/src/models/room.js +++ /dev/null @@ -1,2254 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2018, 2019 New Vector Ltd -Copyright 2019 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. -*/ - -/** - * @module models/room - */ - -import { EventEmitter } from "events"; -import { EventTimelineSet } from "./event-timeline-set"; -import { EventTimeline } from "./event-timeline"; -import { getHttpUriForMxc } from "../content-repo"; -import * as utils from "../utils"; -import { EventStatus, MatrixEvent } from "./event"; -import { RoomMember } from "./room-member"; -import { RoomSummary } from "./room-summary"; -import { logger } from '../logger'; -import { ReEmitter } from '../ReEmitter'; -import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; -import { normalize } from "../utils"; - -// These constants are used as sane defaults when the homeserver doesn't support -// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be -// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the -// room versions which are considered okay for people to run without being asked -// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers -// return an m.room_versions capability. -const KNOWN_SAFE_ROOM_VERSION = '6'; -const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; - -function synthesizeReceipt(userId, event, receiptType) { - // console.log("synthesizing receipt for "+event.getId()); - // This is really ugly because JS has no way to express an object literal - // where the name of a key comes from an expression - const fakeReceipt = { - content: {}, - type: "m.receipt", - room_id: event.getRoomId(), - }; - fakeReceipt.content[event.getId()] = {}; - fakeReceipt.content[event.getId()][receiptType] = {}; - fakeReceipt.content[event.getId()][receiptType][userId] = { - ts: event.getTs(), - }; - return new MatrixEvent(fakeReceipt); -} - -/** - * Construct a new Room. - * - *

For a room, we store an ordered sequence of timelines, which may or may not - * be continuous. Each timeline lists a series of events, as well as tracking - * the room state at the start and the end of the timeline. It also tracks - * forward and backward pagination tokens, as well as containing links to the - * next timeline in the sequence. - * - *

There is one special timeline - the 'live' timeline, which represents the - * timeline to which events are being added in real-time as they are received - * from the /sync API. Note that you should not retain references to this - * timeline - even if it is the current timeline right now, it may not remain - * so if the server gives us a timeline gap in /sync. - * - *

In order that we can find events from their ids later, we also maintain a - * map from event_id to timeline and index. - * - * @constructor - * @alias module:models/room - * @param {string} roomId Required. The ID of this room. - * @param {MatrixClient} client Required. The client, used to lazy load members. - * @param {string} myUserId Required. The ID of the syncing user. - * @param {Object=} opts Configuration options - * @param {*} opts.storageToken Optional. The token which a data store can use - * to remember the state of the room. What this means is dependent on the store - * implementation. - * - * @param {String=} opts.pendingEventOrdering Controls where pending messages - * appear in a room's timeline. If "chronological", messages will appear - * in the timeline when the call to sendEvent was made. If - * "detached", pending messages will appear in a separate list, - * accessbile via {@link module:models/room#getPendingEvents}. Default: - * "chronological". - * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved - * timeline support. - * @param {boolean} [opts.unstableClientRelationAggregation = false] - * Optional. Set to true to enable client-side aggregation of event relations - * via `EventTimelineSet#getRelationsForEvent`. - * This feature is currently unstable and the API may change without notice. - * - * @prop {string} roomId The ID of this room. - * @prop {string} name The human-readable display name for this room. - * @prop {string} normalizedName The unhomoglyphed name for this room. - * @prop {Array} timeline The live event timeline for this room, - * with the oldest event at index 0. Present for backwards compatibility - - * prefer getLiveTimeline().getEvents(). - * @prop {object} tags Dict of room tags; the keys are the tag name and the values - * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } - * @prop {object} accountData Dict of per-room account_data events; the keys are the - * event type and the values are the events. - * @prop {RoomState} oldState The state of the room at the time of the oldest - * event in the live timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). - * @prop {RoomState} currentState The state of the room at the time of the - * newest event in the timeline. Present for backwards compatibility - - * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). - * @prop {RoomSummary} summary The room summary. - * @prop {*} storageToken A token which a data store can use to remember - * the state of the room. - */ -export function Room(roomId, client, myUserId, opts) { - opts = opts || {}; - opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; - - this._client = client; - - // In some cases, we add listeners for every displayed Matrix event, so it's - // common to have quite a few more than the default limit. - this.setMaxListeners(100); - - this.reEmitter = new ReEmitter(this); - - if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { - throw new Error( - "opts.pendingEventOrdering MUST be either 'chronological' or " + - "'detached'. Got: '" + opts.pendingEventOrdering + "'", - ); - } - - this.myUserId = myUserId; - this.roomId = roomId; - this.name = roomId; - this.tags = { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - }; - this.accountData = { - // $eventType: $event - }; - this.summary = null; - this.storageToken = opts.storageToken; - this._opts = opts; - this._txnToEvent = {}; // Pending in-flight requests { string: MatrixEvent } - // receipts should clobber based on receipt_type and user_id pairs hence - // the form of this structure. This is sub-optimal for the exposed APIs - // which pass in an event ID and get back some receipts, so we also store - // a pre-cached list for this purpose. - this._receipts = { - // receipt_type: { - // user_id: { - // eventId: , - // data: - // } - // } - }; - this._receiptCacheByEventId = { - // $event_id: [{ - // type: $type, - // userId: $user_id, - // data: - // }] - }; - // only receipts that came from the server, not synthesized ones - this._realReceipts = {}; - - this._notificationCounts = {}; - - // all our per-room timeline sets. the first one is the unfiltered ones; - // the subsequent ones are the filtered ones in no particular order. - this._timelineSets = [new EventTimelineSet(this, opts)]; - this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); - - this._fixUpLegacyTimelineFields(); - - // any filtered timeline sets we're maintaining for this room - this._filteredTimelineSets = { - // filter_id: timelineSet - }; - - if (this._opts.pendingEventOrdering == "detached") { - this._pendingEventList = []; - const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId)); - if (serializedPendingEventList) { - JSON.parse(serializedPendingEventList) - .forEach(async serializedEvent => { - const event = new MatrixEvent(serializedEvent); - if (event.getType() === "m.room.encrypted") { - await event.attemptDecryption(this._client.crypto); - } - event.setStatus(EventStatus.NOT_SENT); - this.addPendingEvent(event, event.getTxnId()); - }); - } - } - - // read by megolm; boolean value - null indicates "use global value" - this._blacklistUnverifiedDevices = null; - this._selfMembership = null; - this._summaryHeroes = null; - // awaited by getEncryptionTargetMembers while room members are loading - - if (!this._opts.lazyLoadMembers) { - this._membersPromise = Promise.resolve(); - } else { - this._membersPromise = null; - } - - // flags to stop logspam about missing m.room.create events - this.getTypeWarning = false; - this.getVersionWarning = false; -} - -/** - * @param {string} roomId ID of the current room - * @returns {string} Storage key to retrieve pending events - */ -function pendingEventsKey(roomId) { - return `mx_pending_events_${roomId}`; -} - -utils.inherits(Room, EventEmitter); - -/** - * Bulk decrypt critical events in a room - * - * Critical events represents the minimal set of events to decrypt - * for a typical UI to function properly - * - * - Last event of every room (to generate likely message preview) - * - All events up to the read receipt (to calculate an accurate notification count) - * - * @returns {Promise} Signals when all events have been decrypted - */ -Room.prototype.decryptCriticalEvents = function() { - const readReceiptEventId = this.getEventReadUpTo(this._client.getUserId(), true); - const events = this.getLiveTimeline().getEvents(); - const readReceiptTimelineIndex = events.findIndex(matrixEvent => { - return matrixEvent.event.event_id === readReceiptEventId; - }); - - const decryptionPromises = events - .slice(readReceiptTimelineIndex) - .filter(event => event.shouldAttemptDecryption()) - .reverse() - .map(event => event.attemptDecryption(this._client.crypto, { isRetry: true })); - - return Promise.allSettled(decryptionPromises); -}; - -/** - * Bulk decrypt events in a room - * - * @returns {Promise} Signals when all events have been decrypted - */ -Room.prototype.decryptAllEvents = function() { - const decryptionPromises = this - .getUnfilteredTimelineSet() - .getLiveTimeline() - .getEvents() - .filter(event => event.shouldAttemptDecryption()) - .reverse() - .map(event => event.attemptDecryption(this._client.crypto, { isRetry: true })); - - return Promise.allSettled(decryptionPromises); -}; - -/** - * Gets the version of the room - * @returns {string} The version of the room, or null if it could not be determined - */ -Room.prototype.getVersion = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getVersionWarning) { - logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); - this.getVersionWarning = true; - } - return '1'; - } - const ver = createEvent.getContent()['room_version']; - if (ver === undefined) return '1'; - return ver; -}; - -/** - * Determines whether this room needs to be upgraded to a new version - * @returns {string?} What version the room should be upgraded to, or null if - * the room does not require upgrading at this time. - * @deprecated Use #getRecommendedVersion() instead - */ -Room.prototype.shouldUpgradeToVersion = function() { - // TODO: Remove this function. - // This makes assumptions about which versions are safe, and can easily - // be wrong. Instead, people are encouraged to use getRecommendedVersion - // which determines a safer value. This function doesn't use that function - // because this is not async-capable, and to avoid breaking the contract - // we're deprecating this. - - if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { - return KNOWN_SAFE_ROOM_VERSION; - } - - return null; -}; - -/** - * Determines the recommended room version for the room. This returns an - * object with 3 properties: version as the new version the - * room should be upgraded to (may be the same as the current version); - * needsUpgrade to indicate if the room actually can be - * upgraded (ie: does the current version not match?); and urgent - * to indicate if the new version patches a vulnerability in a previous - * version. - * @returns {Promise<{version: string, needsUpgrade: bool, urgent: bool}>} - * Resolves to the version the room should be upgraded to. - */ -Room.prototype.getRecommendedVersion = async function() { - const capabilities = await this._client.getCapabilities(); - let versionCap = capabilities["m.room_versions"]; - if (!versionCap) { - versionCap = { - default: KNOWN_SAFE_ROOM_VERSION, - available: {}, - }; - for (const safeVer of SAFE_ROOM_VERSIONS) { - versionCap.available[safeVer] = "stable"; - } - } - - let result = this._checkVersionAgainstCapability(versionCap); - if (result.urgent && result.needsUpgrade) { - // Something doesn't feel right: we shouldn't need to update - // because the version we're on should be in the protocol's - // namespace. This usually means that the server was updated - // before the client was, making us think the newest possible - // room version is not stable. As a solution, we'll refresh - // the capability we're using to determine this. - logger.warn( - "Refreshing room version capability because the server looks " + - "to be supporting a newer room version we don't know about.", - ); - - const caps = await this._client.getCapabilities(true); - versionCap = caps["m.room_versions"]; - if (!versionCap) { - logger.warn("No room version capability - assuming upgrade required."); - return result; - } else { - result = this._checkVersionAgainstCapability(versionCap); - } - } - - return result; -}; - -Room.prototype._checkVersionAgainstCapability = function(versionCap) { - const currentVersion = this.getVersion(); - logger.log(`[${this.roomId}] Current version: ${currentVersion}`); - logger.log(`[${this.roomId}] Version capability: `, versionCap); - - const result = { - version: currentVersion, - needsUpgrade: false, - urgent: false, - }; - - // If the room is on the default version then nothing needs to change - if (currentVersion === versionCap.default) return result; - - const stableVersions = Object.keys(versionCap.available) - .filter((v) => versionCap.available[v] === 'stable'); - - // Check if the room is on an unstable version. We determine urgency based - // off the version being in the Matrix spec namespace or not (if the version - // is in the current namespace and unstable, the room is probably vulnerable). - if (!stableVersions.includes(currentVersion)) { - result.version = versionCap.default; - result.needsUpgrade = true; - result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); - if (result.urgent) { - logger.warn(`URGENT upgrade required on ${this.roomId}`); - } else { - logger.warn(`Non-urgent upgrade required on ${this.roomId}`); - } - return result; - } - - // The room is on a stable, but non-default, version by this point. - // No upgrade needed. - return result; -}; - -/** - * Determines whether the given user is permitted to perform a room upgrade - * @param {String} userId The ID of the user to test against - * @returns {bool} True if the given user is permitted to upgrade the room - */ -Room.prototype.userMayUpgradeRoom = function(userId) { - return this.currentState.maySendStateEvent("m.room.tombstone", userId); -}; - -/** - * Get the list of pending sent events for this room - * - * @return {module:models/event.MatrixEvent[]} A list of the sent events - * waiting for remote echo. - * - * @throws If opts.pendingEventOrdering was not 'detached' - */ -Room.prototype.getPendingEvents = function() { - if (this._opts.pendingEventOrdering !== "detached") { - throw new Error( - "Cannot call getPendingEvents with pendingEventOrdering == " + - this._opts.pendingEventOrdering); - } - - return this._pendingEventList; -}; - -/** - * Removes a pending event for this room - * - * @param {string} eventId - * @return {boolean} True if an element was removed. - */ -Room.prototype.removePendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - throw new Error( - "Cannot call removePendingEvent with pendingEventOrdering == " + - this._opts.pendingEventOrdering); - } - - const removed = utils.removeElement( - this._pendingEventList, - function(ev) { - return ev.getId() == eventId; - }, false, - ); - - this._savePendingEvents(); - - return removed; -}; - -/** - * Check whether the pending event list contains a given event by ID. - * If pending event ordering is not "detached" then this returns false. - * - * @param {string} eventId The event ID to check for. - * @return {boolean} - */ -Room.prototype.hasPendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - return false; - } - - return this._pendingEventList.some(event => event.getId() === eventId); -}; - -/** - * Get a specific event from the pending event list, if configured, null otherwise. - * - * @param {string} eventId The event ID to check for. - * @return {MatrixEvent} - */ -Room.prototype.getPendingEvent = function(eventId) { - if (this._opts.pendingEventOrdering !== "detached") { - return null; - } - - return this._pendingEventList.find(event => event.getId() === eventId); -}; - -/** - * Get the live unfiltered timeline for this room. - * - * @return {module:models/event-timeline~EventTimeline} live timeline - */ -Room.prototype.getLiveTimeline = function() { - return this.getUnfilteredTimelineSet().getLiveTimeline(); -}; - -/** - * Get the timestamp of the last message in the room - * - * @return {number} the timestamp of the last message in the room - */ -Room.prototype.getLastActiveTimestamp = function() { - const timeline = this.getLiveTimeline(); - const events = timeline.getEvents(); - if (events.length) { - const lastEvent = events[events.length - 1]; - return lastEvent.getTs(); - } else { - return Number.MIN_SAFE_INTEGER; - } -}; - -/** - * @param {string} myUserId the user id for the logged in member - * @return {string} the membership type (join | leave | invite) for the logged in user - */ -Room.prototype.getMyMembership = function() { - return this._selfMembership; -}; - -/** - * If this room is a DM we're invited to, - * try to find out who invited us - * @return {string} user id of the inviter - */ -Room.prototype.getDMInviter = function() { - if (this.myUserId) { - const me = this.getMember(this.myUserId); - if (me) { - return me.getDMInviter(); - } - } - if (this._selfMembership === "invite") { - // fall back to summary information - const memberCount = this.getInvitedAndJoinedMemberCount(); - if (memberCount == 2 && this._summaryHeroes.length) { - return this._summaryHeroes[0]; - } - } -}; - -/** - * Assuming this room is a DM room, tries to guess with which user. - * @return {string} user id of the other member (could be syncing user) - */ -Room.prototype.guessDMUserId = function() { - const me = this.getMember(this.myUserId); - if (me) { - const inviterId = me.getDMInviter(); - if (inviterId) { - return inviterId; - } - } - // remember, we're assuming this room is a DM, - // so returning the first member we find should be fine - const hasHeroes = Array.isArray(this._summaryHeroes) && - this._summaryHeroes.length; - if (hasHeroes) { - return this._summaryHeroes[0]; - } - const members = this.currentState.getMembers(); - const anyMember = members.find((m) => m.userId !== this.myUserId); - if (anyMember) { - return anyMember.userId; - } - // it really seems like I'm the only user in the room - // so I probably created a room with just me in it - // and marked it as a DM. Ok then - return this.myUserId; -}; - -Room.prototype.getAvatarFallbackMember = function() { - const memberCount = this.getInvitedAndJoinedMemberCount(); - if (memberCount > 2) { - return; - } - const hasHeroes = Array.isArray(this._summaryHeroes) && - this._summaryHeroes.length; - if (hasHeroes) { - const availableMember = this._summaryHeroes.map((userId) => { - return this.getMember(userId); - }).find((member) => !!member); - if (availableMember) { - return availableMember; - } - } - const members = this.currentState.getMembers(); - // could be different than memberCount - // as this includes left members - if (members.length <= 2) { - const availableMember = members.find((m) => { - return m.userId !== this.myUserId; - }); - if (availableMember) { - return availableMember; - } - } - // if all else fails, try falling back to a user, - // and create a one-off member for it - if (hasHeroes) { - const availableUser = this._summaryHeroes.map((userId) => { - return this._client.getUser(userId); - }).find((user) => !!user); - if (availableUser) { - const member = new RoomMember( - this.roomId, availableUser.userId); - member.user = availableUser; - return member; - } - } -}; - -/** - * Sets the membership this room was received as during sync - * @param {string} membership join | leave | invite - */ -Room.prototype.updateMyMembership = function(membership) { - const prevMembership = this._selfMembership; - this._selfMembership = membership; - if (prevMembership !== membership) { - if (membership === "leave") { - this._cleanupAfterLeaving(); - } - this.emit("Room.myMembership", this, membership, prevMembership); - } -}; - -Room.prototype._loadMembersFromServer = async function() { - const lastSyncToken = this._client.store.getSyncToken(); - const queryString = utils.encodeParams({ - not_membership: "leave", - at: lastSyncToken, - }); - const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, - { $roomId: this.roomId }); - const http = this._client.http; - const response = await http.authedRequest(undefined, "GET", path); - return response.chunk; -}; - -Room.prototype._loadMembers = async function() { - // were the members loaded from the server? - let fromServer = false; - let rawMembersEvents = - await this._client.store.getOutOfBandMembers(this.roomId); - if (rawMembersEvents === null) { - fromServer = true; - rawMembersEvents = await this._loadMembersFromServer(); - logger.log(`LL: got ${rawMembersEvents.length} ` + - `members from server for room ${this.roomId}`); - } - const memberEvents = rawMembersEvents.map(this._client.getEventMapper()); - return { memberEvents, fromServer }; -}; - -/** - * Preloads the member list in case lazy loading - * of memberships is in use. Can be called multiple times, - * it will only preload once. - * @return {Promise} when preloading is done and - * accessing the members on the room will take - * all members in the room into account - */ -Room.prototype.loadMembersIfNeeded = function() { - if (this._membersPromise) { - return this._membersPromise; - } - - // mark the state so that incoming messages while - // the request is in flight get marked as superseding - // the OOB members - this.currentState.markOutOfBandMembersStarted(); - - const inMemoryUpdate = this._loadMembers().then((result) => { - this.currentState.setOutOfBandMembers(result.memberEvents); - // now the members are loaded, start to track the e2e devices if needed - if (this._client.isCryptoEnabled() && this._client.isRoomEncrypted(this.roomId)) { - this._client.crypto.trackRoomDevices(this.roomId); - } - return result.fromServer; - }).catch((err) => { - // allow retries on fail - this._membersPromise = null; - this.currentState.markOutOfBandMembersFailed(); - throw err; - }); - // update members in storage, but don't wait for it - inMemoryUpdate.then((fromServer) => { - if (fromServer) { - const oobMembers = this.currentState.getMembers() - .filter((m) => m.isOutOfBand()) - .map((m) => m.events.member.event); - logger.log(`LL: telling store to write ${oobMembers.length}` - + ` members for room ${this.roomId}`); - const store = this._client.store; - return store.setOutOfBandMembers(this.roomId, oobMembers) - // swallow any IDB error as we don't want to fail - // because of this - .catch((err) => { - logger.log("LL: storing OOB room members failed, oh well", - err); - }); - } - }).catch((err) => { - // as this is not awaited anywhere, - // at least show the error in the console - logger.error(err); - }); - - this._membersPromise = inMemoryUpdate; - - return this._membersPromise; -}; - -/** - * Removes the lazily loaded members from storage if needed - */ -Room.prototype.clearLoadedMembersIfNeeded = async function() { - if (this._opts.lazyLoadMembers && this._membersPromise) { - await this.loadMembersIfNeeded(); - await this._client.store.clearOutOfBandMembers(this.roomId); - this.currentState.clearOutOfBandMembers(); - this._membersPromise = null; - } -}; - -/** - * called when sync receives this room in the leave section - * to do cleanup after leaving a room. Possibly called multiple times. - */ -Room.prototype._cleanupAfterLeaving = function() { - this.clearLoadedMembersIfNeeded().catch((err) => { - logger.error(`error after clearing loaded members from ` + - `room ${this.roomId} after leaving`); - logger.log(err); - }); -}; - -/** - * Reset the live timeline of all timelineSets, and start new ones. - * - *

This is used when /sync returns a 'limited' timeline. - * - * @param {string=} backPaginationToken token for back-paginating the new timeline - * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, - * if absent or null, all timelines are reset, removing old ones (including the previous live - * timeline which would otherwise be unable to paginate forwards without this token). - * Removing just the old live timeline whilst preserving previous ones is not supported. - */ -Room.prototype.resetLiveTimeline = function(backPaginationToken, forwardPaginationToken) { - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].resetLiveTimeline( - backPaginationToken, forwardPaginationToken, - ); - } - - this._fixUpLegacyTimelineFields(); -}; - -/** - * Fix up this.timeline, this.oldState and this.currentState - * - * @private - */ -Room.prototype._fixUpLegacyTimelineFields = function() { - // maintain this.timeline as a reference to the live timeline, - // and this.oldState and this.currentState as references to the - // state at the start and end of that timeline. These are more - // for backwards-compatibility than anything else. - this.timeline = this.getLiveTimeline().getEvents(); - this.oldState = this.getLiveTimeline() - .getState(EventTimeline.BACKWARDS); - this.currentState = this.getLiveTimeline() - .getState(EventTimeline.FORWARDS); -}; - -/** - * Returns whether there are any devices in the room that are unverified - * - * Note: Callers should first check if crypto is enabled on this device. If it is - * disabled, then we aren't tracking room devices at all, so we can't answer this, and an - * error will be thrown. - * - * @return {bool} the result - */ -Room.prototype.hasUnverifiedDevices = async function() { - if (!this._client.isRoomEncrypted(this.roomId)) { - return false; - } - const e2eMembers = await this.getEncryptionTargetMembers(); - for (const member of e2eMembers) { - const devices = this._client.getStoredDevicesForUser(member.userId); - if (devices.some((device) => device.isUnverified())) { - return true; - } - } - return false; -}; - -/** - * Return the timeline sets for this room. - * @return {EventTimelineSet[]} array of timeline sets for this room - */ -Room.prototype.getTimelineSets = function() { - return this._timelineSets; -}; - -/** - * Helper to return the main unfiltered timeline set for this room - * @return {EventTimelineSet} room's unfiltered timeline set - */ -Room.prototype.getUnfilteredTimelineSet = function() { - return this._timelineSets[0]; -}; - -/** - * Get the timeline which contains the given event from the unfiltered set, if any - * - * @param {string} eventId event ID to look for - * @return {?module:models/event-timeline~EventTimeline} timeline containing - * the given event, or null if unknown - */ -Room.prototype.getTimelineForEvent = function(eventId) { - return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); -}; - -/** - * Add a new timeline to this room's unfiltered timeline set - * - * @return {module:models/event-timeline~EventTimeline} newly-created timeline - */ -Room.prototype.addTimeline = function() { - return this.getUnfilteredTimelineSet().addTimeline(); -}; - -/** - * Get an event which is stored in our unfiltered timeline set - * - * @param {string} eventId event ID to look for - * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown - */ -Room.prototype.findEventById = function(eventId) { - return this.getUnfilteredTimelineSet().findEventById(eventId); -}; - -/** - * Get one of the notification counts for this room - * @param {String} type The type of notification count to get. default: 'total' - * @return {Number} The notification count, or undefined if there is no count - * for this type. - */ -Room.prototype.getUnreadNotificationCount = function(type) { - type = type || 'total'; - return this._notificationCounts[type]; -}; - -/** - * Set one of the notification counts for this room - * @param {String} type The type of notification count to set. - * @param {Number} count The new count - */ -Room.prototype.setUnreadNotificationCount = function(type, count) { - this._notificationCounts[type] = count; -}; - -Room.prototype.setSummary = function(summary) { - const heroes = summary["m.heroes"]; - const joinedCount = summary["m.joined_member_count"]; - const invitedCount = summary["m.invited_member_count"]; - if (Number.isInteger(joinedCount)) { - this.currentState.setJoinedMemberCount(joinedCount); - } - if (Number.isInteger(invitedCount)) { - this.currentState.setInvitedMemberCount(invitedCount); - } - if (Array.isArray(heroes)) { - // be cautious about trusting server values, - // and make sure heroes doesn't contain our own id - // just to be sure - this._summaryHeroes = heroes.filter((userId) => { - return userId !== this.myUserId; - }); - } -}; - -/** - * Whether to send encrypted messages to devices within this room. - * @param {Boolean} value true to blacklist unverified devices, null - * to use the global value for this room. - */ -Room.prototype.setBlacklistUnverifiedDevices = function(value) { - this._blacklistUnverifiedDevices = value; -}; - -/** - * Whether to send encrypted messages to devices within this room. - * @return {Boolean} true if blacklisting unverified devices, null - * if the global value should be used for this room. - */ -Room.prototype.getBlacklistUnverifiedDevices = function() { - return this._blacklistUnverifiedDevices; -}; - -/** - * Get the avatar URL for a room if one was set. - * @param {String} baseUrl The homeserver base URL. See - * {@link module:client~MatrixClient#getHomeserverUrl}. - * @param {Number} width The desired width of the thumbnail. - * @param {Number} height The desired height of the thumbnail. - * @param {string} resizeMethod The thumbnail resize method to use, either - * "crop" or "scale". - * @param {boolean} allowDefault True to allow an identicon for this room if an - * avatar URL wasn't explicitly set. Default: true. (Deprecated) - * @return {?string} the avatar URL or null. - */ -Room.prototype.getAvatarUrl = function(baseUrl, width, height, resizeMethod, - allowDefault) { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - if (allowDefault === undefined) { - allowDefault = true; - } - if (!roomAvatarEvent && !allowDefault) { - return null; - } - - const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; - if (mainUrl) { - return getHttpUriForMxc( - baseUrl, mainUrl, width, height, resizeMethod, - ); - } - - return null; -}; - -/** - * Get the mxc avatar url for the room, if one was set. - * @return {string} the mxc avatar url or falsy - */ -Room.prototype.getMxcAvatarUrl = function() { - const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); - return roomAvatarEvent ? roomAvatarEvent.getContent().url : null; -}; - -/** - * Get the aliases this room has according to the room's state - * The aliases returned by this function may not necessarily - * still point to this room. - * @return {array} The room's alias as an array of strings - */ -Room.prototype.getAliases = function() { - const aliasStrings = []; - - const aliasEvents = this.currentState.getStateEvents("m.room.aliases"); - if (aliasEvents) { - for (let i = 0; i < aliasEvents.length; ++i) { - const aliasEvent = aliasEvents[i]; - if (Array.isArray(aliasEvent.getContent().aliases)) { - const filteredAliases = aliasEvent.getContent().aliases.filter(a => { - if (typeof(a) !== "string") return false; - if (a[0] !== '#') return false; - if (!a.endsWith(`:${aliasEvent.getStateKey()}`)) return false; - - // It's probably valid by here. - return true; - }); - Array.prototype.push.apply(aliasStrings, filteredAliases); - } - } - } - return aliasStrings; -}; - -/** - * Get this room's canonical alias - * The alias returned by this function may not necessarily - * still point to this room. - * @return {?string} The room's canonical alias, or null if there is none - */ -Room.prototype.getCanonicalAlias = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - if (canonicalAlias) { - return canonicalAlias.getContent().alias || null; - } - return null; -}; - -/** - * Get this room's alternative aliases - * @return {array} The room's alternative aliases, or an empty array - */ -Room.prototype.getAltAliases = function() { - const canonicalAlias = this.currentState.getStateEvents("m.room.canonical_alias", ""); - if (canonicalAlias) { - return canonicalAlias.getContent().alt_aliases || []; - } - return []; -}; - -/** - * Add events to a timeline - * - *

Will fire "Room.timeline" for each event added. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {boolean} toStartOfTimeline True to add these events to the start - * (oldest) instead of the end (newest) of the timeline. If true, the oldest - * event will be the last element of 'events'. - * - * @param {module:models/event-timeline~EventTimeline} timeline timeline to - * add events to. - * - * @param {string=} paginationToken token for the next batch of events - * - * @fires module:client~MatrixClient#event:"Room.timeline" - * - */ -Room.prototype.addEventsToTimeline = function(events, toStartOfTimeline, - timeline, paginationToken) { - timeline.getTimelineSet().addEventsToTimeline( - events, toStartOfTimeline, - timeline, paginationToken, - ); -}; - -/** - * Get a member from the current room state. - * @param {string} userId The user ID of the member. - * @return {RoomMember} The member or null. - */ - Room.prototype.getMember = function(userId) { - return this.currentState.getMember(userId); - }; - -/** - * Get all currently loaded members from the current - * room state. - * @returns {RoomMember[]} Room members - */ -Room.prototype.getMembers = function() { - return this.currentState.getMembers(); -}; - -/** - * Get a list of members whose membership state is "join". - * @return {RoomMember[]} A list of currently joined members. - */ - Room.prototype.getJoinedMembers = function() { - return this.getMembersWithMembership("join"); - }; - -/** - * Returns the number of joined members in this room - * This method caches the result. - * This is a wrapper around the method of the same name in roomState, returning - * its result for the room's current state. - * @return {integer} The number of members in this room whose membership is 'join' - */ -Room.prototype.getJoinedMemberCount = function() { - return this.currentState.getJoinedMemberCount(); -}; - -/** - * Returns the number of invited members in this room - * @return {integer} The number of members in this room whose membership is 'invite' - */ -Room.prototype.getInvitedMemberCount = function() { - return this.currentState.getInvitedMemberCount(); -}; - -/** - * Returns the number of invited + joined members in this room - * @return {integer} The number of members in this room whose membership is 'invite' or 'join' - */ -Room.prototype.getInvitedAndJoinedMemberCount = function() { - return this.getInvitedMemberCount() + this.getJoinedMemberCount(); -}; - -/** - * Get a list of members with given membership state. - * @param {string} membership The membership state. - * @return {RoomMember[]} A list of members with the given membership state. - */ - Room.prototype.getMembersWithMembership = function(membership) { - return this.currentState.getMembers().filter(function(m) { - return m.membership === membership; - }); - }; - - /** - * Get a list of members we should be encrypting for in this room - * @return {Promise} A list of members who - * we should encrypt messages for in this room. - */ - Room.prototype.getEncryptionTargetMembers = async function() { - await this.loadMembersIfNeeded(); - let members = this.getMembersWithMembership("join"); - if (this.shouldEncryptForInvitedMembers()) { - members = members.concat(this.getMembersWithMembership("invite")); - } - return members; - }; - - /** - * Determine whether we should encrypt messages for invited users in this room - * @return {boolean} if we should encrypt messages for invited users - */ - Room.prototype.shouldEncryptForInvitedMembers = function() { - const ev = this.currentState.getStateEvents("m.room.history_visibility", ""); - return (ev && ev.getContent() && ev.getContent().history_visibility !== "joined"); - }; - - /** - * Get the default room name (i.e. what a given user would see if the - * room had no m.room.name) - * @param {string} userId The userId from whose perspective we want - * to calculate the default name - * @return {string} The default room name - */ - Room.prototype.getDefaultRoomName = function(userId) { - return calculateRoomName(this, userId, true); - }; - - /** - * Check if the given user_id has the given membership state. - * @param {string} userId The user ID to check. - * @param {string} membership The membership e.g. 'join' - * @return {boolean} True if this user_id has the given membership state. - */ - Room.prototype.hasMembershipState = function(userId, membership) { - const member = this.getMember(userId); - if (!member) { - return false; - } - return member.membership === membership; - }; - -/** - * Add a timelineSet for this room with the given filter - * @param {Filter} filter The filter to be applied to this timelineSet - * @return {EventTimelineSet} The timelineSet - */ -Room.prototype.getOrCreateFilteredTimelineSet = function(filter) { - if (this._filteredTimelineSets[filter.filterId]) { - return this._filteredTimelineSets[filter.filterId]; - } - const opts = Object.assign({ filter: filter }, this._opts); - const timelineSet = new EventTimelineSet(this, opts); - this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); - this._filteredTimelineSets[filter.filterId] = timelineSet; - this._timelineSets.push(timelineSet); - - // populate up the new timelineSet with filtered events from our live - // unfiltered timeline. - // - // XXX: This is risky as our timeline - // may have grown huge and so take a long time to filter. - // see https://github.com/vector-im/vector-web/issues/2109 - - const unfilteredLiveTimeline = this.getLiveTimeline(); - - unfilteredLiveTimeline.getEvents().forEach(function(event) { - timelineSet.addLiveEvent(event); - }); - - // find the earliest unfiltered timeline - let timeline = unfilteredLiveTimeline; - while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { - timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); - } - - timelineSet.getLiveTimeline().setPaginationToken( - timeline.getPaginationToken(EventTimeline.BACKWARDS), - EventTimeline.BACKWARDS, - ); - - // alternatively, we could try to do something like this to try and re-paginate - // in the filtered events from nothing, but Mark says it's an abuse of the API - // to do so: - // - // timelineSet.resetLiveTimeline( - // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) - // ); - - return timelineSet; -}; - -/** - * Forget the timelineSet for this room with the given filter - * - * @param {Filter} filter the filter whose timelineSet is to be forgotten - */ -Room.prototype.removeFilteredTimelineSet = function(filter) { - const timelineSet = this._filteredTimelineSets[filter.filterId]; - delete this._filteredTimelineSets[filter.filterId]; - const i = this._timelineSets.indexOf(timelineSet); - if (i > -1) { - this._timelineSets.splice(i, 1); - } -}; - -/** - * Add an event to the end of this room's live timelines. Will fire - * "Room.timeline". - * - * @param {MatrixEvent} event Event to be added - * @param {string?} duplicateStrategy 'ignore' or 'replace' - * @param {boolean} fromCache whether the sync response came from cache - * @fires module:client~MatrixClient#event:"Room.timeline" - * @private - */ -Room.prototype._addLiveEvent = function(event, duplicateStrategy, fromCache) { - if (event.isRedaction()) { - const redactId = event.event.redacts; - - // if we know about this event, redact its contents now. - const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); - if (redactedEvent) { - redactedEvent.makeRedacted(event); - - // If this is in the current state, replace it with the redacted version - if (redactedEvent.getStateKey()) { - const currentStateEvent = this.currentState.getStateEvents( - redactedEvent.getType(), - redactedEvent.getStateKey(), - ); - if (currentStateEvent.getId() === redactedEvent.getId()) { - this.currentState.setStateEvents([redactedEvent]); - } - } - - this.emit("Room.redaction", event, this); - - // TODO: we stash user displaynames (among other things) in - // RoomMember objects which are then attached to other events - // (in the sender and target fields). We should get those - // RoomMember objects to update themselves when the events that - // they are based on are changed. - } - - // FIXME: apply redactions to notification list - - // NB: We continue to add the redaction event to the timeline so - // clients can say "so and so redacted an event" if they wish to. Also - // this may be needed to trigger an update. - } - - if (event.getUnsigned().transaction_id) { - const existingEvent = this._txnToEvent[event.getUnsigned().transaction_id]; - if (existingEvent) { - // remote echo of an event we sent earlier - this._handleRemoteEcho(event, existingEvent); - return; - } - } - - // add to our timeline sets - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); - } - - // synthesize and inject implicit read receipts - // Done after adding the event because otherwise the app would get a read receipt - // pointing to an event that wasn't yet in the timeline - // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. - if (event.sender && event.getType() !== "m.room.redaction") { - this.addReceipt(synthesizeReceipt( - event.sender.userId, event, "m.read", - ), true); - - // Any live events from a user could be taken as implicit - // presence information: evidence that they are currently active. - // ...except in a world where we use 'user.currentlyActive' to reduce - // presence spam, this isn't very useful - we'll get a transition when - // they are no longer currently active anyway. So don't bother to - // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. - } -}; - -/** - * Add a pending outgoing event to this room. - * - *

The event is added to either the pendingEventList, or the live timeline, - * depending on the setting of opts.pendingEventOrdering. - * - *

This is an internal method, intended for use by MatrixClient. - * - * @param {module:models/event.MatrixEvent} event The event to add. - * - * @param {string} txnId Transaction id for this outgoing event - * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * - * @throws if the event doesn't have status SENDING, or we aren't given a - * unique transaction id. - */ -Room.prototype.addPendingEvent = function(event, txnId) { - if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) { - throw new Error("addPendingEvent called on an event with status " + - event.status); - } - - if (this._txnToEvent[txnId]) { - throw new Error("addPendingEvent called on an event with known txnId " + - txnId); - } - - // call setEventMetadata to set up event.sender etc - // as event is shared over all timelineSets, we set up its metadata based - // on the unfiltered timelineSet. - EventTimeline.setEventMetadata( - event, - this.getLiveTimeline().getState(EventTimeline.FORWARDS), - false, - ); - - this._txnToEvent[txnId] = event; - - if (this._opts.pendingEventOrdering == "detached") { - if (this._pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { - logger.warn("Setting event as NOT_SENT due to messages in the same state"); - event.setStatus(EventStatus.NOT_SENT); - } - this._pendingEventList.push(event); - this._savePendingEvents(); - if (event.isRelation()) { - // For pending events, add them to the relations collection immediately. - // (The alternate case below already covers this as part of adding to - // the timeline set.) - this._aggregateNonLiveRelation(event); - } - - if (event.isRedaction()) { - const redactId = event.event.redacts; - let redactedEvent = this._pendingEventList && - this._pendingEventList.find(e => e.getId() === redactId); - if (!redactedEvent) { - redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); - } - if (redactedEvent) { - redactedEvent.markLocallyRedacted(event); - this.emit("Room.redaction", event, this); - } - } - } else { - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } else { - timelineSet.addEventToTimeline(event, - timelineSet.getLiveTimeline(), false); - } - } - } - - this.emit("Room.localEchoUpdated", event, this, null, null); -}; - -/** - * Persists all pending events to local storage - * - * If the current room is encrypted only encrypted events will be persisted - * all messages that are not yet encrypted will be discarded - * - * This is because the flow of EVENT_STATUS transition is - * queued => sending => encrypting => sending => sent - * - * Steps 3 and 4 are skipped for unencrypted room. - * It is better to discard an unencrypted message rather than persisting - * it locally for everyone to read - */ -Room.prototype._savePendingEvents = function() { - if (this._pendingEventList) { - const pendingEvents = this._pendingEventList.map(event => { - return { - ...event.event, - txn_id: event.getTxnId(), - }; - }).filter(event => { - // Filter out the unencrypted messages if the room is encrypted - const isEventEncrypted = event.type === "m.room.encrypted"; - const isRoomEncrypted = this._client.isRoomEncrypted(this.roomId); - return isEventEncrypted || !isRoomEncrypted; - }); - - const { store } = this._client.sessionStore; - if (this._pendingEventList.length > 0) { - store.setItem( - pendingEventsKey(this.roomId), - JSON.stringify(pendingEvents), - ); - } else { - store.removeItem(pendingEventsKey(this.roomId)); - } - } -}; - -/** - * Used to aggregate the local echo for a relation, and also - * for re-applying a relation after it's redaction has been cancelled, - * as the local echo for the redaction of the relation would have - * un-aggregated the relation. Note that this is different from regular messages, - * which are just kept detached for their local echo. - * - * Also note that live events are aggregated in the live EventTimelineSet. - * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. - */ -Room.prototype._aggregateNonLiveRelation = function(event) { - // TODO: We should consider whether this means it would be a better - // design to lift the relations handling up to the room instead. - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - if (timelineSet.getFilter()) { - if (timelineSet.getFilter().filterRoomTimeline([event]).length) { - timelineSet.aggregateRelations(event); - } - } else { - timelineSet.aggregateRelations(event); - } - } -}; - -/** - * Deal with the echo of a message we sent. - * - *

We move the event to the live timeline if it isn't there already, and - * update it. - * - * @param {module:models/event.MatrixEvent} remoteEvent The event received from - * /sync - * @param {module:models/event.MatrixEvent} localEvent The local echo, which - * should be either in the _pendingEventList or the timeline. - * - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - * @private - */ -Room.prototype._handleRemoteEcho = function(remoteEvent, localEvent) { - const oldEventId = localEvent.getId(); - const newEventId = remoteEvent.getId(); - const oldStatus = localEvent.status; - - logger.debug( - `Got remote echo for event ${oldEventId} -> ${newEventId} ` + - `old status ${oldStatus}`, - ); - - // no longer pending - delete this._txnToEvent[remoteEvent.getUnsigned().transaction_id]; - - // if it's in the pending list, remove it - if (this._pendingEventList) { - this.removePendingEvent(oldEventId); - } - - // replace the event source (this will preserve the plaintext payload if - // any, which is good, because we don't want to try decoding it again). - localEvent.handleRemoteEcho(remoteEvent.event); - - for (let i = 0; i < this._timelineSets.length; i++) { - const timelineSet = this._timelineSets[i]; - - // if it's already in the timeline, update the timeline map. If it's not, add it. - timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); - } - - this.emit("Room.localEchoUpdated", localEvent, this, - oldEventId, oldStatus); -}; - -/* a map from current event status to a list of allowed next statuses - */ -const ALLOWED_TRANSITIONS = {}; - -ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ - EventStatus.SENDING, - EventStatus.NOT_SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ - EventStatus.ENCRYPTING, - EventStatus.QUEUED, - EventStatus.NOT_SENT, - EventStatus.SENT, -]; - -ALLOWED_TRANSITIONS[EventStatus.QUEUED] = - [EventStatus.SENDING, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.SENT] = - []; - -ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = - [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; - -ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = - []; - -/** - * Update the status / event id on a pending event, to reflect its transmission - * progress. - * - *

This is an internal method. - * - * @param {MatrixEvent} event local echo event - * @param {EventStatus} newStatus status to assign - * @param {string} newEventId new event id to assign. Ignored unless - * newStatus == EventStatus.SENT. - * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" - */ -Room.prototype.updatePendingEvent = function(event, newStatus, newEventId) { - logger.log( - `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + - `event ID ${event.getId()} -> ${newEventId}`, - ); - - // if the message was sent, we expect an event id - if (newStatus == EventStatus.SENT && !newEventId) { - throw new Error("updatePendingEvent called with status=SENT, " + - "but no new event id"); - } - - // SENT races against /sync, so we have to special-case it. - if (newStatus == EventStatus.SENT) { - const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); - if (timeline) { - // we've already received the event via the event stream. - // nothing more to do here. - return; - } - } - - const oldStatus = event.status; - const oldEventId = event.getId(); - - if (!oldStatus) { - throw new Error("updatePendingEventStatus called on an event which is " + - "not a local echo."); - } - - const allowed = ALLOWED_TRANSITIONS[oldStatus]; - if (!allowed || allowed.indexOf(newStatus) < 0) { - throw new Error("Invalid EventStatus transition " + oldStatus + "->" + - newStatus); - } - - event.setStatus(newStatus); - - if (newStatus == EventStatus.SENT) { - // update the event id - event.replaceLocalEventId(newEventId); - - // if the event was already in the timeline (which will be the case if - // opts.pendingEventOrdering==chronological), we need to update the - // timeline map. - for (let i = 0; i < this._timelineSets.length; i++) { - this._timelineSets[i].replaceEventId(oldEventId, newEventId); - } - } else if (newStatus == EventStatus.CANCELLED) { - // remove it from the pending event list, or the timeline. - if (this._pendingEventList) { - const idx = this._pendingEventList.findIndex(ev => ev.getId() === oldEventId); - if (idx !== -1) { - const [removedEvent] = this._pendingEventList.splice(idx, 1); - if (removedEvent.isRedaction()) { - this._revertRedactionLocalEcho(removedEvent); - } - } - } - this.removeEvent(oldEventId); - } - this._savePendingEvents(); - - this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); -}; - -Room.prototype._revertRedactionLocalEcho = function(redactionEvent) { - const redactId = redactionEvent.event.redacts; - if (!redactId) { - return; - } - const redactedEvent = this.getUnfilteredTimelineSet() - .findEventById(redactId); - if (redactedEvent) { - redactedEvent.unmarkLocallyRedacted(); - // re-render after undoing redaction - this.emit("Room.redactionCancelled", redactionEvent, this); - // reapply relation now redaction failed - if (redactedEvent.isRelation()) { - this._aggregateNonLiveRelation(redactedEvent); - } - } -}; - -/** - * Add some events to this room. This can include state events, message - * events and typing notifications. These events are treated as "live" so - * they will go to the end of the timeline. - * - * @param {MatrixEvent[]} events A list of events to add. - * - * @param {string} duplicateStrategy Optional. Applies to events in the - * timeline only. If this is 'replace' then if a duplicate is encountered, the - * event passed to this function will replace the existing event in the - * timeline. If this is not specified, or is 'ignore', then the event passed to - * this function will be ignored entirely, preserving the existing event in the - * timeline. Events are identical based on their event ID only. - * - * @param {boolean} fromCache whether the sync response came from cache - * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. - */ -Room.prototype.addLiveEvents = function(events, duplicateStrategy, fromCache) { - let i; - if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { - throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); - } - - // sanity check that the live timeline is still live - for (i = 0; i < this._timelineSets.length; i++) { - const liveTimeline = this._timelineSets[i].getLiveTimeline(); - if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - it has a pagination token " + - "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", - ); - } - if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { - throw new Error( - "live timeline " + i + " is no longer live - " + - "it has a neighbouring timeline", - ); - } - } - - for (i = 0; i < events.length; i++) { - // TODO: We should have a filter to say "only add state event - // types X Y Z to the timeline". - this._addLiveEvent(events[i], duplicateStrategy, fromCache); - } -}; - -/** - * Adds/handles ephemeral events such as typing notifications and read receipts. - * @param {MatrixEvent[]} events A list of events to process - */ -Room.prototype.addEphemeralEvents = function(events) { - for (const event of events) { - if (event.getType() === 'm.typing') { - this.currentState.setTypingEvent(event); - } else if (event.getType() === 'm.receipt') { - this.addReceipt(event); - } // else ignore - life is too short for us to care about these events - } -}; - -/** - * Removes events from this room. - * @param {String[]} eventIds A list of eventIds to remove. - */ -Room.prototype.removeEvents = function(eventIds) { - for (let i = 0; i < eventIds.length; ++i) { - this.removeEvent(eventIds[i]); - } -}; - -/** - * Removes a single event from this room. - * - * @param {String} eventId The id of the event to remove - * - * @return {bool} true if the event was removed from any of the room's timeline sets - */ -Room.prototype.removeEvent = function(eventId) { - let removedAny = false; - for (let i = 0; i < this._timelineSets.length; i++) { - const removed = this._timelineSets[i].removeEvent(eventId); - if (removed) { - if (removed.isRedaction()) { - this._revertRedactionLocalEcho(removed); - } - removedAny = true; - } - } - return removedAny; -}; - -/** - * Recalculate various aspects of the room, including the room name and - * room summary. Call this any time the room's current state is modified. - * May fire "Room.name" if the room name is updated. - * @fires module:client~MatrixClient#event:"Room.name" - */ -Room.prototype.recalculate = function() { - // set fake stripped state events if this is an invite room so logic remains - // consistent elsewhere. - const self = this; - const membershipEvent = this.currentState.getStateEvents( - "m.room.member", this.myUserId, - ); - if (membershipEvent && membershipEvent.getContent().membership === "invite") { - const strippedStateEvents = membershipEvent.event.invite_room_state || []; - strippedStateEvents.forEach(function(strippedEvent) { - const existingEvent = self.currentState.getStateEvents( - strippedEvent.type, strippedEvent.state_key, - ); - if (!existingEvent) { - // set the fake stripped event instead - self.currentState.setStateEvents([new MatrixEvent({ - type: strippedEvent.type, - state_key: strippedEvent.state_key, - content: strippedEvent.content, - event_id: "$fake" + Date.now(), - room_id: self.roomId, - user_id: self.myUserId, // technically a lie - })]); - } - }); - } - - const oldName = this.name; - this.name = calculateRoomName(this, this.myUserId); - this.normalizedName = normalize(this.name); - this.summary = new RoomSummary(this.roomId, { - title: this.name, - }); - - if (oldName !== this.name) { - this.emit("Room.name", this); - } -}; - -/** - * Get a list of user IDs who have read up to the given event. - * @param {MatrixEvent} event the event to get read receipts for. - * @return {String[]} A list of user IDs. - */ -Room.prototype.getUsersReadUpTo = function(event) { - return this.getReceiptsForEvent(event).filter(function(receipt) { - return receipt.type === "m.read"; - }).map(function(receipt) { - return receipt.userId; - }); -}; - -/** - * Get the ID of the event that a given user has read up to, or null if we - * have received no read receipts from them. - * @param {String} userId The user ID to get read receipt event ID for - * @param {Boolean} ignoreSynthesized If true, return only receipts that have been - * sent by the server, not implicit ones generated - * by the JS SDK. - * @return {String} ID of the latest event that the given user has read, or null. - */ -Room.prototype.getEventReadUpTo = function(userId, ignoreSynthesized) { - let receipts = this._receipts; - if (ignoreSynthesized) { - receipts = this._realReceipts; - } - - if ( - receipts["m.read"] === undefined || - receipts["m.read"][userId] === undefined - ) { - return null; - } - - return receipts["m.read"][userId].eventId; -}; - -/** - * Determines if the given user has read a particular event ID with the known - * history of the room. This is not a definitive check as it relies only on - * what is available to the room at the time of execution. - * @param {String} userId The user ID to check the read state of. - * @param {String} eventId The event ID to check if the user read. - * @returns {Boolean} True if the user has read the event, false otherwise. - */ -Room.prototype.hasUserReadEvent = function(userId, eventId) { - const readUpToId = this.getEventReadUpTo(userId, false); - if (readUpToId === eventId) return true; - - if (this.timeline.length - && this.timeline[this.timeline.length - 1].getSender() - && this.timeline[this.timeline.length - 1].getSender() === userId) { - // It doesn't matter where the event is in the timeline, the user has read - // it because they've sent the latest event. - return true; - } - - for (let i = this.timeline.length - 1; i >= 0; --i) { - const ev = this.timeline[i]; - - // If we encounter the target event first, the user hasn't read it - // however if we encounter the readUpToId first then the user has read - // it. These rules apply because we're iterating bottom-up. - if (ev.getId() === eventId) return false; - if (ev.getId() === readUpToId) return true; - } - - // We don't know if the user has read it, so assume not. - return false; -}; - -/** - * Get a list of receipts for the given event. - * @param {MatrixEvent} event the event to get receipts for - * @return {Object[]} A list of receipts with a userId, type and data keys or - * an empty list. - */ -Room.prototype.getReceiptsForEvent = function(event) { - return this._receiptCacheByEventId[event.getId()] || []; -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Boolean} fake True if this event is implicit - */ -Room.prototype.addReceipt = function(event, fake) { - // event content looks like: - // content: { - // $event_id: { - // $receipt_type: { - // $user_id: { - // ts: $timestamp - // } - // } - // } - // } - if (fake === undefined) { - fake = false; - } - if (!fake) { - this._addReceiptsToStructure(event, this._realReceipts); - // we don't bother caching real receipts by event ID - // as there's nothing that would read it. - } - this._addReceiptsToStructure(event, this._receipts); - this._receiptCacheByEventId = this._buildReceiptCache(this._receipts); - - // send events after we've regenerated the cache, otherwise things that - // listened for the event would read from a stale cache - this.emit("Room.receipt", event, this); -}; - -/** - * Add a receipt event to the room. - * @param {MatrixEvent} event The m.receipt event. - * @param {Object} receipts The object to add receipts to - */ -Room.prototype._addReceiptsToStructure = function(event, receipts) { - const self = this; - Object.keys(event.getContent()).forEach(function(eventId) { - Object.keys(event.getContent()[eventId]).forEach(function(receiptType) { - Object.keys(event.getContent()[eventId][receiptType]).forEach( - function(userId) { - const receipt = event.getContent()[eventId][receiptType][userId]; - - if (!receipts[receiptType]) { - receipts[receiptType] = {}; - } - - const existingReceipt = receipts[receiptType][userId]; - - if (!existingReceipt) { - receipts[receiptType][userId] = {}; - } else { - // we only want to add this receipt if we think it is later - // than the one we already have. (This is managed - // server-side, but because we synthesize RRs locally we - // have to do it here too.) - const ordering = self.getUnfilteredTimelineSet().compareEventOrdering( - existingReceipt.eventId, eventId); - if (ordering !== null && ordering >= 0) { - return; - } - } - - receipts[receiptType][userId] = { - eventId: eventId, - data: receipt, - }; - }); - }); - }); -}; - -/** - * Build and return a map of receipts by event ID - * @param {Object} receipts A map of receipts - * @return {Object} Map of receipts by event ID - */ -Room.prototype._buildReceiptCache = function(receipts) { - const receiptCacheByEventId = {}; - Object.keys(receipts).forEach(function(receiptType) { - Object.keys(receipts[receiptType]).forEach(function(userId) { - const receipt = receipts[receiptType][userId]; - if (!receiptCacheByEventId[receipt.eventId]) { - receiptCacheByEventId[receipt.eventId] = []; - } - receiptCacheByEventId[receipt.eventId].push({ - userId: userId, - type: receiptType, - data: receipt.data, - }); - }); - }); - return receiptCacheByEventId; -}; - -/** - * Add a temporary local-echo receipt to the room to reflect in the - * client the fact that we've sent one. - * @param {string} userId The user ID if the receipt sender - * @param {MatrixEvent} e The event that is to be acknowledged - * @param {string} receiptType The type of receipt - */ -Room.prototype._addLocalEchoReceipt = function(userId, e, receiptType) { - this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); -}; - -/** - * Update the room-tag event for the room. The previous one is overwritten. - * @param {MatrixEvent} event the m.tag event - */ -Room.prototype.addTags = function(event) { - // event content looks like: - // content: { - // tags: { - // $tagName: { $metadata: $value }, - // $tagName: { $metadata: $value }, - // } - // } - - // XXX: do we need to deep copy here? - this.tags = event.getContent().tags || {}; - - // XXX: we could do a deep-comparison to see if the tags have really - // changed - but do we want to bother? - this.emit("Room.tags", event, this); -}; - -/** - * Update the account_data events for this room, overwriting events of the same type. - * @param {Array} events an array of account_data events to add - */ -Room.prototype.addAccountData = function(events) { - for (let i = 0; i < events.length; i++) { - const event = events[i]; - if (event.getType() === "m.tag") { - this.addTags(event); - } - const lastEvent = this.accountData[event.getType()]; - this.accountData[event.getType()] = event; - this.emit("Room.accountData", event, this, lastEvent); - } -}; - -/** - * Access account_data event of given event type for this room - * @param {string} type the type of account_data event to be accessed - * @return {?MatrixEvent} the account_data event in question - */ -Room.prototype.getAccountData = function(type) { - return this.accountData[type]; -}; - -/** - * Returns whether the syncing user has permission to send a message in the room - * @return {boolean} true if the user should be permitted to send - * message events into the room. - */ -Room.prototype.maySendMessage = function() { - return this.getMyMembership() === 'join' && - this.currentState.maySendEvent('m.room.message', this.myUserId); -}; - -/** - * Returns whether the given user has permissions to issue an invite for this room. - * @param {string} userId the ID of the Matrix user to check permissions for - * @returns {boolean} true if the user should be permitted to issue invites for this room. - */ -Room.prototype.canInvite = function(userId) { - let canInvite = this.getMyMembership() === "join"; - const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); - const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); - const me = this.getMember(userId); - if (powerLevels && me && powerLevels.invite > me.powerLevel) { - canInvite = false; - } - return canInvite; -}; - -/** - * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. - * @returns {string} the join_rule applied to this room - */ -Room.prototype.getJoinRule = function() { - return this.currentState.getJoinRule(); -}; - -/** - * Returns the type of the room from the `m.room.create` event content or undefined if none is set - * @returns {?string} the type of the room. Currently only RoomType.Space is known. - */ -Room.prototype.getType = function() { - const createEvent = this.currentState.getStateEvents("m.room.create", ""); - if (!createEvent) { - if (!this.getTypeWarning) { - logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); - this.getTypeWarning = true; - } - return undefined; - } - return createEvent.getContent()[RoomCreateTypeField]; -}; - -/** - * Returns whether the room is a space-room as defined by MSC1772. - * @returns {boolean} true if the room's type is RoomType.Space - */ -Room.prototype.isSpaceRoom = function() { - return this.getType() === RoomType.Space; -}; - -/** - * This is an internal method. Calculates the name of the room from the current - * room state. - * @param {Room} room The matrix room. - * @param {string} userId The client's user ID. Used to filter room members - * correctly. - * @param {bool} ignoreRoomNameEvent Return the implicit room name that we'd see if there - * was no m.room.name event. - * @return {string} The calculated room name. - */ -function calculateRoomName(room, userId, ignoreRoomNameEvent) { - if (!ignoreRoomNameEvent) { - // check for an alias, if any. for now, assume first alias is the - // official one. - const mRoomName = room.currentState.getStateEvents("m.room.name", ""); - if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { - return mRoomName.getContent().name; - } - } - - let alias = room.getCanonicalAlias(); - - if (!alias) { - const aliases = room.getAltAliases(); - - if (aliases.length) { - alias = aliases[0]; - } - } - if (alias) { - return alias; - } - - const joinedMemberCount = room.currentState.getJoinedMemberCount(); - const invitedMemberCount = room.currentState.getInvitedMemberCount(); - // -1 because these numbers include the syncing user - const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; - - // get members that are NOT ourselves and are actually in the room. - let otherNames = null; - if (room._summaryHeroes) { - // if we have a summary, the member state events - // should be in the room state - otherNames = room._summaryHeroes.map((userId) => { - const member = room.getMember(userId); - return member ? member.name : userId; - }); - } else { - let otherMembers = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - (m.membership === "invite" || m.membership === "join"); - }); - // make sure members have stable order - otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); - // only 5 first members, immitate _summaryHeroes - otherMembers = otherMembers.slice(0, 5); - otherNames = otherMembers.map((m) => m.name); - } - - if (inviteJoinCount) { - return memberNamesToRoomName(otherNames, inviteJoinCount); - } - - const myMembership = room.getMyMembership(); - // if I have created a room and invited people throuh - // 3rd party invites - if (myMembership == 'join') { - const thirdPartyInvites = - room.currentState.getStateEvents("m.room.third_party_invite"); - - if (thirdPartyInvites && thirdPartyInvites.length) { - const thirdPartyNames = thirdPartyInvites.map((i) => { - return i.getContent().display_name; - }); - - return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; - } - } - // let's try to figure out who was here before - let leftNames = otherNames; - // if we didn't have heroes, try finding them in the room state - if (!leftNames.length) { - leftNames = room.currentState.getMembers().filter((m) => { - return m.userId !== userId && - m.membership !== "invite" && - m.membership !== "join"; - }).map((m) => m.name); - } - if (leftNames.length) { - return `Empty room (was ${memberNamesToRoomName(leftNames)})`; - } else { - return "Empty room"; - } -} - -function memberNamesToRoomName(names, count = (names.length + 1)) { - const countWithoutMe = count - 1; - if (!names.length) { - return "Empty room"; - } else if (names.length === 1 && countWithoutMe <= 1) { - return names[0]; - } else if (names.length === 2 && countWithoutMe <= 2) { - return `${names[0]} and ${names[1]}`; - } else { - const plural = countWithoutMe > 1; - if (plural) { - return `${names[0]} and ${countWithoutMe} others`; - } else { - return `${names[0]} and 1 other`; - } - } -} - -/** - * Fires when an event we had previously received is redacted. - * - * (Note this is *not* fired when the redaction happens before we receive the - * event). - * - * @event module:client~MatrixClient#"Room.redaction" - * @param {MatrixEvent} event The matrix redaction event - * @param {Room} room The room containing the redacted event - */ - -/** - * Fires when an event that was previously redacted isn't anymore. - * This happens when the redaction couldn't be sent and - * was subsequently cancelled by the user. Redactions have a local echo - * which is undone in this scenario. - * - * @event module:client~MatrixClient#"Room.redactionCancelled" - * @param {MatrixEvent} event The matrix redaction event that was cancelled. - * @param {Room} room The room containing the unredacted event - */ - -/** - * Fires whenever the name of a room is updated. - * @event module:client~MatrixClient#"Room.name" - * @param {Room} room The room whose Room.name was updated. - * @example - * matrixClient.on("Room.name", function(room){ - * var newName = room.name; - * }); - */ - -/** - * Fires whenever a receipt is received for a room - * @event module:client~MatrixClient#"Room.receipt" - * @param {event} event The receipt event - * @param {Room} room The room whose receipts was updated. - * @example - * matrixClient.on("Room.receipt", function(event, room){ - * var receiptContent = event.getContent(); - * }); - */ - -/** - * Fires whenever a room's tags are updated. - * @event module:client~MatrixClient#"Room.tags" - * @param {event} event The tags event - * @param {Room} room The room whose Room.tags was updated. - * @example - * matrixClient.on("Room.tags", function(event, room){ - * var newTags = event.getContent().tags; - * if (newTags["favourite"]) showStar(room); - * }); - */ - -/** - * Fires whenever a room's account_data is updated. - * @event module:client~MatrixClient#"Room.accountData" - * @param {event} event The account_data event - * @param {Room} room The room whose account_data was updated. - * @param {MatrixEvent} prevEvent The event being replaced by - * the new account data, if known. - * @example - * matrixClient.on("Room.accountData", function(event, room, oldEvent){ - * if (event.getType() === "m.room.colorscheme") { - * applyColorScheme(event.getContents()); - * } - * }); - */ - -/** - * Fires when the status of a transmitted event is updated. - * - *

When an event is first transmitted, a temporary copy of the event is - * inserted into the timeline, with a temporary event id, and a status of - * 'SENDING'. - * - *

Once the echo comes back from the server, the content of the event - * (MatrixEvent.event) is replaced by the complete event from the homeserver, - * thus updating its event id, as well as server-generated fields such as the - * timestamp. Its status is set to null. - * - *

Once the /send request completes, if the remote echo has not already - * arrived, the event is updated with a new event id and the status is set to - * 'SENT'. The server-generated fields are of course not updated yet. - * - *

If the /send fails, In this case, the event's status is set to - * 'NOT_SENT'. If it is later resent, the process starts again, setting the - * status to 'SENDING'. Alternatively, the message may be cancelled, which - * removes the event from the room, and sets the status to 'CANCELLED'. - * - *

This event is raised to reflect each of the transitions above. - * - * @event module:client~MatrixClient#"Room.localEchoUpdated" - * - * @param {MatrixEvent} event The matrix event which has been updated - * - * @param {Room} room The room containing the redacted event - * - * @param {string} oldEventId The previous event id (the temporary event id, - * except when updating a successfully-sent event when its echo arrives) - * - * @param {EventStatus} oldStatus The previous event status. - */ - -/** - * Fires when the logged in user's membership in the room is updated. - * - * @event module:models/room~Room#"Room.myMembership" - * @param {Room} room The room in which the membership has been updated - * @param {string} membership The new membership value - * @param {string} prevMembership The previous membership value - */ diff --git a/src/models/room.ts b/src/models/room.ts new file mode 100644 index 000000000..9970ae595 --- /dev/null +++ b/src/models/room.ts @@ -0,0 +1,2267 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/room + */ + +import { EventEmitter } from "events"; + +import { EventTimelineSet } from "./event-timeline-set"; +import { EventTimeline } from "./event-timeline"; +import { getHttpUriForMxc } from "../content-repo"; +import * as utils from "../utils"; +import { normalize } from "../utils"; +import { EventStatus, MatrixEvent } from "./event"; +import { RoomMember } from "./room-member"; +import { IRoomSummary, RoomSummary } from "./room-summary"; +import { logger } from '../logger'; +import { ReEmitter } from '../ReEmitter'; +import { EventType, RoomCreateTypeField, RoomType } from "../@types/event"; +import { IRoomVersionsCapability, MatrixClient, RoomVersionStability } from "../client"; +import { ResizeMethod } from "../@types/partials"; +import { Filter } from "../filter"; +import { RoomState } from "./room-state"; + +// These constants are used as sane defaults when the homeserver doesn't support +// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be +// the same as the common default room version whereas SAFE_ROOM_VERSIONS are the +// room versions which are considered okay for people to run without being asked +// to upgrade (ie: "stable"). Eventually, we should remove these when all homeservers +// return an m.room_versions capability. +const KNOWN_SAFE_ROOM_VERSION = '6'; +const SAFE_ROOM_VERSIONS = ['1', '2', '3', '4', '5', '6']; + +function synthesizeReceipt(userId: string, event: MatrixEvent, receiptType: string): MatrixEvent { + // console.log("synthesizing receipt for "+event.getId()); + // This is really ugly because JS has no way to express an object literal + // where the name of a key comes from an expression + const fakeReceipt = { + content: {}, + type: "m.receipt", + room_id: event.getRoomId(), + }; + fakeReceipt.content[event.getId()] = {}; + fakeReceipt.content[event.getId()][receiptType] = {}; + fakeReceipt.content[event.getId()][receiptType][userId] = { + ts: event.getTs(), + }; + return new MatrixEvent(fakeReceipt); +} + +interface IOpts { + storageToken?: string; + pendingEventOrdering?: "chronological" | "detached"; + timelineSupport?: boolean; + unstableClientRelationAggregation?: boolean; + lazyLoadMembers?: boolean; +} + +export interface IRecommendedVersion { + version: string; + needsUpgrade: boolean; + urgent: boolean; +} + +interface IReceipt { + ts: number; +} + +interface IWrappedReceipt { + eventId: string; + data: IReceipt; +} + +interface ICachedReceipt { + type: string; + userId: string; + data: IReceipt; +} + +type ReceiptCache = Record; + +interface IReceiptContent { + [eventId: string]: { + [type: string]: { + [userId: string]: IReceipt; + }; + }; +} + +type Receipts = Record>; + +export enum NotificationCountType { + Highlight = "highlight", + Total = "total", +} + +export class Room extends EventEmitter { + private readonly reEmitter: ReEmitter; + private txnToEvent: Record = {}; // Pending in-flight requests { string: MatrixEvent } + // receipts should clobber based on receipt_type and user_id pairs hence + // the form of this structure. This is sub-optimal for the exposed APIs + // which pass in an event ID and get back some receipts, so we also store + // a pre-cached list for this purpose. + private receipts: Receipts = {}; // { receipt_type: { user_id: IReceipt } } + private receiptCacheByEventId: ReceiptCache = {}; // { event_id: IReceipt2[] } + // only receipts that came from the server, not synthesized ones + private realReceipts: Receipts = {}; + private notificationCounts: Partial> = {}; + private readonly timelineSets: EventTimelineSet[]; + // any filtered timeline sets we're maintaining for this room + private readonly filteredTimelineSets: Record = {}; // filter_id: timelineSet + private readonly pendingEventList?: MatrixEvent[]; + // read by megolm via getter; boolean value - null indicates "use global value" + private blacklistUnverifiedDevices: boolean = null; + private selfMembership: string = null; + private summaryHeroes: string[] = null; + // flags to stop logspam about missing m.room.create events + private getTypeWarning = false; + private getVersionWarning = false; + private membersPromise?: Promise; + + // XXX: These should be read-only + public name: string; + public normalizedName: string; + public tags: Record> = {}; // $tagName: { $metadata: $value } + public accountData: Record = {}; // $eventType: $event + public summary: RoomSummary = null; + public readonly storageToken?: string; + // legacy fields + public timeline: MatrixEvent[]; + public oldState: RoomState; + public currentState: RoomState; + + /** + * Construct a new Room. + * + *

For a room, we store an ordered sequence of timelines, which may or may not + * be continuous. Each timeline lists a series of events, as well as tracking + * the room state at the start and the end of the timeline. It also tracks + * forward and backward pagination tokens, as well as containing links to the + * next timeline in the sequence. + * + *

There is one special timeline - the 'live' timeline, which represents the + * timeline to which events are being added in real-time as they are received + * from the /sync API. Note that you should not retain references to this + * timeline - even if it is the current timeline right now, it may not remain + * so if the server gives us a timeline gap in /sync. + * + *

In order that we can find events from their ids later, we also maintain a + * map from event_id to timeline and index. + * + * @constructor + * @alias module:models/room + * @param {string} roomId Required. The ID of this room. + * @param {MatrixClient} client Required. The client, used to lazy load members. + * @param {string} myUserId Required. The ID of the syncing user. + * @param {Object=} opts Configuration options + * @param {*} opts.storageToken Optional. The token which a data store can use + * to remember the state of the room. What this means is dependent on the store + * implementation. + * + * @param {String=} opts.pendingEventOrdering Controls where pending messages + * appear in a room's timeline. If "chronological", messages will appear + * in the timeline when the call to sendEvent was made. If + * "detached", pending messages will appear in a separate list, + * accessible via {@link module:models/room#getPendingEvents}. Default: + * "chronological". + * @param {boolean} [opts.timelineSupport = false] Set to true to enable improved + * timeline support. + * @param {boolean} [opts.unstableClientRelationAggregation = false] + * Optional. Set to true to enable client-side aggregation of event relations + * via `EventTimelineSet#getRelationsForEvent`. + * This feature is currently unstable and the API may change without notice. + * + * @prop {string} roomId The ID of this room. + * @prop {string} name The human-readable display name for this room. + * @prop {string} normalizedName The un-homoglyphed name for this room. + * @prop {Array} timeline The live event timeline for this room, + * with the oldest event at index 0. Present for backwards compatibility - + * prefer getLiveTimeline().getEvents(). + * @prop {object} tags Dict of room tags; the keys are the tag name and the values + * are any metadata associated with the tag - e.g. { "fav" : { order: 1 } } + * @prop {object} accountData Dict of per-room account_data events; the keys are the + * event type and the values are the events. + * @prop {RoomState} oldState The state of the room at the time of the oldest + * event in the live timeline. Present for backwards compatibility - + * prefer getLiveTimeline().getState(EventTimeline.BACKWARDS). + * @prop {RoomState} currentState The state of the room at the time of the + * newest event in the timeline. Present for backwards compatibility - + * prefer getLiveTimeline().getState(EventTimeline.FORWARDS). + * @prop {RoomSummary} summary The room summary. + * @prop {*} storageToken A token which a data store can use to remember + * the state of the room. + */ + constructor( + public readonly roomId: string, + public readonly client: MatrixClient, + public readonly myUserId: string, + private readonly opts: IOpts = {}, + ) { + super(); + // In some cases, we add listeners for every displayed Matrix event, so it's + // common to have quite a few more than the default limit. + this.setMaxListeners(100); + this.reEmitter = new ReEmitter(this); + + opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; + if (["chronological", "detached"].indexOf(opts.pendingEventOrdering) === -1) { + throw new Error( + "opts.pendingEventOrdering MUST be either 'chronological' or " + + "'detached'. Got: '" + opts.pendingEventOrdering + "'", + ); + } + + this.name = roomId; + + // all our per-room timeline sets. the first one is the unfiltered ones; + // the subsequent ones are the filtered ones in no particular order. + this.timelineSets = [new EventTimelineSet(this, opts)]; + this.reEmitter.reEmit(this.getUnfilteredTimelineSet(), ["Room.timeline", "Room.timelineReset"]); + + this.fixUpLegacyTimelineFields(); + + if (this.opts.pendingEventOrdering == "detached") { + this.pendingEventList = []; + const serializedPendingEventList = client.sessionStore.store.getItem(pendingEventsKey(this.roomId)); + if (serializedPendingEventList) { + JSON.parse(serializedPendingEventList) + .forEach(async serializedEvent => { + const event = new MatrixEvent(serializedEvent); + if (event.getType() === EventType.RoomMessageEncrypted) { + await event.attemptDecryption(this.client.crypto); + } + event.setStatus(EventStatus.NOT_SENT); + this.addPendingEvent(event, event.getTxnId()); + }); + } + } + + // awaited by getEncryptionTargetMembers while room members are loading + if (!this.opts.lazyLoadMembers) { + this.membersPromise = Promise.resolve(false); + } else { + this.membersPromise = null; + } + } + + /** + * Bulk decrypt critical events in a room + * + * Critical events represents the minimal set of events to decrypt + * for a typical UI to function properly + * + * - Last event of every room (to generate likely message preview) + * - All events up to the read receipt (to calculate an accurate notification count) + * + * @returns {Promise} Signals when all events have been decrypted + */ + public decryptCriticalEvents(): Promise { + const readReceiptEventId = this.getEventReadUpTo(this.client.getUserId(), true); + const events = this.getLiveTimeline().getEvents(); + const readReceiptTimelineIndex = events.findIndex(matrixEvent => { + return matrixEvent.event.event_id === readReceiptEventId; + }); + + const decryptionPromises = events + .slice(readReceiptTimelineIndex) + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises) as unknown as Promise; + } + + /** + * Bulk decrypt events in a room + * + * @returns {Promise} Signals when all events have been decrypted + */ + public decryptAllEvents(): Promise { + const decryptionPromises = this + .getUnfilteredTimelineSet() + .getLiveTimeline() + .getEvents() + .filter(event => event.shouldAttemptDecryption()) + .reverse() + .map(event => event.attemptDecryption(this.client.crypto, { isRetry: true })); + + return Promise.allSettled(decryptionPromises) as unknown as Promise; + } + + /** + * Gets the version of the room + * @returns {string} The version of the room, or null if it could not be determined + */ + public getVersion(): string | null { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getVersionWarning) { + logger.warn("[getVersion] Room " + this.roomId + " does not have an m.room.create event"); + this.getVersionWarning = true; + } + return '1'; + } + const ver = createEvent.getContent()['room_version']; + if (ver === undefined) return '1'; + return ver; + } + + /** + * Determines whether this room needs to be upgraded to a new version + * @returns {string?} What version the room should be upgraded to, or null if + * the room does not require upgrading at this time. + * @deprecated Use #getRecommendedVersion() instead + */ + public shouldUpgradeToVersion(): string | null { + // TODO: Remove this function. + // This makes assumptions about which versions are safe, and can easily + // be wrong. Instead, people are encouraged to use getRecommendedVersion + // which determines a safer value. This function doesn't use that function + // because this is not async-capable, and to avoid breaking the contract + // we're deprecating this. + + if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) { + return KNOWN_SAFE_ROOM_VERSION; + } + + return null; + } + + /** + * Determines the recommended room version for the room. This returns an + * object with 3 properties: version as the new version the + * room should be upgraded to (may be the same as the current version); + * needsUpgrade to indicate if the room actually can be + * upgraded (ie: does the current version not match?); and urgent + * to indicate if the new version patches a vulnerability in a previous + * version. + * @returns {Promise<{version: string, needsUpgrade: boolean, urgent: boolean}>} + * Resolves to the version the room should be upgraded to. + */ + public async getRecommendedVersion(): Promise { + const capabilities = await this.client.getCapabilities(); + let versionCap = capabilities["m.room_versions"]; + if (!versionCap) { + versionCap = { + default: KNOWN_SAFE_ROOM_VERSION, + available: {}, + }; + for (const safeVer of SAFE_ROOM_VERSIONS) { + versionCap.available[safeVer] = RoomVersionStability.Stable; + } + } + + let result = this.checkVersionAgainstCapability(versionCap); + if (result.urgent && result.needsUpgrade) { + // Something doesn't feel right: we shouldn't need to update + // because the version we're on should be in the protocol's + // namespace. This usually means that the server was updated + // before the client was, making us think the newest possible + // room version is not stable. As a solution, we'll refresh + // the capability we're using to determine this. + logger.warn( + "Refreshing room version capability because the server looks " + + "to be supporting a newer room version we don't know about.", + ); + + const caps = await this.client.getCapabilities(true); + versionCap = caps["m.room_versions"]; + if (!versionCap) { + logger.warn("No room version capability - assuming upgrade required."); + return result; + } else { + result = this.checkVersionAgainstCapability(versionCap); + } + } + + return result; + } + + private checkVersionAgainstCapability(versionCap: IRoomVersionsCapability): IRecommendedVersion { + const currentVersion = this.getVersion(); + logger.log(`[${this.roomId}] Current version: ${currentVersion}`); + logger.log(`[${this.roomId}] Version capability: `, versionCap); + + const result = { + version: currentVersion, + needsUpgrade: false, + urgent: false, + }; + + // If the room is on the default version then nothing needs to change + if (currentVersion === versionCap.default) return result; + + const stableVersions = Object.keys(versionCap.available) + .filter((v) => versionCap.available[v] === 'stable'); + + // Check if the room is on an unstable version. We determine urgency based + // off the version being in the Matrix spec namespace or not (if the version + // is in the current namespace and unstable, the room is probably vulnerable). + if (!stableVersions.includes(currentVersion)) { + result.version = versionCap.default; + result.needsUpgrade = true; + result.urgent = !!this.getVersion().match(/^[0-9]+[0-9.]*$/g); + if (result.urgent) { + logger.warn(`URGENT upgrade required on ${this.roomId}`); + } else { + logger.warn(`Non-urgent upgrade required on ${this.roomId}`); + } + return result; + } + + // The room is on a stable, but non-default, version by this point. + // No upgrade needed. + return result; + } + + /** + * Determines whether the given user is permitted to perform a room upgrade + * @param {String} userId The ID of the user to test against + * @returns {boolean} True if the given user is permitted to upgrade the room + */ + public userMayUpgradeRoom(userId: string): boolean { + return this.currentState.maySendStateEvent(EventType.RoomTombstone, userId); + } + + /** + * Get the list of pending sent events for this room + * + * @return {module:models/event.MatrixEvent[]} A list of the sent events + * waiting for remote echo. + * + * @throws If opts.pendingEventOrdering was not 'detached' + */ + public getPendingEvents(): MatrixEvent[] { + if (this.opts.pendingEventOrdering !== "detached") { + throw new Error( + "Cannot call getPendingEvents with pendingEventOrdering == " + + this.opts.pendingEventOrdering); + } + + return this.pendingEventList; + } + + /** + * Removes a pending event for this room + * + * @param {string} eventId + * @return {boolean} True if an element was removed. + */ + public removePendingEvent(eventId: string): boolean { + if (this.opts.pendingEventOrdering !== "detached") { + throw new Error( + "Cannot call removePendingEvent with pendingEventOrdering == " + + this.opts.pendingEventOrdering); + } + + const removed = utils.removeElement( + this.pendingEventList, + function(ev) { + return ev.getId() == eventId; + }, false, + ); + + this.savePendingEvents(); + + return removed; + } + + /** + * Check whether the pending event list contains a given event by ID. + * If pending event ordering is not "detached" then this returns false. + * + * @param {string} eventId The event ID to check for. + * @return {boolean} + */ + public hasPendingEvent(eventId: string): boolean { + if (this.opts.pendingEventOrdering !== "detached") { + return false; + } + + return this.pendingEventList.some(event => event.getId() === eventId); + } + + /** + * Get a specific event from the pending event list, if configured, null otherwise. + * + * @param {string} eventId The event ID to check for. + * @return {MatrixEvent} + */ + public getPendingEvent(eventId: string): MatrixEvent | null { + if (this.opts.pendingEventOrdering !== "detached") { + return null; + } + + return this.pendingEventList.find(event => event.getId() === eventId); + } + + /** + * Get the live unfiltered timeline for this room. + * + * @return {module:models/event-timeline~EventTimeline} live timeline + */ + public getLiveTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().getLiveTimeline(); + } + + /** + * Get the timestamp of the last message in the room + * + * @return {number} the timestamp of the last message in the room + */ + public getLastActiveTimestamp(): number { + const timeline = this.getLiveTimeline(); + const events = timeline.getEvents(); + if (events.length) { + const lastEvent = events[events.length - 1]; + return lastEvent.getTs(); + } else { + return Number.MIN_SAFE_INTEGER; + } + } + + /** + * @return {string} the membership type (join | leave | invite) for the logged in user + */ + public getMyMembership(): string { + return this.selfMembership; + } + + /** + * If this room is a DM we're invited to, + * try to find out who invited us + * @return {string} user id of the inviter + */ + public getDMInviter(): string { + if (this.myUserId) { + const me = this.getMember(this.myUserId); + if (me) { + return me.getDMInviter(); + } + } + if (this.selfMembership === "invite") { + // fall back to summary information + const memberCount = this.getInvitedAndJoinedMemberCount(); + if (memberCount == 2 && this.summaryHeroes.length) { + return this.summaryHeroes[0]; + } + } + } + + /** + * Assuming this room is a DM room, tries to guess with which user. + * @return {string} user id of the other member (could be syncing user) + */ + public guessDMUserId(): string { + const me = this.getMember(this.myUserId); + if (me) { + const inviterId = me.getDMInviter(); + if (inviterId) { + return inviterId; + } + } + // remember, we're assuming this room is a DM, + // so returning the first member we find should be fine + const hasHeroes = Array.isArray(this.summaryHeroes) && + this.summaryHeroes.length; + if (hasHeroes) { + return this.summaryHeroes[0]; + } + const members = this.currentState.getMembers(); + const anyMember = members.find((m) => m.userId !== this.myUserId); + if (anyMember) { + return anyMember.userId; + } + // it really seems like I'm the only user in the room + // so I probably created a room with just me in it + // and marked it as a DM. Ok then + return this.myUserId; + } + + public getAvatarFallbackMember(): RoomMember { + const memberCount = this.getInvitedAndJoinedMemberCount(); + if (memberCount > 2) { + return; + } + const hasHeroes = Array.isArray(this.summaryHeroes) && + this.summaryHeroes.length; + if (hasHeroes) { + const availableMember = this.summaryHeroes.map((userId) => { + return this.getMember(userId); + }).find((member) => !!member); + if (availableMember) { + return availableMember; + } + } + const members = this.currentState.getMembers(); + // could be different than memberCount + // as this includes left members + if (members.length <= 2) { + const availableMember = members.find((m) => { + return m.userId !== this.myUserId; + }); + if (availableMember) { + return availableMember; + } + } + // if all else fails, try falling back to a user, + // and create a one-off member for it + if (hasHeroes) { + const availableUser = this.summaryHeroes.map((userId) => { + return this.client.getUser(userId); + }).find((user) => !!user); + if (availableUser) { + const member = new RoomMember( + this.roomId, availableUser.userId); + member.user = availableUser; + return member; + } + } + } + + /** + * Sets the membership this room was received as during sync + * @param {string} membership join | leave | invite + */ + public updateMyMembership(membership: string): void { + const prevMembership = this.selfMembership; + this.selfMembership = membership; + if (prevMembership !== membership) { + if (membership === "leave") { + this.cleanupAfterLeaving(); + } + this.emit("Room.myMembership", this, membership, prevMembership); + } + } + + private async loadMembersFromServer(): Promise { + const lastSyncToken = this.client.store.getSyncToken(); + const queryString = utils.encodeParams({ + not_membership: "leave", + at: lastSyncToken, + }); + const path = utils.encodeUri("/rooms/$roomId/members?" + queryString, + { $roomId: this.roomId }); + const http = this.client.http; + const response = await http.authedRequest(undefined, "GET", path); + return response.chunk; + } + + private async loadMembers(): Promise<{ memberEvents: MatrixEvent[], fromServer: boolean }> { + // were the members loaded from the server? + let fromServer = false; + let rawMembersEvents = + await this.client.store.getOutOfBandMembers(this.roomId); + if (rawMembersEvents === null) { + fromServer = true; + rawMembersEvents = await this.loadMembersFromServer(); + logger.log(`LL: got ${rawMembersEvents.length} ` + + `members from server for room ${this.roomId}`); + } + const memberEvents = rawMembersEvents.map(this.client.getEventMapper()); + return { memberEvents, fromServer }; + } + + /** + * Preloads the member list in case lazy loading + * of memberships is in use. Can be called multiple times, + * it will only preload once. + * @return {Promise} when preloading is done and + * accessing the members on the room will take + * all members in the room into account + */ + public loadMembersIfNeeded(): Promise { + if (this.membersPromise) { + return this.membersPromise; + } + + // mark the state so that incoming messages while + // the request is in flight get marked as superseding + // the OOB members + this.currentState.markOutOfBandMembersStarted(); + + const inMemoryUpdate = this.loadMembers().then((result) => { + this.currentState.setOutOfBandMembers(result.memberEvents); + // now the members are loaded, start to track the e2e devices if needed + if (this.client.isCryptoEnabled() && this.client.isRoomEncrypted(this.roomId)) { + this.client.crypto.trackRoomDevices(this.roomId); + } + return result.fromServer; + }).catch((err) => { + // allow retries on fail + this.membersPromise = null; + this.currentState.markOutOfBandMembersFailed(); + throw err; + }); + // update members in storage, but don't wait for it + inMemoryUpdate.then((fromServer) => { + if (fromServer) { + const oobMembers = this.currentState.getMembers() + .filter((m) => m.isOutOfBand()) + .map((m) => m.events.member.event); + logger.log(`LL: telling store to write ${oobMembers.length}` + + ` members for room ${this.roomId}`); + const store = this.client.store; + return store.setOutOfBandMembers(this.roomId, oobMembers) + // swallow any IDB error as we don't want to fail + // because of this + .catch((err) => { + logger.log("LL: storing OOB room members failed, oh well", + err); + }); + } + }).catch((err) => { + // as this is not awaited anywhere, + // at least show the error in the console + logger.error(err); + }); + + this.membersPromise = inMemoryUpdate; + + return this.membersPromise; + } + + /** + * Removes the lazily loaded members from storage if needed + */ + public async clearLoadedMembersIfNeeded(): Promise { + if (this.opts.lazyLoadMembers && this.membersPromise) { + await this.loadMembersIfNeeded(); + await this.client.store.clearOutOfBandMembers(this.roomId); + this.currentState.clearOutOfBandMembers(); + this.membersPromise = null; + } + } + + /** + * called when sync receives this room in the leave section + * to do cleanup after leaving a room. Possibly called multiple times. + */ + private cleanupAfterLeaving(): void { + this.clearLoadedMembersIfNeeded().catch((err) => { + logger.error(`error after clearing loaded members from ` + + `room ${this.roomId} after leaving`); + logger.log(err); + }); + } + + /** + * Reset the live timeline of all timelineSets, and start new ones. + * + *

This is used when /sync returns a 'limited' timeline. + * + * @param {string=} backPaginationToken token for back-paginating the new timeline + * @param {string=} forwardPaginationToken token for forward-paginating the old live timeline, + * if absent or null, all timelines are reset, removing old ones (including the previous live + * timeline which would otherwise be unable to paginate forwards without this token). + * Removing just the old live timeline whilst preserving previous ones is not supported. + */ + public resetLiveTimeline(backPaginationToken: string, forwardPaginationToken: string): void { + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].resetLiveTimeline( + backPaginationToken, forwardPaginationToken, + ); + } + + this.fixUpLegacyTimelineFields(); + } + + /** + * Fix up this.timeline, this.oldState and this.currentState + * + * @private + */ + private fixUpLegacyTimelineFields(): void { + // maintain this.timeline as a reference to the live timeline, + // and this.oldState and this.currentState as references to the + // state at the start and end of that timeline. These are more + // for backwards-compatibility than anything else. + this.timeline = this.getLiveTimeline().getEvents(); + this.oldState = this.getLiveTimeline() + .getState(EventTimeline.BACKWARDS); + this.currentState = this.getLiveTimeline() + .getState(EventTimeline.FORWARDS); + } + + /** + * Returns whether there are any devices in the room that are unverified + * + * Note: Callers should first check if crypto is enabled on this device. If it is + * disabled, then we aren't tracking room devices at all, so we can't answer this, and an + * error will be thrown. + * + * @return {boolean} the result + */ + public async hasUnverifiedDevices(): Promise { + if (!this.client.isRoomEncrypted(this.roomId)) { + return false; + } + const e2eMembers = await this.getEncryptionTargetMembers(); + for (const member of e2eMembers) { + const devices = this.client.getStoredDevicesForUser(member.userId); + if (devices.some((device) => device.isUnverified())) { + return true; + } + } + return false; + } + + /** + * Return the timeline sets for this room. + * @return {EventTimelineSet[]} array of timeline sets for this room + */ + public getTimelineSets(): EventTimelineSet[] { + return this.timelineSets; + } + + /** + * Helper to return the main unfiltered timeline set for this room + * @return {EventTimelineSet} room's unfiltered timeline set + */ + public getUnfilteredTimelineSet(): EventTimelineSet { + return this.timelineSets[0]; + } + + /** + * Get the timeline which contains the given event from the unfiltered set, if any + * + * @param {string} eventId event ID to look for + * @return {?module:models/event-timeline~EventTimeline} timeline containing + * the given event, or null if unknown + */ + public getTimelineForEvent(eventId: string): EventTimeline { + return this.getUnfilteredTimelineSet().getTimelineForEvent(eventId); + } + + /** + * Add a new timeline to this room's unfiltered timeline set + * + * @return {module:models/event-timeline~EventTimeline} newly-created timeline + */ + public addTimeline(): EventTimeline { + return this.getUnfilteredTimelineSet().addTimeline(); + } + + /** + * Get an event which is stored in our unfiltered timeline set + * + * @param {string} eventId event ID to look for + * @return {?module:models/event.MatrixEvent} the given event, or undefined if unknown + */ + public findEventById(eventId: string): MatrixEvent | undefined { + return this.getUnfilteredTimelineSet().findEventById(eventId); + } + + /** + * Get one of the notification counts for this room + * @param {String} type The type of notification count to get. default: 'total' + * @return {Number} The notification count, or undefined if there is no count + * for this type. + */ + public getUnreadNotificationCount(type = NotificationCountType.Total): number | undefined { + return this.notificationCounts[type]; + } + + /** + * Set one of the notification counts for this room + * @param {String} type The type of notification count to set. + * @param {Number} count The new count + */ + public setUnreadNotificationCount(type: NotificationCountType, count: number): void { + this.notificationCounts[type] = count; + } + + public setSummary(summary: IRoomSummary): void { + const heroes = summary["m.heroes"]; + const joinedCount = summary["m.joined_member_count"]; + const invitedCount = summary["m.invited_member_count"]; + if (Number.isInteger(joinedCount)) { + this.currentState.setJoinedMemberCount(joinedCount); + } + if (Number.isInteger(invitedCount)) { + this.currentState.setInvitedMemberCount(invitedCount); + } + if (Array.isArray(heroes)) { + // be cautious about trusting server values, + // and make sure heroes doesn't contain our own id + // just to be sure + this.summaryHeroes = heroes.filter((userId) => { + return userId !== this.myUserId; + }); + } + } + + /** + * Whether to send encrypted messages to devices within this room. + * @param {Boolean} value true to blacklist unverified devices, null + * to use the global value for this room. + */ + public setBlacklistUnverifiedDevices(value: boolean): void { + this.blacklistUnverifiedDevices = value; + } + + /** + * Whether to send encrypted messages to devices within this room. + * @return {Boolean} true if blacklisting unverified devices, null + * if the global value should be used for this room. + */ + public getBlacklistUnverifiedDevices(): boolean { + return this.blacklistUnverifiedDevices; + } + + /** + * Get the avatar URL for a room if one was set. + * @param {String} baseUrl The homeserver base URL. See + * {@link module:client~MatrixClient#getHomeserverUrl}. + * @param {Number} width The desired width of the thumbnail. + * @param {Number} height The desired height of the thumbnail. + * @param {string} resizeMethod The thumbnail resize method to use, either + * "crop" or "scale". + * @param {boolean} allowDefault True to allow an identicon for this room if an + * avatar URL wasn't explicitly set. Default: true. (Deprecated) + * @return {?string} the avatar URL or null. + */ + public getAvatarUrl( + baseUrl: string, + width: number, + height: number, + resizeMethod: ResizeMethod, + allowDefault = true, + ): string | null { + const roomAvatarEvent = this.currentState.getStateEvents(EventType.RoomAvatar, ""); + if (!roomAvatarEvent && !allowDefault) { + return null; + } + + const mainUrl = roomAvatarEvent ? roomAvatarEvent.getContent().url : null; + if (mainUrl) { + return getHttpUriForMxc(baseUrl, mainUrl, width, height, resizeMethod); + } + + return null; + } + + /** + * Get the mxc avatar url for the room, if one was set. + * @return {string} the mxc avatar url or falsy + */ + public getMxcAvatarUrl(): string | null { + return this.currentState.getStateEvents(EventType.RoomAvatar, "")?.getContent()?.url || null; + } + + /** + * Get the aliases this room has according to the room's state + * The aliases returned by this function may not necessarily + * still point to this room. + * @return {array} The room's alias as an array of strings + */ + public getAliases(): string[] { + const aliasStrings = []; + + const aliasEvents = this.currentState.getStateEvents(EventType.RoomAliases); + if (aliasEvents) { + for (let i = 0; i < aliasEvents.length; ++i) { + const aliasEvent = aliasEvents[i]; + if (Array.isArray(aliasEvent.getContent().aliases)) { + const filteredAliases = aliasEvent.getContent().aliases.filter(a => { + if (typeof(a) !== "string") return false; + if (a[0] !== '#') return false; + if (!a.endsWith(`:${aliasEvent.getStateKey()}`)) return false; + + // It's probably valid by here. + return true; + }); + Array.prototype.push.apply(aliasStrings, filteredAliases); + } + } + } + return aliasStrings; + } + + /** + * Get this room's canonical alias + * The alias returned by this function may not necessarily + * still point to this room. + * @return {?string} The room's canonical alias, or null if there is none + */ + public getCanonicalAlias(): string | null { + const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alias || null; + } + return null; + } + + /** + * Get this room's alternative aliases + * @return {array} The room's alternative aliases, or an empty array + */ + public getAltAliases(): string[] { + const canonicalAlias = this.currentState.getStateEvents(EventType.RoomCanonicalAlias, ""); + if (canonicalAlias) { + return canonicalAlias.getContent().alt_aliases || []; + } + return []; + } + + /** + * Add events to a timeline + * + *

Will fire "Room.timeline" for each event added. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {boolean} toStartOfTimeline True to add these events to the start + * (oldest) instead of the end (newest) of the timeline. If true, the oldest + * event will be the last element of 'events'. + * + * @param {module:models/event-timeline~EventTimeline} timeline timeline to + * add events to. + * + * @param {string=} paginationToken token for the next batch of events + * + * @fires module:client~MatrixClient#event:"Room.timeline" + * + */ + public addEventsToTimeline( + events: MatrixEvent[], + toStartOfTimeline: boolean, + timeline: EventTimeline, + paginationToken?: string, + ): void { + timeline.getTimelineSet().addEventsToTimeline( + events, toStartOfTimeline, + timeline, paginationToken, + ); + } + + /** + * Get a member from the current room state. + * @param {string} userId The user ID of the member. + * @return {RoomMember} The member or null. + */ + public getMember(userId: string): RoomMember | null { + return this.currentState.getMember(userId); + } + + /** + * Get all currently loaded members from the current + * room state. + * @returns {RoomMember[]} Room members + */ + public getMembers(): RoomMember[] { + return this.currentState.getMembers(); + } + + /** + * Get a list of members whose membership state is "join". + * @return {RoomMember[]} A list of currently joined members. + */ + public getJoinedMembers(): RoomMember[] { + return this.getMembersWithMembership("join"); + } + + /** + * Returns the number of joined members in this room + * This method caches the result. + * This is a wrapper around the method of the same name in roomState, returning + * its result for the room's current state. + * @return {number} The number of members in this room whose membership is 'join' + */ + public getJoinedMemberCount(): number { + return this.currentState.getJoinedMemberCount(); + } + + /** + * Returns the number of invited members in this room + * @return {number} The number of members in this room whose membership is 'invite' + */ + public getInvitedMemberCount(): number { + return this.currentState.getInvitedMemberCount(); + } + + /** + * Returns the number of invited + joined members in this room + * @return {number} The number of members in this room whose membership is 'invite' or 'join' + */ + public getInvitedAndJoinedMemberCount(): number { + return this.getInvitedMemberCount() + this.getJoinedMemberCount(); + } + + /** + * Get a list of members with given membership state. + * @param {string} membership The membership state. + * @return {RoomMember[]} A list of members with the given membership state. + */ + public getMembersWithMembership(membership: string): RoomMember[] { + return this.currentState.getMembers().filter(function(m) { + return m.membership === membership; + }); + } + + /** + * Get a list of members we should be encrypting for in this room + * @return {Promise} A list of members who + * we should encrypt messages for in this room. + */ + public async getEncryptionTargetMembers(): Promise { + await this.loadMembersIfNeeded(); + let members = this.getMembersWithMembership("join"); + if (this.shouldEncryptForInvitedMembers()) { + members = members.concat(this.getMembersWithMembership("invite")); + } + return members; + } + + /** + * Determine whether we should encrypt messages for invited users in this room + * @return {boolean} if we should encrypt messages for invited users + */ + public shouldEncryptForInvitedMembers(): boolean { + const ev = this.currentState.getStateEvents(EventType.RoomHistoryVisibility, ""); + return ev?.getContent()?.history_visibility !== "joined"; + } + + /** + * Get the default room name (i.e. what a given user would see if the + * room had no m.room.name) + * @param {string} userId The userId from whose perspective we want + * to calculate the default name + * @return {string} The default room name + */ + public getDefaultRoomName(userId: string): string { + return this.calculateRoomName(userId, true); + } + + /** + * Check if the given user_id has the given membership state. + * @param {string} userId The user ID to check. + * @param {string} membership The membership e.g. 'join' + * @return {boolean} True if this user_id has the given membership state. + */ + public hasMembershipState(userId: string, membership: string): boolean { + const member = this.getMember(userId); + if (!member) { + return false; + } + return member.membership === membership; + } + + /** + * Add a timelineSet for this room with the given filter + * @param {Filter} filter The filter to be applied to this timelineSet + * @return {EventTimelineSet} The timelineSet + */ + public getOrCreateFilteredTimelineSet(filter: Filter): EventTimelineSet { + if (this.filteredTimelineSets[filter.filterId]) { + return this.filteredTimelineSets[filter.filterId]; + } + const opts = Object.assign({ filter: filter }, this.opts); + const timelineSet = new EventTimelineSet(this, opts); + this.reEmitter.reEmit(timelineSet, ["Room.timeline", "Room.timelineReset"]); + this.filteredTimelineSets[filter.filterId] = timelineSet; + this.timelineSets.push(timelineSet); + + // populate up the new timelineSet with filtered events from our live + // unfiltered timeline. + // + // XXX: This is risky as our timeline + // may have grown huge and so take a long time to filter. + // see https://github.com/vector-im/vector-web/issues/2109 + + const unfilteredLiveTimeline = this.getLiveTimeline(); + + unfilteredLiveTimeline.getEvents().forEach(function(event) { + timelineSet.addLiveEvent(event); + }); + + // find the earliest unfiltered timeline + let timeline = unfilteredLiveTimeline; + while (timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS)) { + timeline = timeline.getNeighbouringTimeline(EventTimeline.BACKWARDS); + } + + timelineSet.getLiveTimeline().setPaginationToken( + timeline.getPaginationToken(EventTimeline.BACKWARDS), + EventTimeline.BACKWARDS, + ); + + // alternatively, we could try to do something like this to try and re-paginate + // in the filtered events from nothing, but Mark says it's an abuse of the API + // to do so: + // + // timelineSet.resetLiveTimeline( + // unfilteredLiveTimeline.getPaginationToken(EventTimeline.FORWARDS) + // ); + + return timelineSet; + } + + /** + * Forget the timelineSet for this room with the given filter + * + * @param {Filter} filter the filter whose timelineSet is to be forgotten + */ + public removeFilteredTimelineSet(filter: Filter): void { + const timelineSet = this.filteredTimelineSets[filter.filterId]; + delete this.filteredTimelineSets[filter.filterId]; + const i = this.timelineSets.indexOf(timelineSet); + if (i > -1) { + this.timelineSets.splice(i, 1); + } + } + + /** + * Add an event to the end of this room's live timelines. Will fire + * "Room.timeline". + * + * @param {MatrixEvent} event Event to be added + * @param {string?} duplicateStrategy 'ignore' or 'replace' + * @param {boolean} fromCache whether the sync response came from cache + * @fires module:client~MatrixClient#event:"Room.timeline" + * @private + */ + private addLiveEvent(event: MatrixEvent, duplicateStrategy?: "ignore" | "replace", fromCache = false): void { + if (event.isRedaction()) { + const redactId = event.event.redacts; + + // if we know about this event, redact its contents now. + const redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + if (redactedEvent) { + redactedEvent.makeRedacted(event); + + // If this is in the current state, replace it with the redacted version + if (redactedEvent.getStateKey()) { + const currentStateEvent = this.currentState.getStateEvents( + redactedEvent.getType(), + redactedEvent.getStateKey(), + ); + if (currentStateEvent.getId() === redactedEvent.getId()) { + this.currentState.setStateEvents([redactedEvent]); + } + } + + this.emit("Room.redaction", event, this); + + // TODO: we stash user displaynames (among other things) in + // RoomMember objects which are then attached to other events + // (in the sender and target fields). We should get those + // RoomMember objects to update themselves when the events that + // they are based on are changed. + } + + // FIXME: apply redactions to notification list + + // NB: We continue to add the redaction event to the timeline so + // clients can say "so and so redacted an event" if they wish to. Also + // this may be needed to trigger an update. + } + + if (event.getUnsigned().transaction_id) { + const existingEvent = this.txnToEvent[event.getUnsigned().transaction_id]; + if (existingEvent) { + // remote echo of an event we sent earlier + this.handleRemoteEcho(event, existingEvent); + return; + } + } + + // add to our timeline sets + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].addLiveEvent(event, duplicateStrategy, fromCache); + } + + // synthesize and inject implicit read receipts + // Done after adding the event because otherwise the app would get a read receipt + // pointing to an event that wasn't yet in the timeline + // Don't synthesize RR for m.room.redaction as this causes the RR to go missing. + if (event.sender && event.getType() !== EventType.RoomRedaction) { + this.addReceipt(synthesizeReceipt( + event.sender.userId, event, "m.read", + ), true); + + // Any live events from a user could be taken as implicit + // presence information: evidence that they are currently active. + // ...except in a world where we use 'user.currentlyActive' to reduce + // presence spam, this isn't very useful - we'll get a transition when + // they are no longer currently active anyway. So don't bother to + // reset the lastActiveAgo and lastPresenceTs from the RoomState's user. + } + } + + /** + * Add a pending outgoing event to this room. + * + *

The event is added to either the pendingEventList, or the live timeline, + * depending on the setting of opts.pendingEventOrdering. + * + *

This is an internal method, intended for use by MatrixClient. + * + * @param {module:models/event.MatrixEvent} event The event to add. + * + * @param {string} txnId Transaction id for this outgoing event + * + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * + * @throws if the event doesn't have status SENDING, or we aren't given a + * unique transaction id. + */ + public addPendingEvent(event: MatrixEvent, txnId: string): void { + if (event.status !== EventStatus.SENDING && event.status !== EventStatus.NOT_SENT) { + throw new Error("addPendingEvent called on an event with status " + + event.status); + } + + if (this.txnToEvent[txnId]) { + throw new Error("addPendingEvent called on an event with known txnId " + + txnId); + } + + // call setEventMetadata to set up event.sender etc + // as event is shared over all timelineSets, we set up its metadata based + // on the unfiltered timelineSet. + EventTimeline.setEventMetadata(event, this.getLiveTimeline().getState(EventTimeline.FORWARDS), false); + + this.txnToEvent[txnId] = event; + + if (this.opts.pendingEventOrdering == "detached") { + if (this.pendingEventList.some((e) => e.status === EventStatus.NOT_SENT)) { + logger.warn("Setting event as NOT_SENT due to messages in the same state"); + event.setStatus(EventStatus.NOT_SENT); + } + this.pendingEventList.push(event); + this.savePendingEvents(); + if (event.isRelation()) { + // For pending events, add them to the relations collection immediately. + // (The alternate case below already covers this as part of adding to + // the timeline set.) + this.aggregateNonLiveRelation(event); + } + + if (event.isRedaction()) { + const redactId = event.event.redacts; + let redactedEvent = this.pendingEventList && + this.pendingEventList.find(e => e.getId() === redactId); + if (!redactedEvent) { + redactedEvent = this.getUnfilteredTimelineSet().findEventById(redactId); + } + if (redactedEvent) { + redactedEvent.markLocallyRedacted(event); + this.emit("Room.redaction", event, this); + } + } + } else { + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } else { + timelineSet.addEventToTimeline(event, + timelineSet.getLiveTimeline(), false); + } + } + } + + this.emit("Room.localEchoUpdated", event, this, null, null); + } + + /** + * Persists all pending events to local storage + * + * If the current room is encrypted only encrypted events will be persisted + * all messages that are not yet encrypted will be discarded + * + * This is because the flow of EVENT_STATUS transition is + * queued => sending => encrypting => sending => sent + * + * Steps 3 and 4 are skipped for unencrypted room. + * It is better to discard an unencrypted message rather than persisting + * it locally for everyone to read + */ + private savePendingEvents(): void { + if (this.pendingEventList) { + const pendingEvents = this.pendingEventList.map(event => { + return { + ...event.event, + txn_id: event.getTxnId(), + }; + }).filter(event => { + // Filter out the unencrypted messages if the room is encrypted + const isEventEncrypted = event.type === EventType.RoomMessageEncrypted; + const isRoomEncrypted = this.client.isRoomEncrypted(this.roomId); + return isEventEncrypted || !isRoomEncrypted; + }); + + const { store } = this.client.sessionStore; + if (this.pendingEventList.length > 0) { + store.setItem( + pendingEventsKey(this.roomId), + JSON.stringify(pendingEvents), + ); + } else { + store.removeItem(pendingEventsKey(this.roomId)); + } + } + } + + /** + * Used to aggregate the local echo for a relation, and also + * for re-applying a relation after it's redaction has been cancelled, + * as the local echo for the redaction of the relation would have + * un-aggregated the relation. Note that this is different from regular messages, + * which are just kept detached for their local echo. + * + * Also note that live events are aggregated in the live EventTimelineSet. + * @param {module:models/event.MatrixEvent} event the relation event that needs to be aggregated. + */ + private aggregateNonLiveRelation(event: MatrixEvent): void { + // TODO: We should consider whether this means it would be a better + // design to lift the relations handling up to the room instead. + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + if (timelineSet.getFilter()) { + if (timelineSet.getFilter().filterRoomTimeline([event]).length) { + timelineSet.aggregateRelations(event); + } + } else { + timelineSet.aggregateRelations(event); + } + } + } + + /** + * Deal with the echo of a message we sent. + * + *

We move the event to the live timeline if it isn't there already, and + * update it. + * + * @param {module:models/event.MatrixEvent} remoteEvent The event received from + * /sync + * @param {module:models/event.MatrixEvent} localEvent The local echo, which + * should be either in the pendingEventList or the timeline. + * + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + * @private + */ + private handleRemoteEcho(remoteEvent: MatrixEvent, localEvent: MatrixEvent): void { + const oldEventId = localEvent.getId(); + const newEventId = remoteEvent.getId(); + const oldStatus = localEvent.status; + + logger.debug( + `Got remote echo for event ${oldEventId} -> ${newEventId} ` + + `old status ${oldStatus}`, + ); + + // no longer pending + delete this.txnToEvent[remoteEvent.getUnsigned().transaction_id]; + + // if it's in the pending list, remove it + if (this.pendingEventList) { + this.removePendingEvent(oldEventId); + } + + // replace the event source (this will preserve the plaintext payload if + // any, which is good, because we don't want to try decoding it again). + localEvent.handleRemoteEcho(remoteEvent.event); + + for (let i = 0; i < this.timelineSets.length; i++) { + const timelineSet = this.timelineSets[i]; + + // if it's already in the timeline, update the timeline map. If it's not, add it. + timelineSet.handleRemoteEcho(localEvent, oldEventId, newEventId); + } + + this.emit("Room.localEchoUpdated", localEvent, this, + oldEventId, oldStatus); + } + + /** + * Update the status / event id on a pending event, to reflect its transmission + * progress. + * + *

This is an internal method. + * + * @param {MatrixEvent} event local echo event + * @param {EventStatus} newStatus status to assign + * @param {string} newEventId new event id to assign. Ignored unless + * newStatus == EventStatus.SENT. + * @fires module:client~MatrixClient#event:"Room.localEchoUpdated" + */ + public updatePendingEvent(event: MatrixEvent, newStatus: EventStatus, newEventId?: string): void { + logger.log( + `setting pendingEvent status to ${newStatus} in ${event.getRoomId()} ` + + `event ID ${event.getId()} -> ${newEventId}`, + ); + + // if the message was sent, we expect an event id + if (newStatus == EventStatus.SENT && !newEventId) { + throw new Error("updatePendingEvent called with status=SENT, " + + "but no new event id"); + } + + // SENT races against /sync, so we have to special-case it. + if (newStatus == EventStatus.SENT) { + const timeline = this.getUnfilteredTimelineSet().eventIdToTimeline(newEventId); + if (timeline) { + // we've already received the event via the event stream. + // nothing more to do here. + return; + } + } + + const oldStatus = event.status; + const oldEventId = event.getId(); + + if (!oldStatus) { + throw new Error("updatePendingEventStatus called on an event which is " + + "not a local echo."); + } + + const allowed = ALLOWED_TRANSITIONS[oldStatus]; + if (!allowed || allowed.indexOf(newStatus) < 0) { + throw new Error("Invalid EventStatus transition " + oldStatus + "->" + + newStatus); + } + + event.setStatus(newStatus); + + if (newStatus == EventStatus.SENT) { + // update the event id + event.replaceLocalEventId(newEventId); + + // if the event was already in the timeline (which will be the case if + // opts.pendingEventOrdering==chronological), we need to update the + // timeline map. + for (let i = 0; i < this.timelineSets.length; i++) { + this.timelineSets[i].replaceEventId(oldEventId, newEventId); + } + } else if (newStatus == EventStatus.CANCELLED) { + // remove it from the pending event list, or the timeline. + if (this.pendingEventList) { + const idx = this.pendingEventList.findIndex(ev => ev.getId() === oldEventId); + if (idx !== -1) { + const [removedEvent] = this.pendingEventList.splice(idx, 1); + if (removedEvent.isRedaction()) { + this.revertRedactionLocalEcho(removedEvent); + } + } + } + this.removeEvent(oldEventId); + } + this.savePendingEvents(); + + this.emit("Room.localEchoUpdated", event, this, oldEventId, oldStatus); + } + + private revertRedactionLocalEcho(redactionEvent: MatrixEvent): void { + const redactId = redactionEvent.event.redacts; + if (!redactId) { + return; + } + const redactedEvent = this.getUnfilteredTimelineSet() + .findEventById(redactId); + if (redactedEvent) { + redactedEvent.unmarkLocallyRedacted(); + // re-render after undoing redaction + this.emit("Room.redactionCancelled", redactionEvent, this); + // reapply relation now redaction failed + if (redactedEvent.isRelation()) { + this.aggregateNonLiveRelation(redactedEvent); + } + } + } + + /** + * Add some events to this room. This can include state events, message + * events and typing notifications. These events are treated as "live" so + * they will go to the end of the timeline. + * + * @param {MatrixEvent[]} events A list of events to add. + * + * @param {string} duplicateStrategy Optional. Applies to events in the + * timeline only. If this is 'replace' then if a duplicate is encountered, the + * event passed to this function will replace the existing event in the + * timeline. If this is not specified, or is 'ignore', then the event passed to + * this function will be ignored entirely, preserving the existing event in the + * timeline. Events are identical based on their event ID only. + * + * @param {boolean} fromCache whether the sync response came from cache + * @throws If duplicateStrategy is not falsey, 'replace' or 'ignore'. + */ + public addLiveEvents(events: MatrixEvent[], duplicateStrategy?: "replace" | "ignore", fromCache = false): void { + let i; + if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) { + throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'"); + } + + // sanity check that the live timeline is still live + for (i = 0; i < this.timelineSets.length; i++) { + const liveTimeline = this.timelineSets[i].getLiveTimeline(); + if (liveTimeline.getPaginationToken(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - it has a pagination token " + + "(" + liveTimeline.getPaginationToken(EventTimeline.FORWARDS) + ")", + ); + } + if (liveTimeline.getNeighbouringTimeline(EventTimeline.FORWARDS)) { + throw new Error( + "live timeline " + i + " is no longer live - " + + "it has a neighbouring timeline", + ); + } + } + + for (i = 0; i < events.length; i++) { + // TODO: We should have a filter to say "only add state event + // types X Y Z to the timeline". + this.addLiveEvent(events[i], duplicateStrategy, fromCache); + } + } + + /** + * Adds/handles ephemeral events such as typing notifications and read receipts. + * @param {MatrixEvent[]} events A list of events to process + */ + public addEphemeralEvents(events: MatrixEvent[]): void { + for (const event of events) { + if (event.getType() === 'm.typing') { + this.currentState.setTypingEvent(event); + } else if (event.getType() === 'm.receipt') { + this.addReceipt(event); + } // else ignore - life is too short for us to care about these events + } + } + + /** + * Removes events from this room. + * @param {String[]} eventIds A list of eventIds to remove. + */ + public removeEvents(eventIds: string[]): void { + for (let i = 0; i < eventIds.length; ++i) { + this.removeEvent(eventIds[i]); + } + } + + /** + * Removes a single event from this room. + * + * @param {String} eventId The id of the event to remove + * + * @return {boolean} true if the event was removed from any of the room's timeline sets + */ + public removeEvent(eventId: string): boolean { + let removedAny = false; + for (let i = 0; i < this.timelineSets.length; i++) { + const removed = this.timelineSets[i].removeEvent(eventId); + if (removed) { + if (removed.isRedaction()) { + this.revertRedactionLocalEcho(removed); + } + removedAny = true; + } + } + return removedAny; + } + + /** + * Recalculate various aspects of the room, including the room name and + * room summary. Call this any time the room's current state is modified. + * May fire "Room.name" if the room name is updated. + * @fires module:client~MatrixClient#event:"Room.name" + */ + public recalculate(): void { + // set fake stripped state events if this is an invite room so logic remains + // consistent elsewhere. + const membershipEvent = this.currentState.getStateEvents(EventType.RoomMember, this.myUserId); + if (membershipEvent && membershipEvent.getContent().membership === "invite") { + const strippedStateEvents = membershipEvent.getUnsigned().invite_room_state || []; + strippedStateEvents.forEach((strippedEvent) => { + const existingEvent = this.currentState.getStateEvents(strippedEvent.type, strippedEvent.state_key); + if (!existingEvent) { + // set the fake stripped event instead + this.currentState.setStateEvents([new MatrixEvent({ + type: strippedEvent.type, + state_key: strippedEvent.state_key, + content: strippedEvent.content, + event_id: "$fake" + Date.now(), + room_id: this.roomId, + user_id: this.myUserId, // technically a lie + })]); + } + }); + } + + const oldName = this.name; + this.name = this.calculateRoomName(this.myUserId); + this.normalizedName = normalize(this.name); + this.summary = new RoomSummary(this.roomId, { + title: this.name, + }); + + if (oldName !== this.name) { + this.emit("Room.name", this); + } + } + + /** + * Get a list of user IDs who have read up to the given event. + * @param {MatrixEvent} event the event to get read receipts for. + * @return {String[]} A list of user IDs. + */ + public getUsersReadUpTo(event: MatrixEvent): string[] { + return this.getReceiptsForEvent(event).filter(function(receipt) { + return receipt.type === "m.read"; + }).map(function(receipt) { + return receipt.userId; + }); + } + + /** + * Get the ID of the event that a given user has read up to, or null if we + * have received no read receipts from them. + * @param {String} userId The user ID to get read receipt event ID for + * @param {Boolean} ignoreSynthesized If true, return only receipts that have been + * sent by the server, not implicit ones generated + * by the JS SDK. + * @return {String} ID of the latest event that the given user has read, or null. + */ + public getEventReadUpTo(userId: string, ignoreSynthesized = false): string | null { + let receipts = this.receipts; + if (ignoreSynthesized) { + receipts = this.realReceipts; + } + + if ( + receipts["m.read"] === undefined || + receipts["m.read"][userId] === undefined + ) { + return null; + } + + return receipts["m.read"][userId].eventId; + } + + /** + * Determines if the given user has read a particular event ID with the known + * history of the room. This is not a definitive check as it relies only on + * what is available to the room at the time of execution. + * @param {String} userId The user ID to check the read state of. + * @param {String} eventId The event ID to check if the user read. + * @returns {Boolean} True if the user has read the event, false otherwise. + */ + public hasUserReadEvent(userId: string, eventId: string): boolean { + const readUpToId = this.getEventReadUpTo(userId, false); + if (readUpToId === eventId) return true; + + if (this.timeline.length + && this.timeline[this.timeline.length - 1].getSender() + && this.timeline[this.timeline.length - 1].getSender() === userId) { + // It doesn't matter where the event is in the timeline, the user has read + // it because they've sent the latest event. + return true; + } + + for (let i = this.timeline.length - 1; i >= 0; --i) { + const ev = this.timeline[i]; + + // If we encounter the target event first, the user hasn't read it + // however if we encounter the readUpToId first then the user has read + // it. These rules apply because we're iterating bottom-up. + if (ev.getId() === eventId) return false; + if (ev.getId() === readUpToId) return true; + } + + // We don't know if the user has read it, so assume not. + return false; + } + + /** + * Get a list of receipts for the given event. + * @param {MatrixEvent} event the event to get receipts for + * @return {Object[]} A list of receipts with a userId, type and data keys or + * an empty list. + */ + public getReceiptsForEvent(event: MatrixEvent): ICachedReceipt[] { + return this.receiptCacheByEventId[event.getId()] || []; + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Boolean} fake True if this event is implicit + */ + public addReceipt(event: MatrixEvent, fake = false): void { + if (!fake) { + this.addReceiptsToStructure(event, this.realReceipts); + // we don't bother caching real receipts by event ID + // as there's nothing that would read it. + } + this.addReceiptsToStructure(event, this.receipts); + this.receiptCacheByEventId = this.buildReceiptCache(this.receipts); + + // send events after we've regenerated the cache, otherwise things that + // listened for the event would read from a stale cache + this.emit("Room.receipt", event, this); + } + + /** + * Add a receipt event to the room. + * @param {MatrixEvent} event The m.receipt event. + * @param {Object} receipts The object to add receipts to + */ + private addReceiptsToStructure(event: MatrixEvent, receipts: Receipts): void { + const content = event.getContent(); + Object.keys(content).forEach((eventId) => { + Object.keys(content[eventId]).forEach((receiptType) => { + Object.keys(content[eventId][receiptType]).forEach((userId) => { + const receipt = content[eventId][receiptType][userId]; + + if (!receipts[receiptType]) { + receipts[receiptType] = {}; + } + + const existingReceipt = receipts[receiptType][userId]; + + if (!existingReceipt) { + receipts[receiptType][userId] = {} as IWrappedReceipt; + } else { + // we only want to add this receipt if we think it is later + // than the one we already have. (This is managed + // server-side, but because we synthesize RRs locally we + // have to do it here too.) + const ordering = this.getUnfilteredTimelineSet().compareEventOrdering( + existingReceipt.eventId, eventId); + if (ordering !== null && ordering >= 0) { + return; + } + } + + receipts[receiptType][userId] = { + eventId: eventId, + data: receipt, + }; + }); + }); + }); + } + + /** + * Build and return a map of receipts by event ID + * @param {Object} receipts A map of receipts + * @return {Object} Map of receipts by event ID + */ + private buildReceiptCache(receipts: Receipts): ReceiptCache { + const receiptCacheByEventId = {}; + Object.keys(receipts).forEach(function(receiptType) { + Object.keys(receipts[receiptType]).forEach(function(userId) { + const receipt = receipts[receiptType][userId]; + if (!receiptCacheByEventId[receipt.eventId]) { + receiptCacheByEventId[receipt.eventId] = []; + } + receiptCacheByEventId[receipt.eventId].push({ + userId: userId, + type: receiptType, + data: receipt.data, + }); + }); + }); + return receiptCacheByEventId; + } + + /** + * Add a temporary local-echo receipt to the room to reflect in the + * client the fact that we've sent one. + * @param {string} userId The user ID if the receipt sender + * @param {MatrixEvent} e The event that is to be acknowledged + * @param {string} receiptType The type of receipt + */ + public addLocalEchoReceipt(userId: string, e: MatrixEvent, receiptType: string): void { + this.addReceipt(synthesizeReceipt(userId, e, receiptType), true); + } + + /** + * Update the room-tag event for the room. The previous one is overwritten. + * @param {MatrixEvent} event the m.tag event + */ + public addTags(event: MatrixEvent): void { + // event content looks like: + // content: { + // tags: { + // $tagName: { $metadata: $value }, + // $tagName: { $metadata: $value }, + // } + // } + + // XXX: do we need to deep copy here? + this.tags = event.getContent().tags || {}; + + // XXX: we could do a deep-comparison to see if the tags have really + // changed - but do we want to bother? + this.emit("Room.tags", event, this); + } + + /** + * Update the account_data events for this room, overwriting events of the same type. + * @param {Array} events an array of account_data events to add + */ + public addAccountData(events: MatrixEvent[]): void { + for (let i = 0; i < events.length; i++) { + const event = events[i]; + if (event.getType() === "m.tag") { + this.addTags(event); + } + const lastEvent = this.accountData[event.getType()]; + this.accountData[event.getType()] = event; + this.emit("Room.accountData", event, this, lastEvent); + } + } + + /** + * Access account_data event of given event type for this room + * @param {string} type the type of account_data event to be accessed + * @return {?MatrixEvent} the account_data event in question + */ + public getAccountData(type: EventType | string): MatrixEvent | undefined { + return this.accountData[type]; + } + + /** + * Returns whether the syncing user has permission to send a message in the room + * @return {boolean} true if the user should be permitted to send + * message events into the room. + */ + public maySendMessage(): boolean { + return this.getMyMembership() === 'join' && + this.currentState.maySendEvent(EventType.RoomMessage, this.myUserId); + } + + /** + * Returns whether the given user has permissions to issue an invite for this room. + * @param {string} userId the ID of the Matrix user to check permissions for + * @returns {boolean} true if the user should be permitted to issue invites for this room. + */ + public canInvite(userId: string): boolean { + let canInvite = this.getMyMembership() === "join"; + const powerLevelsEvent = this.currentState.getStateEvents(EventType.RoomPowerLevels, ""); + const powerLevels = powerLevelsEvent && powerLevelsEvent.getContent(); + const me = this.getMember(userId); + if (powerLevels && me && powerLevels.invite > me.powerLevel) { + canInvite = false; + } + return canInvite; + } + + /** + * Returns the join rule based on the m.room.join_rule state event, defaulting to `invite`. + * @returns {string} the join_rule applied to this room + */ + public getJoinRule(): string { + return this.currentState.getJoinRule(); + } + + /** + * Returns the type of the room from the `m.room.create` event content or undefined if none is set + * @returns {?string} the type of the room. Currently only RoomType.Space is known. + */ + public getType(): RoomType | string | undefined { + const createEvent = this.currentState.getStateEvents(EventType.RoomCreate, ""); + if (!createEvent) { + if (!this.getTypeWarning) { + logger.warn("[getType] Room " + this.roomId + " does not have an m.room.create event"); + this.getTypeWarning = true; + } + return undefined; + } + return createEvent.getContent()[RoomCreateTypeField]; + } + + /** + * Returns whether the room is a space-room as defined by MSC1772. + * @returns {boolean} true if the room's type is RoomType.Space + */ + public isSpaceRoom(): boolean { + return this.getType() === RoomType.Space; + } + + /** + * This is an internal method. Calculates the name of the room from the current + * room state. + * @param {string} userId The client's user ID. Used to filter room members + * correctly. + * @param {boolean} ignoreRoomNameEvent Return the implicit room name that we'd see if there + * was no m.room.name event. + * @return {string} The calculated room name. + */ + private calculateRoomName(userId: string, ignoreRoomNameEvent = false): string { + if (!ignoreRoomNameEvent) { + // check for an alias, if any. for now, assume first alias is the + // official one. + const mRoomName = this.currentState.getStateEvents(EventType.RoomName, ""); + if (mRoomName && mRoomName.getContent() && mRoomName.getContent().name) { + return mRoomName.getContent().name; + } + } + + let alias = this.getCanonicalAlias(); + + if (!alias) { + const aliases = this.getAltAliases(); + + if (aliases.length) { + alias = aliases[0]; + } + } + if (alias) { + return alias; + } + + const joinedMemberCount = this.currentState.getJoinedMemberCount(); + const invitedMemberCount = this.currentState.getInvitedMemberCount(); + // -1 because these numbers include the syncing user + const inviteJoinCount = joinedMemberCount + invitedMemberCount - 1; + + // get members that are NOT ourselves and are actually in the room. + let otherNames = null; + if (this.summaryHeroes) { + // if we have a summary, the member state events + // should be in the room state + otherNames = this.summaryHeroes.map((userId) => { + const member = this.getMember(userId); + return member ? member.name : userId; + }); + } else { + let otherMembers = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + (m.membership === "invite" || m.membership === "join"); + }); + // make sure members have stable order + otherMembers.sort((a, b) => a.userId.localeCompare(b.userId)); + // only 5 first members, immitate summaryHeroes + otherMembers = otherMembers.slice(0, 5); + otherNames = otherMembers.map((m) => m.name); + } + + if (inviteJoinCount) { + return memberNamesToRoomName(otherNames, inviteJoinCount); + } + + const myMembership = this.getMyMembership(); + // if I have created a room and invited people throuh + // 3rd party invites + if (myMembership == 'join') { + const thirdPartyInvites = + this.currentState.getStateEvents(EventType.RoomThirdPartyInvite); + + if (thirdPartyInvites && thirdPartyInvites.length) { + const thirdPartyNames = thirdPartyInvites.map((i) => { + return i.getContent().display_name; + }); + + return `Inviting ${memberNamesToRoomName(thirdPartyNames)}`; + } + } + // let's try to figure out who was here before + let leftNames = otherNames; + // if we didn't have heroes, try finding them in the room state + if (!leftNames.length) { + leftNames = this.currentState.getMembers().filter((m) => { + return m.userId !== userId && + m.membership !== "invite" && + m.membership !== "join"; + }).map((m) => m.name); + } + if (leftNames.length) { + return `Empty room (was ${memberNamesToRoomName(leftNames)})`; + } else { + return "Empty room"; + } + } +} + +/** + * @param {string} roomId ID of the current room + * @returns {string} Storage key to retrieve pending events + */ +function pendingEventsKey(roomId: string): string { + return `mx_pending_events_${roomId}`; +} + +/* a map from current event status to a list of allowed next statuses + */ +const ALLOWED_TRANSITIONS = {}; + +ALLOWED_TRANSITIONS[EventStatus.ENCRYPTING] = [ + EventStatus.SENDING, + EventStatus.NOT_SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.SENDING] = [ + EventStatus.ENCRYPTING, + EventStatus.QUEUED, + EventStatus.NOT_SENT, + EventStatus.SENT, +]; + +ALLOWED_TRANSITIONS[EventStatus.QUEUED] = + [EventStatus.SENDING, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.SENT] = + []; + +ALLOWED_TRANSITIONS[EventStatus.NOT_SENT] = + [EventStatus.SENDING, EventStatus.QUEUED, EventStatus.CANCELLED]; + +ALLOWED_TRANSITIONS[EventStatus.CANCELLED] = + []; + +// TODO i18n +function memberNamesToRoomName(names: string[], count = (names.length + 1)) { + const countWithoutMe = count - 1; + if (!names.length) { + return "Empty room"; + } else if (names.length === 1 && countWithoutMe <= 1) { + return names[0]; + } else if (names.length === 2 && countWithoutMe <= 2) { + return `${names[0]} and ${names[1]}`; + } else { + const plural = countWithoutMe > 1; + if (plural) { + return `${names[0]} and ${countWithoutMe} others`; + } else { + return `${names[0]} and 1 other`; + } + } +} + +/** + * Fires when an event we had previously received is redacted. + * + * (Note this is *not* fired when the redaction happens before we receive the + * event). + * + * @event module:client~MatrixClient#"Room.redaction" + * @param {MatrixEvent} event The matrix redaction event + * @param {Room} room The room containing the redacted event + */ + +/** + * Fires when an event that was previously redacted isn't anymore. + * This happens when the redaction couldn't be sent and + * was subsequently cancelled by the user. Redactions have a local echo + * which is undone in this scenario. + * + * @event module:client~MatrixClient#"Room.redactionCancelled" + * @param {MatrixEvent} event The matrix redaction event that was cancelled. + * @param {Room} room The room containing the unredacted event + */ + +/** + * Fires whenever the name of a room is updated. + * @event module:client~MatrixClient#"Room.name" + * @param {Room} room The room whose Room.name was updated. + * @example + * matrixClient.on("Room.name", function(room){ + * var newName = room.name; + * }); + */ + +/** + * Fires whenever a receipt is received for a room + * @event module:client~MatrixClient#"Room.receipt" + * @param {event} event The receipt event + * @param {Room} room The room whose receipts was updated. + * @example + * matrixClient.on("Room.receipt", function(event, room){ + * var receiptContent = event.getContent(); + * }); + */ + +/** + * Fires whenever a room's tags are updated. + * @event module:client~MatrixClient#"Room.tags" + * @param {event} event The tags event + * @param {Room} room The room whose Room.tags was updated. + * @example + * matrixClient.on("Room.tags", function(event, room){ + * var newTags = event.getContent().tags; + * if (newTags["favourite"]) showStar(room); + * }); + */ + +/** + * Fires whenever a room's account_data is updated. + * @event module:client~MatrixClient#"Room.accountData" + * @param {event} event The account_data event + * @param {Room} room The room whose account_data was updated. + * @param {MatrixEvent} prevEvent The event being replaced by + * the new account data, if known. + * @example + * matrixClient.on("Room.accountData", function(event, room, oldEvent){ + * if (event.getType() === "m.room.colorscheme") { + * applyColorScheme(event.getContents()); + * } + * }); + */ + +/** + * Fires when the status of a transmitted event is updated. + * + *

When an event is first transmitted, a temporary copy of the event is + * inserted into the timeline, with a temporary event id, and a status of + * 'SENDING'. + * + *

Once the echo comes back from the server, the content of the event + * (MatrixEvent.event) is replaced by the complete event from the homeserver, + * thus updating its event id, as well as server-generated fields such as the + * timestamp. Its status is set to null. + * + *

Once the /send request completes, if the remote echo has not already + * arrived, the event is updated with a new event id and the status is set to + * 'SENT'. The server-generated fields are of course not updated yet. + * + *

If the /send fails, In this case, the event's status is set to + * 'NOT_SENT'. If it is later resent, the process starts again, setting the + * status to 'SENDING'. Alternatively, the message may be cancelled, which + * removes the event from the room, and sets the status to 'CANCELLED'. + * + *

This event is raised to reflect each of the transitions above. + * + * @event module:client~MatrixClient#"Room.localEchoUpdated" + * + * @param {MatrixEvent} event The matrix event which has been updated + * + * @param {Room} room The room containing the redacted event + * + * @param {string} oldEventId The previous event id (the temporary event id, + * except when updating a successfully-sent event when its echo arrives) + * + * @param {EventStatus} oldStatus The previous event status. + */ + +/** + * Fires when the logged in user's membership in the room is updated. + * + * @event module:models/room~Room#"Room.myMembership" + * @param {Room} room The room in which the membership has been updated + * @param {string} membership The new membership value + * @param {string} prevMembership The previous membership value + */ diff --git a/src/models/user.js b/src/models/user.js deleted file mode 100644 index ec1127e84..000000000 --- a/src/models/user.js +++ /dev/null @@ -1,260 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** - * @module models/user - */ - -import * as utils from "../utils"; -import { EventEmitter } from "events"; - -/** - * Construct a new User. A User must have an ID and can optionally have extra - * information associated with it. - * @constructor - * @param {string} userId Required. The ID of this user. - * @prop {string} userId The ID of the user. - * @prop {Object} info The info object supplied in the constructor. - * @prop {string} displayName The 'displayname' of the user if known. - * @prop {string} avatarUrl The 'avatar_url' of the user if known. - * @prop {string} presence The presence enum if known. - * @prop {string} presenceStatusMsg The presence status message if known. - * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted - * proactively with the server, or we saw a message from the user - * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last - * received presence data for this user. We can subtract - * lastActiveAgo from this to approximate an absolute value for - * when a user was last active. - * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be - * an approximation and that the user should be seen as active 'now' - * @prop {string} _unstable_statusMessage The status message for the user, if known. This is - * different from the presenceStatusMsg in that this is not tied to - * the user's presence, and should be represented differently. - * @prop {Object} events The events describing this user. - * @prop {MatrixEvent} events.presence The m.presence event for this user. - */ -export function User(userId) { - this.userId = userId; - this.presence = "offline"; - this.presenceStatusMsg = null; - this._unstable_statusMessage = ""; - this.displayName = userId; - this.rawDisplayName = userId; - this.avatarUrl = null; - this.lastActiveAgo = 0; - this.lastPresenceTs = 0; - this.currentlyActive = false; - this.events = { - presence: null, - profile: null, - }; - this._updateModifiedTime(); -} -utils.inherits(User, EventEmitter); - -/** - * Update this User with the given presence event. May fire "User.presence", - * "User.avatarUrl" and/or "User.displayName" if this event updates this user's - * properties. - * @param {MatrixEvent} event The m.presence event. - * @fires module:client~MatrixClient#event:"User.presence" - * @fires module:client~MatrixClient#event:"User.displayName" - * @fires module:client~MatrixClient#event:"User.avatarUrl" - */ -User.prototype.setPresenceEvent = function(event) { - if (event.getType() !== "m.presence") { - return; - } - const firstFire = this.events.presence === null; - this.events.presence = event; - - const eventsToFire = []; - if (event.getContent().presence !== this.presence || firstFire) { - eventsToFire.push("User.presence"); - } - if (event.getContent().avatar_url && - event.getContent().avatar_url !== this.avatarUrl) { - eventsToFire.push("User.avatarUrl"); - } - if (event.getContent().displayname && - event.getContent().displayname !== this.displayName) { - eventsToFire.push("User.displayName"); - } - if (event.getContent().currently_active !== undefined && - event.getContent().currently_active !== this.currentlyActive) { - eventsToFire.push("User.currentlyActive"); - } - - this.presence = event.getContent().presence; - eventsToFire.push("User.lastPresenceTs"); - - if (event.getContent().status_msg) { - this.presenceStatusMsg = event.getContent().status_msg; - } - if (event.getContent().displayname) { - this.displayName = event.getContent().displayname; - } - if (event.getContent().avatar_url) { - this.avatarUrl = event.getContent().avatar_url; - } - this.lastActiveAgo = event.getContent().last_active_ago; - this.lastPresenceTs = Date.now(); - this.currentlyActive = event.getContent().currently_active; - - this._updateModifiedTime(); - - for (let i = 0; i < eventsToFire.length; i++) { - this.emit(eventsToFire[i], event, this); - } -}; - -/** - * Manually set this user's display name. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setDisplayName = function(name) { - const oldName = this.displayName; - if (typeof name === "string") { - this.displayName = name; - } else { - this.displayName = undefined; - } - if (name !== oldName) { - this._updateModifiedTime(); - } -}; - -/** - * Manually set this user's non-disambiguated display name. No event is emitted - * in response to this as there is no underlying MatrixEvent to emit with. - * @param {string} name The new display name. - */ -User.prototype.setRawDisplayName = function(name) { - if (typeof name === "string") { - this.rawDisplayName = name; - } else { - this.rawDisplayName = undefined; - } -}; - -/** - * Manually set this user's avatar URL. No event is emitted in response to this - * as there is no underlying MatrixEvent to emit with. - * @param {string} url The new avatar URL. - */ -User.prototype.setAvatarUrl = function(url) { - const oldUrl = this.avatarUrl; - this.avatarUrl = url; - if (url !== oldUrl) { - this._updateModifiedTime(); - } -}; - -/** - * Update the last modified time to the current time. - */ -User.prototype._updateModifiedTime = function() { - this._modified = Date.now(); -}; - -/** - * Get the timestamp when this User was last updated. This timestamp is - * updated when this User receives a new Presence event which has updated a - * property on this object. It is updated before firing events. - * @return {number} The timestamp - */ -User.prototype.getLastModifiedTime = function() { - return this._modified; -}; - -/** - * Get the absolute timestamp when this User was last known active on the server. - * It is *NOT* accurate if this.currentlyActive is true. - * @return {number} The timestamp - */ -User.prototype.getLastActiveTs = function() { - return this.lastPresenceTs - this.lastActiveAgo; -}; - -/** - * Manually set the user's status message. - * @param {MatrixEvent} event The im.vector.user_status event. - * @fires module:client~MatrixClient#event:"User._unstable_statusMessage" - */ -User.prototype._unstable_updateStatusMessage = function(event) { - if (!event.getContent()) this._unstable_statusMessage = ""; - else this._unstable_statusMessage = event.getContent()["status"]; - this._updateModifiedTime(); - this.emit("User._unstable_statusMessage", this); -}; - -/** - * Fires whenever any user's lastPresenceTs changes, - * ie. whenever any presence event is received for a user. - * @event module:client~MatrixClient#"User.lastPresenceTs" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.lastPresenceTs changed. - * @example - * matrixClient.on("User.lastPresenceTs", function(event, user){ - * var newlastPresenceTs = user.lastPresenceTs; - * }); - */ - -/** - * Fires whenever any user's presence changes. - * @event module:client~MatrixClient#"User.presence" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.presence changed. - * @example - * matrixClient.on("User.presence", function(event, user){ - * var newPresence = user.presence; - * }); - */ - -/** - * Fires whenever any user's currentlyActive changes. - * @event module:client~MatrixClient#"User.currentlyActive" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.currentlyActive changed. - * @example - * matrixClient.on("User.currentlyActive", function(event, user){ - * var newCurrentlyActive = user.currentlyActive; - * }); - */ - -/** - * Fires whenever any user's display name changes. - * @event module:client~MatrixClient#"User.displayName" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.displayName changed. - * @example - * matrixClient.on("User.displayName", function(event, user){ - * var newName = user.displayName; - * }); - */ - -/** - * Fires whenever any user's avatar URL changes. - * @event module:client~MatrixClient#"User.avatarUrl" - * @param {MatrixEvent} event The matrix event which caused this event to fire. - * @param {User} user The user whose User.avatarUrl changed. - * @example - * matrixClient.on("User.avatarUrl", function(event, user){ - * var newUrl = user.avatarUrl; - * }); - */ diff --git a/src/models/user.ts b/src/models/user.ts new file mode 100644 index 000000000..5eff5062c --- /dev/null +++ b/src/models/user.ts @@ -0,0 +1,274 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/** + * @module models/user + */ + +import { EventEmitter } from "events"; + +import { MatrixEvent } from "./event"; + +export class User extends EventEmitter { + // eslint-disable-next-line camelcase + private modified: number; + + // XXX these should be read-only + public displayName: string; + public rawDisplayName: string; + public avatarUrl: string; + public presenceStatusMsg: string = null; + public presence = "offline"; + public lastActiveAgo = 0; + public lastPresenceTs = 0; + public currentlyActive = false; + public events: { + presence?: MatrixEvent; + profile?: MatrixEvent; + } = { + presence: null, + profile: null, + }; + // eslint-disable-next-line camelcase + public unstable_statusMessage = ""; + + /** + * Construct a new User. A User must have an ID and can optionally have extra + * information associated with it. + * @constructor + * @param {string} userId Required. The ID of this user. + * @prop {string} userId The ID of the user. + * @prop {Object} info The info object supplied in the constructor. + * @prop {string} displayName The 'displayname' of the user if known. + * @prop {string} avatarUrl The 'avatar_url' of the user if known. + * @prop {string} presence The presence enum if known. + * @prop {string} presenceStatusMsg The presence status message if known. + * @prop {Number} lastActiveAgo The time elapsed in ms since the user interacted + * proactively with the server, or we saw a message from the user + * @prop {Number} lastPresenceTs Timestamp (ms since the epoch) for when we last + * received presence data for this user. We can subtract + * lastActiveAgo from this to approximate an absolute value for + * when a user was last active. + * @prop {Boolean} currentlyActive Whether we should consider lastActiveAgo to be + * an approximation and that the user should be seen as active 'now' + * @prop {string} unstable_statusMessage The status message for the user, if known. This is + * different from the presenceStatusMsg in that this is not tied to + * the user's presence, and should be represented differently. + * @prop {Object} events The events describing this user. + * @prop {MatrixEvent} events.presence The m.presence event for this user. + */ + constructor(public readonly userId: string) { + super(); + this.displayName = userId; + this.rawDisplayName = userId; + this.avatarUrl = null; + this.updateModifiedTime(); + } + + /** + * Update this User with the given presence event. May fire "User.presence", + * "User.avatarUrl" and/or "User.displayName" if this event updates this user's + * properties. + * @param {MatrixEvent} event The m.presence event. + * @fires module:client~MatrixClient#event:"User.presence" + * @fires module:client~MatrixClient#event:"User.displayName" + * @fires module:client~MatrixClient#event:"User.avatarUrl" + */ + public setPresenceEvent(event: MatrixEvent): void { + if (event.getType() !== "m.presence") { + return; + } + const firstFire = this.events.presence === null; + this.events.presence = event; + + const eventsToFire = []; + if (event.getContent().presence !== this.presence || firstFire) { + eventsToFire.push("User.presence"); + } + if (event.getContent().avatar_url && + event.getContent().avatar_url !== this.avatarUrl) { + eventsToFire.push("User.avatarUrl"); + } + if (event.getContent().displayname && + event.getContent().displayname !== this.displayName) { + eventsToFire.push("User.displayName"); + } + if (event.getContent().currently_active !== undefined && + event.getContent().currently_active !== this.currentlyActive) { + eventsToFire.push("User.currentlyActive"); + } + + this.presence = event.getContent().presence; + eventsToFire.push("User.lastPresenceTs"); + + if (event.getContent().status_msg) { + this.presenceStatusMsg = event.getContent().status_msg; + } + if (event.getContent().displayname) { + this.displayName = event.getContent().displayname; + } + if (event.getContent().avatar_url) { + this.avatarUrl = event.getContent().avatar_url; + } + this.lastActiveAgo = event.getContent().last_active_ago; + this.lastPresenceTs = Date.now(); + this.currentlyActive = event.getContent().currently_active; + + this.updateModifiedTime(); + + for (let i = 0; i < eventsToFire.length; i++) { + this.emit(eventsToFire[i], event, this); + } + } + + /** + * Manually set this user's display name. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setDisplayName(name: string): void { + const oldName = this.displayName; + if (typeof name === "string") { + this.displayName = name; + } else { + this.displayName = undefined; + } + if (name !== oldName) { + this.updateModifiedTime(); + } + } + + /** + * Manually set this user's non-disambiguated display name. No event is emitted + * in response to this as there is no underlying MatrixEvent to emit with. + * @param {string} name The new display name. + */ + public setRawDisplayName(name: string): void { + if (typeof name === "string") { + this.rawDisplayName = name; + } else { + this.rawDisplayName = undefined; + } + } + + /** + * Manually set this user's avatar URL. No event is emitted in response to this + * as there is no underlying MatrixEvent to emit with. + * @param {string} url The new avatar URL. + */ + public setAvatarUrl(url: string): void { + const oldUrl = this.avatarUrl; + this.avatarUrl = url; + if (url !== oldUrl) { + this.updateModifiedTime(); + } + } + + /** + * Update the last modified time to the current time. + */ + private updateModifiedTime(): void { + this.modified = Date.now(); + } + + /** + * Get the timestamp when this User was last updated. This timestamp is + * updated when this User receives a new Presence event which has updated a + * property on this object. It is updated before firing events. + * @return {number} The timestamp + */ + public getLastModifiedTime(): number { + return this.modified; + } + + /** + * Get the absolute timestamp when this User was last known active on the server. + * It is *NOT* accurate if this.currentlyActive is true. + * @return {number} The timestamp + */ + public getLastActiveTs(): number { + return this.lastPresenceTs - this.lastActiveAgo; + } + + /** + * Manually set the user's status message. + * @param {MatrixEvent} event The im.vector.user_status event. + * @fires module:client~MatrixClient#event:"User.unstable_statusMessage" + */ + // eslint-disable-next-line camelcase + public unstable_updateStatusMessage(event: MatrixEvent): void { + if (!event.getContent()) this.unstable_statusMessage = ""; + else this.unstable_statusMessage = event.getContent()["status"]; + this.updateModifiedTime(); + this.emit("User.unstable_statusMessage", this); + } +} + +/** + * Fires whenever any user's lastPresenceTs changes, + * ie. whenever any presence event is received for a user. + * @event module:client~MatrixClient#"User.lastPresenceTs" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.lastPresenceTs changed. + * @example + * matrixClient.on("User.lastPresenceTs", function(event, user){ + * var newlastPresenceTs = user.lastPresenceTs; + * }); + */ + +/** + * Fires whenever any user's presence changes. + * @event module:client~MatrixClient#"User.presence" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.presence changed. + * @example + * matrixClient.on("User.presence", function(event, user){ + * var newPresence = user.presence; + * }); + */ + +/** + * Fires whenever any user's currentlyActive changes. + * @event module:client~MatrixClient#"User.currentlyActive" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.currentlyActive changed. + * @example + * matrixClient.on("User.currentlyActive", function(event, user){ + * var newCurrentlyActive = user.currentlyActive; + * }); + */ + +/** + * Fires whenever any user's display name changes. + * @event module:client~MatrixClient#"User.displayName" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.displayName changed. + * @example + * matrixClient.on("User.displayName", function(event, user){ + * var newName = user.displayName; + * }); + */ + +/** + * Fires whenever any user's avatar URL changes. + * @event module:client~MatrixClient#"User.avatarUrl" + * @param {MatrixEvent} event The matrix event which caused this event to fire. + * @param {User} user The user whose User.avatarUrl changed. + * @example + * matrixClient.on("User.avatarUrl", function(event, user){ + * var newUrl = user.avatarUrl; + * }); + */ diff --git a/src/service-types.ts b/src/service-types.ts new file mode 100644 index 000000000..79dc99937 --- /dev/null +++ b/src/service-types.ts @@ -0,0 +1,20 @@ +/* +Copyright 2019 - 2021 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 enum SERVICE_TYPES { + IS = 'SERVICE_TYPE_IS', // An Identity Service + IM = 'SERVICE_TYPE_IM', // An Integration Manager +} diff --git a/src/store/index.ts b/src/store/index.ts new file mode 100644 index 000000000..80cdc8a1a --- /dev/null +++ b/src/store/index.ts @@ -0,0 +1,234 @@ +/* +Copyright 2015 - 2021 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 { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { RoomSummary } from "../models/room-summary"; +import { IMinimalEvent, IGroups, IRooms } from "../sync-accumulator"; + +export interface ISavedSync { + nextBatch: string; + roomsData: IRooms; + groupsData: IGroups; + accountData: IMinimalEvent[]; +} + +/** + * Construct a stub store. This does no-ops on most store methods. + * @constructor + */ +export interface IStore { + /** @return {Promise} whether or not the database was newly created in this session. */ + isNewlyCreated(): Promise; + + /** + * Get the sync token. + * @return {string} + */ + getSyncToken(): string | null; + + /** + * Set the sync token. + * @param {string} token + */ + setSyncToken(token: string); + + /** + * No-op. + * @param {Group} group + */ + storeGroup(group: Group); + + /** + * No-op. + * @param {string} groupId + * @return {null} + */ + getGroup(groupId: string): Group | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getGroups(): Group[]; + + /** + * No-op. + * @param {Room} room + */ + storeRoom(room: Room); + + /** + * No-op. + * @param {string} roomId + * @return {null} + */ + getRoom(roomId: string): Room | null; + + /** + * No-op. + * @return {Array} An empty array. + */ + getRooms(): Room[]; + + /** + * Permanently delete a room. + * @param {string} roomId + */ + removeRoom(roomId: string); + + /** + * No-op. + * @return {Array} An empty array. + */ + getRoomSummaries(): RoomSummary[]; + + /** + * No-op. + * @param {User} user + */ + storeUser(user: User); + + /** + * No-op. + * @param {string} userId + * @return {null} + */ + getUser(userId: string): User | null; + + /** + * No-op. + * @return {User[]} + */ + getUsers(): User[]; + + /** + * No-op. + * @param {Room} room + * @param {integer} limit + * @return {Array} + */ + scrollback(room: Room, limit: number): MatrixEvent[]; + + /** + * Store events for a room. + * @param {Room} room The room to store events for. + * @param {Array} events The events to store. + * @param {string} token The token associated with these events. + * @param {boolean} toStart True if these are paginated results. + */ + storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean); + + /** + * Store a filter. + * @param {Filter} filter + */ + storeFilter(filter: Filter); + + /** + * Retrieve a filter. + * @param {string} userId + * @param {string} filterId + * @return {?Filter} A filter or null. + */ + getFilter(userId: string, filterId: string): Filter | null; + + /** + * Retrieve a filter ID with the given name. + * @param {string} filterName The filter name. + * @return {?string} The filter ID or null. + */ + getFilterIdByName(filterName: string): string | null; + + /** + * Set a filter name to ID mapping. + * @param {string} filterName + * @param {string} filterId + */ + setFilterIdByName(filterName: string, filterId: string); + + /** + * Store user-scoped account data events + * @param {Array} events The events to store. + */ + storeAccountDataEvents(events: MatrixEvent[]); + + /** + * Get account data event by event type + * @param {string} eventType The event type being queried + */ + getAccountData(eventType: EventType | string): MatrixEvent; + + /** + * setSyncData does nothing as there is no backing data store. + * + * @param {Object} syncData The sync data + * @return {Promise} An immediately resolved promise. + */ + setSyncData(syncData: object): Promise; + + /** + * We never want to save because we have nothing to save to. + * + * @return {boolean} If the store wants to save + */ + wantsSave(): boolean; + + /** + * Save does nothing as there is no backing data store. + */ + save(force: boolean): void; + + /** + * Startup does nothing. + * @return {Promise} An immediately resolved promise. + */ + startup(): Promise; + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + getSavedSync(): Promise; + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + getSavedSyncToken(): Promise; + + /** + * Delete all data from this store. Does nothing since this store + * doesn't store anything. + * @return {Promise} An immediately resolved promise. + */ + deleteAllData(): Promise; + + getOutOfBandMembers(roomId: string): Promise; + + setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise; + + clearOutOfBandMembers(roomId: string): Promise; + + getClientOptions(): Promise; + + storeClientOptions(options: object): Promise; +} diff --git a/src/store/indexeddb.js b/src/store/indexeddb.js deleted file mode 100644 index e661c83a8..000000000 --- a/src/store/indexeddb.js +++ /dev/null @@ -1,319 +0,0 @@ -/* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. -*/ - -/* eslint-disable @babel/no-invalid-this */ - -import { MemoryStore } from "./memory"; -import * as utils from "../utils"; -import { EventEmitter } from 'events'; -import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; -import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; -import { User } from "../models/user"; -import { MatrixEvent } from "../models/event"; -import { logger } from '../logger'; - -/** - * This is an internal module. See {@link IndexedDBStore} for the public class. - * @module store/indexeddb - */ - -// If this value is too small we'll be writing very often which will cause -// noticable stop-the-world pauses. If this value is too big we'll be writing -// so infrequently that the /sync size gets bigger on reload. Writing more -// often does not affect the length of the pause since the entire /sync -// response is persisted each time. -const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes - -/** - * Construct a new Indexed Database store, which extends MemoryStore. - * - * This store functions like a MemoryStore except it periodically persists - * the contents of the store to an IndexedDB backend. - * - * All data is still kept in-memory but can be loaded from disk by calling - * startup(). This can make startup times quicker as a complete - * sync from the server is not required. This does not reduce memory usage as all - * the data is eagerly fetched when startup() is called. - *
- * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
- * let store = new IndexedDBStore(opts);
- * await store.startup(); // load from indexed db
- * let client = sdk.createClient({
- *     store: store,
- * });
- * client.startClient();
- * client.on("sync", function(state, prevState, data) {
- *     if (state === "PREPARED") {
- *         console.log("Started up, now with go faster stripes!");
- *     }
- * });
- * 
- * - * @constructor - * @extends MemoryStore - * @param {Object} opts Options object. - * @param {Object} opts.indexedDB The Indexed DB interface e.g. - * window.indexedDB - * @param {string=} opts.dbName Optional database name. The same name must be used - * to open the same database. - * @param {string=} opts.workerScript Optional URL to a script to invoke a web - * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker - * class is provided for this purpose and requires the application to provide a - * trivial wrapper script around it. - * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker - * object will be used if it exists. - * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to - * this API if you need to perform specific indexeddb actions like deleting the - * database. - */ -export function IndexedDBStore(opts) { - MemoryStore.call(this, opts); - - if (!opts.indexedDB) { - throw new Error('Missing required option: indexedDB'); - } - - if (opts.workerScript) { - // try & find a webworker-compatible API - let workerApi = opts.workerApi; - if (!workerApi) { - // default to the global Worker object (which is where it in a browser) - workerApi = global.Worker; - } - this.backend = new RemoteIndexedDBStoreBackend( - opts.workerScript, opts.dbName, workerApi, - ); - } else { - this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); - } - - this.startedUp = false; - this._syncTs = 0; - - // Records the last-modified-time of each user at the last point we saved - // the database, such that we can derive the set if users that have been - // modified since we last saved. - this._userModifiedMap = { - // user_id : timestamp - }; -} -utils.inherits(IndexedDBStore, MemoryStore); -utils.extend(IndexedDBStore.prototype, EventEmitter.prototype); - -IndexedDBStore.exists = function(indexedDB, dbName) { - return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); -}; - -/** - * @return {Promise} Resolved when loaded from indexed db. - */ -IndexedDBStore.prototype.startup = function() { - if (this.startedUp) { - logger.log(`IndexedDBStore.startup: already started`); - return Promise.resolve(); - } - - logger.log(`IndexedDBStore.startup: connecting to backend`); - return this.backend.connect().then(() => { - logger.log(`IndexedDBStore.startup: loading presence events`); - return this.backend.getUserPresenceEvents(); - }).then((userPresenceEvents) => { - logger.log(`IndexedDBStore.startup: processing presence events`); - userPresenceEvents.forEach(([userId, rawEvent]) => { - const u = new User(userId); - if (rawEvent) { - u.setPresenceEvent(new MatrixEvent(rawEvent)); - } - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - this.storeUser(u); - }); - }); -}; - -/** - * @return {Promise} Resolves with a sync response to restore the - * client state to where it was at the last save, or null if there - * is no saved sync data. - */ -IndexedDBStore.prototype.getSavedSync = degradable(function() { - return this.backend.getSavedSync(); -}, "getSavedSync"); - -/** @return {Promise} whether or not the database was newly created in this session. */ -IndexedDBStore.prototype.isNewlyCreated = degradable(function() { - return this.backend.isNewlyCreated(); -}, "isNewlyCreated"); - -/** - * @return {Promise} If there is a saved sync, the nextBatch token - * for this sync, otherwise null. - */ -IndexedDBStore.prototype.getSavedSyncToken = degradable(function() { - return this.backend.getNextBatchToken(); -}, "getSavedSyncToken"), - -/** - * Delete all data from this store. - * @return {Promise} Resolves if the data was deleted from the database. - */ -IndexedDBStore.prototype.deleteAllData = degradable(function() { - MemoryStore.prototype.deleteAllData.call(this); - return this.backend.clearDatabase().then(() => { - logger.log("Deleted indexeddb data."); - }, (err) => { - logger.error(`Failed to delete indexeddb data: ${err}`); - throw err; - }); -}); - -/** - * Whether this store would like to save its data - * Note that obviously whether the store wants to save or - * not could change between calling this function and calling - * save(). - * - * @return {boolean} True if calling save() will actually save - * (at the time this function is called). - */ -IndexedDBStore.prototype.wantsSave = function() { - const now = Date.now(); - return now - this._syncTs > WRITE_DELAY_MS; -}; - -/** - * Possibly write data to the database. - * - * @param {bool} force True to force a save to happen - * @return {Promise} Promise resolves after the write completes - * (or immediately if no write is performed) - */ -IndexedDBStore.prototype.save = function(force) { - if (force || this.wantsSave()) { - return this._reallySave(); - } - return Promise.resolve(); -}; - -IndexedDBStore.prototype._reallySave = degradable(function() { - this._syncTs = Date.now(); // set now to guard against multi-writes - - // work out changed users (this doesn't handle deletions but you - // can't 'delete' users as they are just presence events). - const userTuples = []; - for (const u of this.getUsers()) { - if (this._userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; - if (!u.events.presence) continue; - - userTuples.push([u.userId, u.events.presence.event]); - - // note that we've saved this version of the user - this._userModifiedMap[u.userId] = u.getLastModifiedTime(); - } - - return this.backend.syncToDatabase(userTuples); -}); - -IndexedDBStore.prototype.setSyncData = degradable(function(syncData) { - return this.backend.setSyncData(syncData); -}, "setSyncData"); - -/** - * Returns the out-of-band membership events for this room that - * were previously loaded. - * @param {string} roomId - * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members - * @returns {null} in case the members for this room haven't been stored yet - */ -IndexedDBStore.prototype.getOutOfBandMembers = degradable(function(roomId) { - return this.backend.getOutOfBandMembers(roomId); -}, "getOutOfBandMembers"); - -/** - * Stores the out-of-band membership events for this room. Note that - * it still makes sense to store an empty array as the OOB status for the room is - * marked as fetched, and getOutOfBandMembers will return an empty array instead of null - * @param {string} roomId - * @param {event[]} membershipEvents the membership events to store - * @returns {Promise} when all members have been stored - */ -IndexedDBStore.prototype.setOutOfBandMembers = degradable(function( - roomId, - membershipEvents, -) { - MemoryStore.prototype.setOutOfBandMembers.call(this, roomId, membershipEvents); - return this.backend.setOutOfBandMembers(roomId, membershipEvents); -}, "setOutOfBandMembers"); - -IndexedDBStore.prototype.clearOutOfBandMembers = degradable(function(roomId) { - MemoryStore.prototype.clearOutOfBandMembers.call(this); - return this.backend.clearOutOfBandMembers(roomId); -}, "clearOutOfBandMembers"); - -IndexedDBStore.prototype.getClientOptions = degradable(function() { - return this.backend.getClientOptions(); -}, "getClientOptions"); - -IndexedDBStore.prototype.storeClientOptions = degradable(function(options) { - MemoryStore.prototype.storeClientOptions.call(this, options); - return this.backend.storeClientOptions(options); -}, "storeClientOptions"); - -/** - * All member functions of `IndexedDBStore` that access the backend use this wrapper to - * watch for failures after initial store startup, including `QuotaExceededError` as - * free disk space changes, etc. - * - * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` - * in place so that the current operation and all future ones are in-memory only. - * - * @param {Function} func The degradable work to do. - * @param {String} fallback The method name for fallback. - * @returns {Function} A wrapped member function. - */ -function degradable(func, fallback) { - return async function(...args) { - try { - return await func.call(this, ...args); - } catch (e) { - logger.error("IndexedDBStore failure, degrading to MemoryStore", e); - this.emit("degraded", e); - try { - // We try to delete IndexedDB after degrading since this store is only a - // cache (the app will still function correctly without the data). - // It's possible that deleting repair IndexedDB for the next app load, - // potenially by making a little more space available. - logger.log("IndexedDBStore trying to delete degraded data"); - await this.backend.clearDatabase(); - logger.log("IndexedDBStore delete after degrading succeeeded"); - } catch (e) { - logger.warn("IndexedDBStore delete after degrading failed", e); - } - // Degrade the store from being an instance of `IndexedDBStore` to instead be - // an instance of `MemoryStore` so that future API calls use the memory path - // directly and skip IndexedDB entirely. This should be safe as - // `IndexedDBStore` already extends from `MemoryStore`, so we are making the - // store become its parent type in a way. The mutator methods of - // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are - // not overridden at all). - Object.setPrototypeOf(this, MemoryStore.prototype); - if (fallback) { - return await MemoryStore.prototype[fallback].call(this, ...args); - } - } - }; -} diff --git a/src/store/indexeddb.ts b/src/store/indexeddb.ts new file mode 100644 index 000000000..f7f9fb8b9 --- /dev/null +++ b/src/store/indexeddb.ts @@ -0,0 +1,331 @@ +/* +Copyright 2017 - 2021 Vector Creations Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/* eslint-disable @babel/no-invalid-this */ + +import { EventEmitter } from 'events'; + +import { MemoryStore, IOpts as IBaseOpts } from "./memory"; +import { LocalIndexedDBStoreBackend } from "./indexeddb-local-backend.js"; +import { RemoteIndexedDBStoreBackend } from "./indexeddb-remote-backend.js"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { logger } from '../logger'; +import { ISavedSync } from "./index"; + +/** + * This is an internal module. See {@link IndexedDBStore} for the public class. + * @module store/indexeddb + */ + +// If this value is too small we'll be writing very often which will cause +// noticeable stop-the-world pauses. If this value is too big we'll be writing +// so infrequently that the /sync size gets bigger on reload. Writing more +// often does not affect the length of the pause since the entire /sync +// response is persisted each time. +const WRITE_DELAY_MS = 1000 * 60 * 5; // once every 5 minutes + +interface IOpts extends IBaseOpts { + indexedDB: IDBFactory; + dbName?: string; + workerScript?: string; + workerApi?: typeof Worker; +} + +export class IndexedDBStore extends MemoryStore { + static exists(indexedDB: IDBFactory, dbName: string): boolean { + return LocalIndexedDBStoreBackend.exists(indexedDB, dbName); + } + + // TODO these should conform to one interface + public readonly backend: LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend; + + private startedUp = false; + private syncTs = 0; + // Records the last-modified-time of each user at the last point we saved + // the database, such that we can derive the set if users that have been + // modified since we last saved. + private userModifiedMap: Record = {}; // user_id : timestamp + private emitter = new EventEmitter(); + + /** + * Construct a new Indexed Database store, which extends MemoryStore. + * + * This store functions like a MemoryStore except it periodically persists + * the contents of the store to an IndexedDB backend. + * + * All data is still kept in-memory but can be loaded from disk by calling + * startup(). This can make startup times quicker as a complete + * sync from the server is not required. This does not reduce memory usage as all + * the data is eagerly fetched when startup() is called. + *
+     * let opts = { indexedDB: window.indexedDB, localStorage: window.localStorage };
+     * let store = new IndexedDBStore(opts);
+     * await store.startup(); // load from indexed db
+     * let client = sdk.createClient({
+     *     store: store,
+     * });
+     * client.startClient();
+     * client.on("sync", function(state, prevState, data) {
+     *     if (state === "PREPARED") {
+     *         console.log("Started up, now with go faster stripes!");
+     *     }
+     * });
+     * 
+ * + * @constructor + * @extends MemoryStore + * @param {Object} opts Options object. + * @param {Object} opts.indexedDB The Indexed DB interface e.g. + * window.indexedDB + * @param {string=} opts.dbName Optional database name. The same name must be used + * to open the same database. + * @param {string=} opts.workerScript Optional URL to a script to invoke a web + * worker with to run IndexedDB queries on the web worker. The IndexedDbStoreWorker + * class is provided for this purpose and requires the application to provide a + * trivial wrapper script around it. + * @param {Object=} opts.workerApi The webWorker API object. If omitted, the global Worker + * object will be used if it exists. + * @prop {IndexedDBStoreBackend} backend The backend instance. Call through to + * this API if you need to perform specific indexeddb actions like deleting the + * database. + */ + constructor(opts: IOpts) { + super(opts); + + if (!opts.indexedDB) { + throw new Error('Missing required option: indexedDB'); + } + + if (opts.workerScript) { + // try & find a webworker-compatible API + let workerApi = opts.workerApi; + if (!workerApi) { + // default to the global Worker object (which is where it in a browser) + workerApi = global.Worker; + } + this.backend = new RemoteIndexedDBStoreBackend( + opts.workerScript, opts.dbName, workerApi, + ); + } else { + this.backend = new LocalIndexedDBStoreBackend(opts.indexedDB, opts.dbName); + } + } + + public on = this.emitter.on.bind(this.emitter); + + /** + * @return {Promise} Resolved when loaded from indexed db. + */ + public startup(): Promise { + if (this.startedUp) { + logger.log(`IndexedDBStore.startup: already started`); + return Promise.resolve(); + } + + logger.log(`IndexedDBStore.startup: connecting to backend`); + return this.backend.connect().then(() => { + logger.log(`IndexedDBStore.startup: loading presence events`); + return this.backend.getUserPresenceEvents(); + }).then((userPresenceEvents) => { + logger.log(`IndexedDBStore.startup: processing presence events`); + userPresenceEvents.forEach(([userId, rawEvent]) => { + const u = new User(userId); + if (rawEvent) { + u.setPresenceEvent(new MatrixEvent(rawEvent)); + } + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + this.storeUser(u); + }); + }); + } + + /** + * @return {Promise} Resolves with a sync response to restore the + * client state to where it was at the last save, or null if there + * is no saved sync data. + */ + public getSavedSync = this.degradable((): Promise => { + return this.backend.getSavedSync(); + }, "getSavedSync"); + + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated = this.degradable((): Promise => { + return this.backend.isNewlyCreated(); + }, "isNewlyCreated"); + + /** + * @return {Promise} If there is a saved sync, the nextBatch token + * for this sync, otherwise null. + */ + public getSavedSyncToken = this.degradable((): Promise => { + return this.backend.getNextBatchToken(); + }, "getSavedSyncToken"); + + /** + * Delete all data from this store. + * @return {Promise} Resolves if the data was deleted from the database. + */ + public deleteAllData = this.degradable((): Promise => { + super.deleteAllData(); + return this.backend.clearDatabase().then(() => { + logger.log("Deleted indexeddb data."); + }, (err) => { + logger.error(`Failed to delete indexeddb data: ${err}`); + throw err; + }); + }); + + /** + * Whether this store would like to save its data + * Note that obviously whether the store wants to save or + * not could change between calling this function and calling + * save(). + * + * @return {boolean} True if calling save() will actually save + * (at the time this function is called). + */ + public wantsSave(): boolean { + const now = Date.now(); + return now - this.syncTs > WRITE_DELAY_MS; + } + + /** + * Possibly write data to the database. + * + * @param {boolean} force True to force a save to happen + * @return {Promise} Promise resolves after the write completes + * (or immediately if no write is performed) + */ + public save(force = false): Promise { + if (force || this.wantsSave()) { + return this.reallySave(); + } + return Promise.resolve(); + } + + private reallySave = this.degradable((): Promise => { + this.syncTs = Date.now(); // set now to guard against multi-writes + + // work out changed users (this doesn't handle deletions but you + // can't 'delete' users as they are just presence events). + const userTuples = []; + for (const u of this.getUsers()) { + if (this.userModifiedMap[u.userId] === u.getLastModifiedTime()) continue; + if (!u.events.presence) continue; + + userTuples.push([u.userId, u.events.presence.event]); + + // note that we've saved this version of the user + this.userModifiedMap[u.userId] = u.getLastModifiedTime(); + } + + return this.backend.syncToDatabase(userTuples); + }); + + public setSyncData = this.degradable((syncData: object): Promise => { + return this.backend.setSyncData(syncData); + }, "setSyncData"); + + /** + * Returns the out-of-band membership events for this room that + * were previously loaded. + * @param {string} roomId + * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members + * @returns {null} in case the members for this room haven't been stored yet + */ + public getOutOfBandMembers = this.degradable((roomId: string): Promise => { + return this.backend.getOutOfBandMembers(roomId); + }, "getOutOfBandMembers"); + + /** + * Stores the out-of-band membership events for this room. Note that + * it still makes sense to store an empty array as the OOB status for the room is + * marked as fetched, and getOutOfBandMembers will return an empty array instead of null + * @param {string} roomId + * @param {event[]} membershipEvents the membership events to store + * @returns {Promise} when all members have been stored + */ + public setOutOfBandMembers = this.degradable((roomId: string, membershipEvents: MatrixEvent[]): Promise => { + super.setOutOfBandMembers(roomId, membershipEvents); + return this.backend.setOutOfBandMembers(roomId, membershipEvents); + }, "setOutOfBandMembers"); + + public clearOutOfBandMembers = this.degradable((roomId: string) => { + super.clearOutOfBandMembers(roomId); + return this.backend.clearOutOfBandMembers(roomId); + }, "clearOutOfBandMembers"); + + public getClientOptions = this.degradable((): Promise => { + return this.backend.getClientOptions(); + }, "getClientOptions"); + + public storeClientOptions = this.degradable((options: object): Promise => { + super.storeClientOptions(options); + return this.backend.storeClientOptions(options); + }, "storeClientOptions"); + + /** + * All member functions of `IndexedDBStore` that access the backend use this wrapper to + * watch for failures after initial store startup, including `QuotaExceededError` as + * free disk space changes, etc. + * + * When IndexedDB fails via any of these paths, we degrade this back to a `MemoryStore` + * in place so that the current operation and all future ones are in-memory only. + * + * @param {Function} func The degradable work to do. + * @param {String} fallback The method name for fallback. + * @returns {Function} A wrapped member function. + */ + private degradable, R = void>( + func: DegradableFn, + fallback?: string, + ): DegradableFn { + const fallbackFn = super[fallback]; + + return async (...args) => { + try { + return func.call(this, ...args); + } catch (e) { + logger.error("IndexedDBStore failure, degrading to MemoryStore", e); + this.emitter.emit("degraded", e); + try { + // We try to delete IndexedDB after degrading since this store is only a + // cache (the app will still function correctly without the data). + // It's possible that deleting repair IndexedDB for the next app load, + // potentially by making a little more space available. + logger.log("IndexedDBStore trying to delete degraded data"); + await this.backend.clearDatabase(); + logger.log("IndexedDBStore delete after degrading succeeded"); + } catch (e) { + logger.warn("IndexedDBStore delete after degrading failed", e); + } + // Degrade the store from being an instance of `IndexedDBStore` to instead be + // an instance of `MemoryStore` so that future API calls use the memory path + // directly and skip IndexedDB entirely. This should be safe as + // `IndexedDBStore` already extends from `MemoryStore`, so we are making the + // store become its parent type in a way. The mutator methods of + // `IndexedDBStore` also maintain the state that `MemoryStore` uses (many are + // not overridden at all). + if (fallbackFn) { + return fallbackFn(...args); + } + } + }; + } +} + +type DegradableFn, T> = (...args: A) => Promise; diff --git a/src/store/memory.js b/src/store/memory.ts similarity index 71% rename from src/store/memory.js rename to src/store/memory.ts index 809696b25..452eb0c9a 100644 --- a/src/store/memory.js +++ b/src/store/memory.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,9 +19,18 @@ limitations under the License. * @module store/memory */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { RoomState } from "../models/room-state"; +import { RoomMember } from "../models/room-member"; +import { Filter } from "../filter"; +import { ISavedSync, IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; -function isValidFilterId(filterId) { +function isValidFilterId(filterId: string): boolean { const isValidStr = typeof filterId === "string" && !!filterId && filterId !== "undefined" && // exclude these as we've serialized undefined in localStorage before @@ -33,6 +39,10 @@ function isValidFilterId(filterId) { return isValidStr || typeof filterId === "number"; } +export interface IOpts { + localStorage?: Storage; +} + /** * Construct a new in-memory data store for the Matrix Client. * @constructor @@ -40,96 +50,84 @@ function isValidFilterId(filterId) { * @param {LocalStorage} opts.localStorage The local storage instance to persist * some forms of data such as tokens. Rooms will NOT be stored. */ -export function MemoryStore(opts) { - opts = opts || {}; - this.rooms = { - // roomId: Room - }; - this.groups = { - // groupId: Group - }; - this.users = { - // userId: User - }; - this.syncToken = null; - this.filters = { - // userId: { - // filterId: Filter - // } - }; - this.accountData = { - // type : content - }; - this.localStorage = opts.localStorage; - this._oobMembers = { - // roomId: [member events] - }; - this._clientOptions = {}; -} +export class MemoryStore implements IStore { + private rooms: Record = {}; // roomId: Room + private groups: Record = {}; // groupId: Group + private users: Record = {}; // userId: User + private syncToken: string = null; + // userId: { + // filterId: Filter + // } + private filters: Record> = {}; + private accountData: Record = {}; // type : content + private readonly localStorage: Storage; + private oobMembers: Record = {}; // roomId: [member events] + private clientOptions = {}; -MemoryStore.prototype = { + constructor(opts: IOpts = {}) { + this.localStorage = opts.localStorage; + } /** * Retrieve the token to stream from. * @return {string} The token or null. */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.syncToken; - }, + } - /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + /** @return {Promise} whether or not the database was newly created in this session. */ + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Set the token to stream from. * @param {string} token The token to stream from. */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.syncToken = token; - }, + } /** * Store the given room. * @param {Group} group The group to be stored */ - storeGroup: function(group) { + public storeGroup(group: Group) { this.groups[group.groupId] = group; - }, + } /** * Retrieve a group by its group ID. * @param {string} groupId The group ID. * @return {Group} The group or null. */ - getGroup: function(groupId) { + public getGroup(groupId: string): Group | null { return this.groups[groupId] || null; - }, + } /** * Retrieve all known groups. * @return {Group[]} A list of groups, which may be empty. */ - getGroups: function() { + public getGroups(): Group[] { return Object.values(this.groups); - }, + } /** * Store the given room. * @param {Room} room The room to be stored. All properties must be stored. */ - storeRoom: function(room) { + public storeRoom(room: Room) { this.rooms[room.roomId] = room; // add listeners for room member changes so we can keep the room member // map up-to-date. - room.currentState.on("RoomState.members", this._onRoomMember.bind(this)); + room.currentState.on("RoomState.members", this.onRoomMember); // add existing members - const self = this; - room.currentState.getMembers().forEach(function(m) { - self._onRoomMember(null, room.currentState, m); + room.currentState.getMembers().forEach((m) => { + this.onRoomMember(null, room.currentState, m); }); - }, + } /** * Called when a room member in a room being tracked by this store has been @@ -138,7 +136,7 @@ MemoryStore.prototype = { * @param {RoomState} state * @param {RoomMember} member */ - _onRoomMember: function(event, state, member) { + private onRoomMember = (event: MatrixEvent, state: RoomState, member: RoomMember) => { if (member.membership === "invite") { // We do NOT add invited members because people love to typo user IDs // which would then show up in these lists (!) @@ -158,70 +156,70 @@ MemoryStore.prototype = { user.setAvatarUrl(member.events.member.getContent().avatar_url); } this.users[user.userId] = user; - }, + }; /** * Retrieve a room by its' room ID. * @param {string} roomId The room ID. * @return {Room} The room or null. */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return this.rooms[roomId] || null; - }, + } /** * Retrieve all known rooms. * @return {Room[]} A list of rooms, which may be empty. */ - getRooms: function() { + public getRooms(): Room[] { return Object.values(this.rooms); - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string): void { if (this.rooms[roomId]) { - this.rooms[roomId].removeListener("RoomState.members", this._onRoomMember); + this.rooms[roomId].removeListener("RoomState.members", this.onRoomMember); } delete this.rooms[roomId]; - }, + } /** * Retrieve a summary of all the rooms. * @return {RoomSummary[]} A summary of each room. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return Object.values(this.rooms).map(function(room) { return room.summary; }); - }, + } /** * Store a User. * @param {User} user The user to store. */ - storeUser: function(user) { + public storeUser(user: User): void { this.users[user.userId] = user; - }, + } /** * Retrieve a User by its' user ID. * @param {string} userId The user ID. * @return {User} The user or null. */ - getUser: function(userId) { + public getUser(userId: string): User | null { return this.users[userId] || null; - }, + } /** * Retrieve all known users. * @return {User[]} A list of users, which may be empty. */ - getUsers: function() { + public getUsers(): User[] { return Object.values(this.users); - }, + } /** * Retrieve scrollback for this room. @@ -230,9 +228,9 @@ MemoryStore.prototype = { * @return {Array} An array of objects which will be at most 'limit' * length and at least 0. The objects are the raw event JSON. */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. The events have already been added to the timeline @@ -241,15 +239,15 @@ MemoryStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) { // no-op because they've already been added to the room instance. - }, + } /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { + public storeFilter(filter: Filter): void { if (!filter) { return; } @@ -257,7 +255,7 @@ MemoryStore.prototype = { this.filters[filter.userId] = {}; } this.filters[filter.userId][filter.filterId] = filter; - }, + } /** * Retrieve a filter. @@ -265,19 +263,19 @@ MemoryStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { if (!this.filters[userId] || !this.filters[userId][filterId]) { return null; } return this.filters[userId][filterId]; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): string | null { if (!this.localStorage) { return null; } @@ -294,14 +292,14 @@ MemoryStore.prototype = { } } catch (e) {} return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { + public setFilterIdByName(filterName: string, filterId: string) { if (!this.localStorage) { return; } @@ -313,7 +311,7 @@ MemoryStore.prototype = { this.localStorage.removeItem(key); } } catch (e) {} - }, + } /** * Store user-scoped account data events. @@ -321,21 +319,20 @@ MemoryStore.prototype = { * events with the same type will replace each other. * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - const self = this; - events.forEach(function(event) { - self.accountData[event.getType()] = event; + public storeAccountDataEvents(events: MatrixEvent[]): void { + events.forEach((event) => { + this.accountData[event.getType()] = event; }); - }, + } /** * Get account data event by event type * @param {string} eventType The event type being queried * @return {?MatrixEvent} the user account_data event of given type, if any */ - getAccountData: function(eventType) { + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { return this.accountData[eventType]; - }, + } /** * setSyncData does nothing as there is no backing data store. @@ -343,56 +340,56 @@ MemoryStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** * We never want to save becase we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. * @param {bool} force True to force a save (but the memory * store still can't save anything) */ - save: function(force) {}, + public save(force: boolean): void {} /** * Startup does nothing as this store doesn't require starting up. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { this.rooms = { // roomId: Room }; @@ -409,7 +406,7 @@ MemoryStore.prototype = { // type : content }; return Promise.resolve(); - }, + } /** * Returns the out-of-band membership events for this room that @@ -418,9 +415,9 @@ MemoryStore.prototype = { * @returns {event[]} the events, potentially an empty array if OOB loading didn't yield any new members * @returns {null} in case the members for this room haven't been stored yet */ - getOutOfBandMembers: function(roomId) { - return Promise.resolve(this._oobMembers[roomId] || null); - }, + public getOutOfBandMembers(roomId: string): Promise { + return Promise.resolve(this.oobMembers[roomId] || null); + } /** * Stores the out-of-band membership events for this room. Note that @@ -430,22 +427,22 @@ MemoryStore.prototype = { * @param {event[]} membershipEvents the membership events to store * @returns {Promise} when all members have been stored */ - setOutOfBandMembers: function(roomId, membershipEvents) { - this._oobMembers[roomId] = membershipEvents; + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { + this.oobMembers[roomId] = membershipEvents; return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { - this._oobMembers = {}; + public clearOutOfBandMembers(roomId: string): Promise { + this.oobMembers = {}; return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(this._clientOptions); - }, + public getClientOptions(): Promise { + return Promise.resolve(this.clientOptions); + } - storeClientOptions: function(options) { - this._clientOptions = Object.assign({}, options); + public storeClientOptions(options: object): Promise { + this.clientOptions = Object.assign({}, options); return Promise.resolve(); - }, -}; + } +} diff --git a/src/store/stub.js b/src/store/stub.ts similarity index 63% rename from src/store/stub.js rename to src/store/stub.ts index f94e97393..1a136d1d1 100644 --- a/src/store/stub.js +++ b/src/store/stub.ts @@ -1,8 +1,5 @@ /* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2015 - 2021 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. @@ -22,124 +19,127 @@ limitations under the License. * @module store/stub */ +import { EventType } from "../@types/event"; +import { Group } from "../models/group"; +import { Room } from "../models/room"; +import { User } from "../models/user"; +import { MatrixEvent } from "../models/event"; +import { Filter } from "../filter"; +import { ISavedSync, IStore } from "./index"; +import { RoomSummary } from "../models/room-summary"; + /** * Construct a stub store. This does no-ops on most store methods. * @constructor */ -export function StubStore() { - this.fromToken = null; -} - -StubStore.prototype = { +export class StubStore implements IStore { + private fromToken: string = null; /** @return {Promise} whether or not the database was newly created in this session. */ - isNewlyCreated: function() { + public isNewlyCreated(): Promise { return Promise.resolve(true); - }, + } /** * Get the sync token. * @return {string} */ - getSyncToken: function() { + public getSyncToken(): string | null { return this.fromToken; - }, + } /** * Set the sync token. * @param {string} token */ - setSyncToken: function(token) { + public setSyncToken(token: string) { this.fromToken = token; - }, + } /** * No-op. * @param {Group} group */ - storeGroup: function(group) { - }, + public storeGroup(group: Group) {} /** * No-op. * @param {string} groupId * @return {null} */ - getGroup: function(groupId) { + public getGroup(groupId: string): Group | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getGroups: function() { + public getGroups(): Group[] { return []; - }, + } /** * No-op. * @param {Room} room */ - storeRoom: function(room) { - }, + public storeRoom(room: Room) {} /** * No-op. * @param {string} roomId * @return {null} */ - getRoom: function(roomId) { + public getRoom(roomId: string): Room | null { return null; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRooms: function() { + public getRooms(): Room[] { return []; - }, + } /** * Permanently delete a room. * @param {string} roomId */ - removeRoom: function(roomId) { + public removeRoom(roomId: string) { return; - }, + } /** * No-op. * @return {Array} An empty array. */ - getRoomSummaries: function() { + public getRoomSummaries(): RoomSummary[] { return []; - }, + } /** * No-op. * @param {User} user */ - storeUser: function(user) { - }, + public storeUser(user: User) {} /** * No-op. * @param {string} userId * @return {null} */ - getUser: function(userId) { + public getUser(userId: string): User | null { return null; - }, + } /** * No-op. * @return {User[]} */ - getUsers: function() { + public getUsers(): User[] { return []; - }, + } /** * No-op. @@ -147,9 +147,9 @@ StubStore.prototype = { * @param {integer} limit * @return {Array} */ - scrollback: function(room, limit) { + public scrollback(room: Room, limit: number): MatrixEvent[] { return []; - }, + } /** * Store events for a room. @@ -158,15 +158,13 @@ StubStore.prototype = { * @param {string} token The token associated with these events. * @param {boolean} toStart True if these are paginated results. */ - storeEvents: function(room, events, token, toStart) { - }, + public storeEvents(room: Room, events: MatrixEvent[], token: string, toStart: boolean) {} /** * Store a filter. * @param {Filter} filter */ - storeFilter: function(filter) { - }, + public storeFilter(filter: Filter) {} /** * Retrieve a filter. @@ -174,43 +172,39 @@ StubStore.prototype = { * @param {string} filterId * @return {?Filter} A filter or null. */ - getFilter: function(userId, filterId) { + public getFilter(userId: string, filterId: string): Filter | null { return null; - }, + } /** * Retrieve a filter ID with the given name. * @param {string} filterName The filter name. * @return {?string} The filter ID or null. */ - getFilterIdByName: function(filterName) { + public getFilterIdByName(filterName: string): string | null { return null; - }, + } /** * Set a filter name to ID mapping. * @param {string} filterName * @param {string} filterId */ - setFilterIdByName: function(filterName, filterId) { - - }, + public setFilterIdByName(filterName: string, filterId: string) {} /** * Store user-scoped account data events * @param {Array} events The events to store. */ - storeAccountDataEvents: function(events) { - - }, + public storeAccountDataEvents(events: MatrixEvent[]) {} /** * Get account data event by event type * @param {string} eventType The event type being queried */ - getAccountData: function(eventType) { - - }, + public getAccountData(eventType: EventType | string): MatrixEvent | undefined { + return undefined; + } /** * setSyncData does nothing as there is no backing data store. @@ -218,75 +212,75 @@ StubStore.prototype = { * @param {Object} syncData The sync data * @return {Promise} An immediately resolved promise. */ - setSyncData: function(syncData) { + public setSyncData(syncData: object): Promise { return Promise.resolve(); - }, + } /** - * We never want to save becase we have nothing to save to. + * We never want to save because we have nothing to save to. * * @return {boolean} If the store wants to save */ - wantsSave: function() { + public wantsSave(): boolean { return false; - }, + } /** * Save does nothing as there is no backing data store. */ - save: function() {}, + public save() {} /** * Startup does nothing. * @return {Promise} An immediately resolved promise. */ - startup: function() { + public startup(): Promise { return Promise.resolve(); - }, + } /** * @return {Promise} Resolves with a sync response to restore the * client state to where it was at the last save, or null if there * is no saved sync data. */ - getSavedSync: function() { + public getSavedSync(): Promise { return Promise.resolve(null); - }, + } /** * @return {Promise} If there is a saved sync, the nextBatch token * for this sync, otherwise null. */ - getSavedSyncToken: function() { + public getSavedSyncToken(): Promise { return Promise.resolve(null); - }, + } /** * Delete all data from this store. Does nothing since this store * doesn't store anything. * @return {Promise} An immediately resolved promise. */ - deleteAllData: function() { + public deleteAllData(): Promise { return Promise.resolve(); - }, + } - getOutOfBandMembers: function() { + public getOutOfBandMembers(): Promise { return Promise.resolve(null); - }, + } - setOutOfBandMembers: function() { + public setOutOfBandMembers(roomId: string, membershipEvents: MatrixEvent[]): Promise { return Promise.resolve(); - }, + } - clearOutOfBandMembers: function() { + public clearOutOfBandMembers(): Promise { return Promise.resolve(); - }, + } - getClientOptions: function() { - return Promise.resolve(); - }, + public getClientOptions(): Promise { + return Promise.resolve({}); + } - storeClientOptions: function() { + public storeClientOptions(options: object): Promise { return Promise.resolve(); - }, -}; + } +} diff --git a/src/sync-accumulator.js b/src/sync-accumulator.ts similarity index 76% rename from src/sync-accumulator.js rename to src/sync-accumulator.ts index 1f190538c..8e2c14485 100644 --- a/src/sync-accumulator.js +++ b/src/sync-accumulator.ts @@ -1,7 +1,5 @@ /* -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 The Matrix.org Foundation C.I.C. +Copyright 2017 - 2021 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. @@ -23,6 +21,153 @@ limitations under the License. import { logger } from './logger'; import { deepCopy } from "./utils"; +import { IContent, IUnsigned } from "./models/event"; +import { IRoomSummary } from "./models/room-summary"; +import { EventType } from "./@types/event"; + +interface IOpts { + maxTimelineEntries?: number; +} + +export interface IMinimalEvent { + content: IContent; + type: EventType | string; +} + +export interface IEphemeral { + events: IMinimalEvent[]; +} + +/* eslint-disable camelcase */ +interface IUnreadNotificationCounts { + highlight_count: number; + notification_count: number; +} + +export interface IRoomEvent extends IMinimalEvent { + event_id: string; + sender: string; + origin_server_ts: number; + unsigned?: IUnsigned; + /** @deprecated - legacy field */ + age?: number; +} + +export interface IStateEvent extends IRoomEvent { + prev_content?: IContent; + state_key: string; +} + +interface IState { + events: IStateEvent[]; +} + +export interface ITimeline { + events: Array; + limited: boolean; + prev_batch: string; +} + +export interface IJoinedRoom { + summary: IRoomSummary; + state: IState; + timeline: ITimeline; + ephemeral: IEphemeral; + account_data: IAccountData; + unread_notifications: IUnreadNotificationCounts; +} + +export interface IStrippedState { + content: IContent; + state_key: string; + type: EventType | string; + sender: string; +} + +export interface IInviteState { + events: IStrippedState[]; +} + +export interface IInvitedRoom { + invite_state: IInviteState; +} + +export interface ILeftRoom { + state: IState; + timeline: ITimeline; + account_data: IAccountData; +} + +export interface IRooms { + [Category.Join]: Record; + [Category.Invite]: Record; + [Category.Leave]: Record; +} + +interface IPresence { + events: IMinimalEvent[]; +} + +interface IAccountData { + events: IMinimalEvent[]; +} + +interface IToDeviceEvent { + content: IContent; + sender: string; + type: string; +} + +interface IToDevice { + events: IToDeviceEvent[]; +} + +interface IDeviceLists { + changed: string[]; + left: string[]; +} + +export interface IGroups { + [Category.Join]: object; + [Category.Invite]: object; + [Category.Leave]: object; +} + +export interface ISyncResponse { + next_batch: string; + rooms: IRooms; + presence?: IPresence; + account_data: IAccountData; + to_device?: IToDevice; + device_lists?: IDeviceLists; + device_one_time_keys_count?: Record; + + groups: IGroups; // unspecced +} +/* eslint-enable camelcase */ + +export enum Category { + Invite = "invite", + Leave = "leave", + Join = "join", +} + +interface IRoom { + _currentState: { [eventType: string]: { [stateKey: string]: IStateEvent } }; + _timeline: { + event: IRoomEvent | IStateEvent; + token: string | null; + }[]; + _summary: Partial; + _accountData: { [eventType: string]: IMinimalEvent }; + _unreadNotifications: Partial; + _readReceipts: { + [userId: string]: { + data: IMinimalEvent; + eventId: string; + }; + }; +} /** * The purpose of this class is to accumulate /sync responses such that a @@ -35,6 +180,22 @@ import { deepCopy } from "./utils"; * rather than asking the server to do an initial sync on startup. */ export class SyncAccumulator { + private accountData: Record = {}; // $event_type: Object + private inviteRooms: Record = {}; // $roomId: { ... sync 'invite' json data ... } + private joinRooms: { [roomId: string]: IRoom } = {}; + // the /sync token which corresponds to the last time rooms were + // accumulated. We remember this so that any caller can obtain a + // coherent /sync response and know at what point they should be + // streaming from without losing events. + private nextBatch: string = null; + + // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } + private groups: Record = { + invite: {}, + join: {}, + leave: {}, + }; + /** * @param {Object} opts * @param {Number=} opts.maxTimelineEntries The ideal maximum number of @@ -44,57 +205,18 @@ export class SyncAccumulator { * never be more. This cannot be 0 or else it makes it impossible to scroll * back in a room. Default: 50. */ - constructor(opts) { - opts = opts || {}; - opts.maxTimelineEntries = opts.maxTimelineEntries || 50; - this.opts = opts; - this.accountData = { - //$event_type: Object - }; - this.inviteRooms = { - //$roomId: { ... sync 'invite' json data ... } - }; - this.joinRooms = { - //$roomId: { - // _currentState: { $event_type: { $state_key: json } }, - // _timeline: [ - // { event: $event, token: null|token }, - // { event: $event, token: null|token }, - // { event: $event, token: null|token }, - // ... - // ], - // _summary: { - // m.heroes: [ $user_id ], - // m.joined_member_count: $count, - // m.invited_member_count: $count - // }, - // _accountData: { $event_type: json }, - // _unreadNotifications: { ... unread_notifications JSON ... }, - // _readReceipts: { $user_id: { data: $json, eventId: $event_id }} - //} - }; - // the /sync token which corresponds to the last time rooms were - // accumulated. We remember this so that any caller can obtain a - // coherent /sync response and know at what point they should be - // streaming from without losing events. - this.nextBatch = null; - - // { ('invite'|'join'|'leave'): $groupId: { ... sync 'group' data } } - this.groups = { - invite: {}, - join: {}, - leave: {}, - }; + constructor(private readonly opts: IOpts = {}) { + this.opts.maxTimelineEntries = this.opts.maxTimelineEntries || 50; } - accumulate(syncResponse, fromDatabase) { - this._accumulateRooms(syncResponse, fromDatabase); - this._accumulateGroups(syncResponse); - this._accumulateAccountData(syncResponse); + public accumulate(syncResponse: ISyncResponse, fromDatabase = false): void { + this.accumulateRooms(syncResponse, fromDatabase); + this.accumulateGroups(syncResponse); + this.accumulateAccountData(syncResponse); this.nextBatch = syncResponse.next_batch; } - _accumulateAccountData(syncResponse) { + private accumulateAccountData(syncResponse: ISyncResponse): void { if (!syncResponse.account_data || !syncResponse.account_data.events) { return; } @@ -109,34 +231,31 @@ export class SyncAccumulator { * @param {Object} syncResponse the complete /sync JSON * @param {boolean} fromDatabase True if the sync response is one saved to the database */ - _accumulateRooms(syncResponse, fromDatabase) { + private accumulateRooms(syncResponse: ISyncResponse, fromDatabase = false): void { if (!syncResponse.rooms) { return; } if (syncResponse.rooms.invite) { Object.keys(syncResponse.rooms.invite).forEach((roomId) => { - this._accumulateRoom( - roomId, "invite", syncResponse.rooms.invite[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Invite, syncResponse.rooms.invite[roomId], fromDatabase); }); } if (syncResponse.rooms.join) { Object.keys(syncResponse.rooms.join).forEach((roomId) => { - this._accumulateRoom( - roomId, "join", syncResponse.rooms.join[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Join, syncResponse.rooms.join[roomId], fromDatabase); }); } if (syncResponse.rooms.leave) { Object.keys(syncResponse.rooms.leave).forEach((roomId) => { - this._accumulateRoom( - roomId, "leave", syncResponse.rooms.leave[roomId], fromDatabase, - ); + this.accumulateRoom(roomId, Category.Leave, syncResponse.rooms.leave[roomId], fromDatabase); }); } } - _accumulateRoom(roomId, category, data, fromDatabase) { + private accumulateRoom(roomId: string, category: Category.Invite, data: IInvitedRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category.Join, data: IJoinedRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category.Leave, data: ILeftRoom, fromDatabase: boolean): void; + private accumulateRoom(roomId: string, category: Category, data: any, fromDatabase = false): void { // Valid /sync state transitions // +--------+ <======+ 1: Accept an invite // +== | INVITE | | (5) 2: Leave a room @@ -149,10 +268,11 @@ export class SyncAccumulator { // // * equivalent to "no state" switch (category) { - case "invite": // (5) - this._accumulateInviteState(roomId, data); + case Category.Invite: // (5) + this.accumulateInviteState(roomId, data as IInvitedRoom); break; - case "join": + + case Category.Join: if (this.inviteRooms[roomId]) { // (1) // was previously invite, now join. We expect /sync to give // the entire state and timeline on 'join', so delete previous @@ -160,21 +280,23 @@ export class SyncAccumulator { delete this.inviteRooms[roomId]; } // (3) - this._accumulateJoinState(roomId, data, fromDatabase); + this.accumulateJoinState(roomId, data as IJoinedRoom, fromDatabase); break; - case "leave": + + case Category.Leave: if (this.inviteRooms[roomId]) { // (4) delete this.inviteRooms[roomId]; } else { // (2) delete this.joinRooms[roomId]; } break; + default: logger.error("Unknown cateogory: ", category); } } - _accumulateInviteState(roomId, data) { + private accumulateInviteState(roomId: string, data: IInvitedRoom): void { if (!data.invite_state || !data.invite_state.events) { // no new data return; } @@ -204,7 +326,7 @@ export class SyncAccumulator { } // Accumulate timeline and state events in a room. - _accumulateJoinState(roomId, data, fromDatabase) { + private accumulateJoinState(roomId: string, data: IJoinedRoom, fromDatabase = false): void { // We expect this function to be called a lot (every /sync) so we want // this to be fast. /sync stores events in an array but we often want // to clobber based on type/state_key. Rather than convert arrays to @@ -338,7 +460,7 @@ export class SyncAccumulator { setState(currentData._currentState, e); // append the event to the timeline. The back-pagination token // corresponds to the first event in the timeline - let transformedEvent; + let transformedEvent: IRoomEvent & { _localTs?: number }; if (!fromDatabase) { transformedEvent = Object.assign({}, e); if (transformedEvent.unsigned !== undefined) { @@ -379,35 +501,29 @@ export class SyncAccumulator { * Accumulate incremental /sync group data. * @param {Object} syncResponse the complete /sync JSON */ - _accumulateGroups(syncResponse) { + private accumulateGroups(syncResponse: ISyncResponse): void { if (!syncResponse.groups) { return; } if (syncResponse.groups.invite) { Object.keys(syncResponse.groups.invite).forEach((groupId) => { - this._accumulateGroup( - groupId, "invite", syncResponse.groups.invite[groupId], - ); + this.accumulateGroup(groupId, Category.Invite, syncResponse.groups.invite[groupId]); }); } if (syncResponse.groups.join) { Object.keys(syncResponse.groups.join).forEach((groupId) => { - this._accumulateGroup( - groupId, "join", syncResponse.groups.join[groupId], - ); + this.accumulateGroup(groupId, Category.Join, syncResponse.groups.join[groupId]); }); } if (syncResponse.groups.leave) { Object.keys(syncResponse.groups.leave).forEach((groupId) => { - this._accumulateGroup( - groupId, "leave", syncResponse.groups.leave[groupId], - ); + this.accumulateGroup(groupId, Category.Leave, syncResponse.groups.leave[groupId]); }); } } - _accumulateGroup(groupId, category, data) { - for (const cat of ['invite', 'join', 'leave']) { + private accumulateGroup(groupId: string, category: Category, data: object): void { + for (const cat of [Category.Invite, Category.Leave, Category.Join]) { delete this.groups[cat][groupId]; } this.groups[category][groupId] = data; @@ -428,7 +544,7 @@ export class SyncAccumulator { * /sync response from the 'rooms' key onwards. The "accountData" key is * a list of raw events which represent global account data. */ - getJSON(forDatabase) { + public getJSON(forDatabase = false): object { const data = { join: {}, invite: {}, @@ -501,14 +617,14 @@ export class SyncAccumulator { roomJson.timeline.prev_batch = msgData.token; } - let transformedEvent; - if (!forDatabase && msgData.event._localTs) { + let transformedEvent: (IRoomEvent | IStateEvent) & { _localTs?: number }; + if (!forDatabase && msgData.event["_localTs"]) { // This means we have to copy each event so we can fix it up to // set a correct 'age' parameter whilst keeping the local timestamp // on our stored event. If this turns out to be a bottleneck, it could // be optimised either by doing this in the main process after the data // has been structured-cloned to go between the worker & main process, - // or special-casing data from saved syncs to read the local timstamp + // or special-casing data from saved syncs to read the local timestamp // directly rather than turning it into age to then immediately be // transformed back again into a local timestamp. transformedEvent = Object.assign({}, msgData.event); @@ -517,7 +633,7 @@ export class SyncAccumulator { } delete transformedEvent._localTs; transformedEvent.unsigned = transformedEvent.unsigned || {}; - transformedEvent.unsigned.age = Date.now() - msgData.event._localTs; + transformedEvent.unsigned.age = Date.now() - msgData.event["_localTs"]; } else { transformedEvent = msgData.event; } @@ -575,17 +691,17 @@ export class SyncAccumulator { }; } - getNextBatchToken() { + public getNextBatchToken(): string { return this.nextBatch; } } -function setState(eventMap, event) { - if (event.state_key === null || event.state_key === undefined || !event.type) { +function setState(eventMap: Record>, event: IRoomEvent | IStateEvent): void { + if ((event as IStateEvent).state_key === null || (event as IStateEvent).state_key === undefined || !event.type) { return; } if (!eventMap[event.type]) { eventMap[event.type] = Object.create(null); } - eventMap[event.type][event.state_key] = event; + eventMap[event.type][(event as IStateEvent).state_key] = event as IStateEvent; } diff --git a/src/sync.js b/src/sync.js deleted file mode 100644 index 9929629c2..000000000 --- a/src/sync.js +++ /dev/null @@ -1,1710 +0,0 @@ -/* -Copyright 2015, 2016 OpenMarket Ltd -Copyright 2017 Vector Creations Ltd -Copyright 2018 New Vector Ltd -Copyright 2019 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. -*/ - -/* - * TODO: - * This class mainly serves to take all the syncing logic out of client.js and - * into a separate file. It's all very fluid, and this class gut wrenches a lot - * of MatrixClient props (e.g. http). Given we want to support WebSockets as - * an alternative syncing API, we may want to have a proper syncing interface - * for HTTP and WS at some point. - */ - -import { User } from "./models/user"; -import { Room } from "./models/room"; -import { Group } from "./models/group"; -import * as utils from "./utils"; -import { Filter } from "./filter"; -import { EventTimeline } from "./models/event-timeline"; -import { PushProcessor } from "./pushprocessor"; -import { logger } from './logger'; -import { InvalidStoreError } from './errors'; - -const DEBUG = true; - -// /sync requests allow you to set a timeout= but the request may continue -// beyond that and wedge forever, so we need to track how long we are willing -// to keep open the connection. This constant is *ADDED* to the timeout= value -// to determine the max time we're willing to wait. -const BUFFER_PERIOD_MS = 80 * 1000; - -// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed -// to RECONNECTING. This is needed to inform the client of server issues when the -// keepAlive is successful but the server /sync fails. -const FAILED_SYNC_ERROR_THRESHOLD = 3; - -function getFilterName(userId, suffix) { - // scope this on the user ID because people may login on many accounts - // and they all need to be stored! - return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); -} - -function debuglog(...params) { - if (!DEBUG) { - return; - } - logger.log(...params); -} - -/** - * Internal class - unstable. - * Construct an entity which is able to sync with a homeserver. - * @constructor - * @param {MatrixClient} client The matrix client instance to use. - * @param {Object} opts Config options - * @param {module:crypto=} opts.crypto Crypto manager - * @param {Function=} opts.canResetEntireTimeline A function which is called - * with a room ID and returns a boolean. It should return 'true' if the SDK can - * SAFELY remove events from this room. It may not be safe to remove events if - * there are other references to the timelines for this room. - * Default: returns false. - * @param {Boolean=} opts.disablePresence True to perform syncing without automatically - * updating presence. - */ -export function SyncApi(client, opts) { - this.client = client; - opts = opts || {}; - opts.initialSyncLimit = ( - opts.initialSyncLimit === undefined ? 8 : opts.initialSyncLimit - ); - opts.resolveInvitesToProfiles = opts.resolveInvitesToProfiles || false; - opts.pollTimeout = opts.pollTimeout || (30 * 1000); - opts.pendingEventOrdering = opts.pendingEventOrdering || "chronological"; - if (!opts.canResetEntireTimeline) { - opts.canResetEntireTimeline = function(roomId) { - return false; - }; - } - this.opts = opts; - this._peekRoom = null; - this._currentSyncRequest = null; - this._syncState = null; - this._syncStateData = null; // additional data (eg. error object for failed sync) - this._catchingUp = false; - this._running = false; - this._keepAliveTimer = null; - this._connectionReturnedDefer = null; - this._notifEvents = []; // accumulator of sync events in the current sync response - this._failedSyncCount = 0; // Number of consecutive failed /sync requests - this._storeIsInvalid = false; // flag set if the store needs to be cleared before we can start - - if (client.getNotifTimelineSet()) { - client.reEmitter.reEmit(client.getNotifTimelineSet(), - ["Room.timeline", "Room.timelineReset"]); - } -} - -/** - * @param {string} roomId - * @return {Room} - */ -SyncApi.prototype.createRoom = function(roomId) { - const client = this.client; - const { - timelineSupport, - unstableClientRelationAggregation, - } = client; - const room = new Room(roomId, client, client.getUserId(), { - lazyLoadMembers: this.opts.lazyLoadMembers, - pendingEventOrdering: this.opts.pendingEventOrdering, - timelineSupport, - unstableClientRelationAggregation, - }); - client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", - "Room.redaction", - "Room.redactionCancelled", - "Room.receipt", "Room.tags", - "Room.timelineReset", - "Room.localEchoUpdated", - "Room.accountData", - "Room.myMembership", - "Room.replaceEvent", - ]); - this._registerStateListeners(room); - return room; -}; - -/** - * @param {string} groupId - * @return {Group} - */ -SyncApi.prototype.createGroup = function(groupId) { - const client = this.client; - const group = new Group(groupId); - client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); - client.store.storeGroup(group); - return group; -}; - -/** - * @param {Room} room - * @private - */ -SyncApi.prototype._registerStateListeners = function(room) { - const client = this.client; - // we need to also re-emit room state and room member events, so hook it up - // to the client now. We need to add a listener for RoomState.members in - // order to hook them correctly. (TODO: find a better way?) - client.reEmitter.reEmit(room.currentState, [ - "RoomState.events", "RoomState.members", "RoomState.newMember", - ]); - room.currentState.on("RoomState.newMember", function(event, state, member) { - member.user = client.getUser(member.userId); - client.reEmitter.reEmit( - member, - [ - "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", - "RoomMember.membership", - ], - ); - }); -}; - -/** - * @param {Room} room - * @private - */ -SyncApi.prototype._deregisterStateListeners = function(room) { - // could do with a better way of achieving this. - room.currentState.removeAllListeners("RoomState.events"); - room.currentState.removeAllListeners("RoomState.members"); - room.currentState.removeAllListeners("RoomState.newMember"); -}; - -/** - * Sync rooms the user has left. - * @return {Promise} Resolved when they've been added to the store. - */ -SyncApi.prototype.syncLeftRooms = function() { - const client = this.client; - const self = this; - - // grab a filter with limit=1 and include_leave=true - const filter = new Filter(this.client.credentials.userId); - filter.setTimelineLimit(1); - filter.setIncludeLeaveRooms(true); - - const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; - const qps = { - timeout: 0, // don't want to block since this is a single isolated req - }; - - return client.getOrCreateFilter( - getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, - ).then(function(filterId) { - qps.filter = filterId; - return client.http.authedRequest( - undefined, "GET", "/sync", qps, undefined, localTimeoutMs, - ); - }).then(function(data) { - let leaveRooms = []; - if (data.rooms && data.rooms.leave) { - leaveRooms = self._mapSyncResponseToRoomArray(data.rooms.leave); - } - const rooms = []; - leaveRooms.forEach(function(leaveObj) { - const room = leaveObj.room; - rooms.push(room); - if (!leaveObj.isBrandNewRoom) { - // the intention behind syncLeftRooms is to add in rooms which were - // *omitted* from the initial /sync. Rooms the user were joined to - // but then left whilst the app is running will appear in this list - // and we do not want to bother with them since they will have the - // current state already (and may get dupe messages if we add - // yet more timeline events!), so skip them. - // NB: When we persist rooms to localStorage this will be more - // complicated... - return; - } - leaveObj.timeline = leaveObj.timeline || {}; - const timelineEvents = - self._mapSyncEventsFormat(leaveObj.timeline, room); - const stateEvents = self._mapSyncEventsFormat(leaveObj.state, room); - - // set the back-pagination token. Do this *before* adding any - // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, - EventTimeline.BACKWARDS); - - self._processRoomEvents(room, stateEvents, timelineEvents); - - room.recalculate(); - client.store.storeRoom(room); - client.emit("Room", room); - - self._processEventsForNotifs(room, timelineEvents); - }); - return rooms; - }); -}; - -/** - * Peek into a room. This will result in the room in question being synced so it - * is accessible via getRooms(). Live updates for the room will be provided. - * @param {string} roomId The room ID to peek into. - * @return {Promise} A promise which resolves once the room has been added to the - * store. - */ -SyncApi.prototype.peek = function(roomId) { - if (this._peekRoom && this._peekRoom.roomId === roomId) { - return Promise.resolve(this._peekRoom); - } - - const client = this.client; - this._peekRoom = this.createRoom(roomId); - return this.client.roomInitialSync(roomId, 20).then((response) => { - // make sure things are init'd - response.messages = response.messages || {}; - response.messages.chunk = response.messages.chunk || []; - response.state = response.state || []; - - // FIXME: Mostly duplicated from _processRoomEvents but not entirely - // because "state" in this API is at the BEGINNING of the chunk - const oldStateEvents = utils.deepCopy(response.state) - .map(client.getEventMapper()); - const stateEvents = response.state.map(client.getEventMapper()); - const messages = response.messages.chunk.map(client.getEventMapper()); - - // XXX: copypasted from /sync until we kill off this - // minging v1 API stuff) - // handle presence events (User objects) - if (response.presence && Array.isArray(response.presence)) { - response.presence.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit("event", presenceEvent); - }); - } - - // set the pagination token before adding the events in case people - // fire off pagination requests in response to the Room.timeline - // events. - if (response.messages.start) { - this._peekRoom.oldState.paginationToken = response.messages.start; - } - - // set the state of the room to as it was after the timeline executes - this._peekRoom.oldState.setStateEvents(oldStateEvents); - this._peekRoom.currentState.setStateEvents(stateEvents); - - this._resolveInvites(this._peekRoom); - this._peekRoom.recalculate(); - - // roll backwards to diverge old state. addEventsToTimeline - // will overwrite the pagination token, so make sure it overwrites - // it with the right thing. - this._peekRoom.addEventsToTimeline(messages.reverse(), true, - this._peekRoom.getLiveTimeline(), - response.messages.start); - - client.store.storeRoom(this._peekRoom); - client.emit("Room", this._peekRoom); - - this._peekPoll(this._peekRoom); - return this._peekRoom; - }); -}; - -/** - * Stop polling for updates in the peeked room. NOPs if there is no room being - * peeked. - */ -SyncApi.prototype.stopPeeking = function() { - this._peekRoom = null; -}; - -/** - * Do a peek room poll. - * @param {Room} peekRoom - * @param {string?} token from= token - */ -SyncApi.prototype._peekPoll = function(peekRoom, token) { - if (this._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - - const self = this; - // FIXME: gut wrenching; hard-coded timeout values - this.client.http.authedRequest(undefined, "GET", "/events", { - room_id: peekRoom.roomId, - timeout: 30 * 1000, - from: token, - }, undefined, 50 * 1000).then(function(res) { - if (self._peekRoom !== peekRoom) { - debuglog("Stopped peeking in room %s", peekRoom.roomId); - return; - } - // We have a problem that we get presence both from /events and /sync - // however, /sync only returns presence for users in rooms - // you're actually joined to. - // in order to be sure to get presence for all of the users in the - // peeked room, we handle presence explicitly here. This may result - // in duplicate presence events firing for some users, which is a - // performance drain, but such is life. - // XXX: copypasted from /sync until we can kill this minging v1 stuff. - - res.chunk.filter(function(e) { - return e.type === "m.presence"; - }).map(self.client.getEventMapper()).forEach(function(presenceEvent) { - let user = self.client.store.getUser(presenceEvent.getContent().user_id); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(self.client, presenceEvent.getContent().user_id); - user.setPresenceEvent(presenceEvent); - self.client.store.storeUser(user); - } - self.client.emit("event", presenceEvent); - }); - - // strip out events which aren't for the given room_id (e.g presence) - // and also ephemeral events (which we're assuming is anything without - // and event ID because the /events API doesn't separate them). - const events = res.chunk.filter(function(e) { - return e.room_id === peekRoom.roomId && e.event_id; - }).map(self.client.getEventMapper()); - - peekRoom.addLiveEvents(events); - self._peekPoll(peekRoom, res.end); - }, function(err) { - logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); - setTimeout(function() { - self._peekPoll(peekRoom, token); - }, 30 * 1000); - }); -}; - -/** - * Returns the current state of this sync object - * @see module:client~MatrixClient#event:"sync" - * @return {?String} - */ -SyncApi.prototype.getSyncState = function() { - return this._syncState; -}; - -/** - * Returns the additional data object associated with - * the current sync state, or null if there is no - * such data. - * Sync errors, if available, are put in the 'error' key of - * this object. - * @return {?Object} - */ -SyncApi.prototype.getSyncStateData = function() { - return this._syncStateData; -}; - -SyncApi.prototype.recoverFromSyncStartupError = async function(savedSyncPromise, err) { - // Wait for the saved sync to complete - we send the pushrules and filter requests - // before the saved sync has finished so they can run in parallel, but only process - // the results after the saved sync is done. Equivalently, we wait for it to finish - // before reporting failures from these functions. - await savedSyncPromise; - const keepaliveProm = this._startKeepAlives(); - this._updateSyncState("ERROR", { error: err }); - await keepaliveProm; -}; - -/** - * Is the lazy loading option different than in previous session? - * @param {bool} lazyLoadMembers current options for lazy loading - * @return {bool} whether or not the option has changed compared to the previous session */ -SyncApi.prototype._wasLazyLoadingToggled = async function(lazyLoadMembers) { - lazyLoadMembers = !!lazyLoadMembers; - // assume it was turned off before - // if we don't know any better - let lazyLoadMembersBefore = false; - const isStoreNewlyCreated = await this.client.store.isNewlyCreated(); - if (!isStoreNewlyCreated) { - const prevClientOptions = await this.client.store.getClientOptions(); - if (prevClientOptions) { - lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; - } - return lazyLoadMembersBefore !== lazyLoadMembers; - } - return false; -}; - -SyncApi.prototype._shouldAbortSync = function(error) { - if (error.errcode === "M_UNKNOWN_TOKEN") { - // The logout already happened, we just need to stop. - logger.warn("Token no longer valid - assuming logout"); - this.stop(); - return true; - } - return false; -}; - -/** - * Main entry point - */ -SyncApi.prototype.sync = function() { - const client = this.client; - const self = this; - - this._running = true; - - if (global.window && global.window.addEventListener) { - this._onOnlineBound = this._onOnline.bind(this); - global.window.addEventListener("online", this._onOnlineBound, false); - } - - let savedSyncPromise = Promise.resolve(); - let savedSyncToken = null; - - // We need to do one-off checks before we can begin the /sync loop. - // These are: - // 1) We need to get push rules so we can check if events should bing as we get - // them from /sync. - // 2) We need to get/create a filter which we can use for /sync. - // 3) We need to check the lazy loading option matches what was used in the - // stored sync. If it doesn't, we can't use the stored sync. - - async function getPushRules() { - try { - debuglog("Getting push rules..."); - const result = await client.getPushRules(); - debuglog("Got push rules"); - - client.pushRules = result; - } catch (err) { - logger.error("Getting push rules failed", err); - if (self._shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying push rules..."); - await self.recoverFromSyncStartupError(savedSyncPromise, err); - getPushRules(); - return; - } - checkLazyLoadStatus(); // advance to the next stage - } - - function buildDefaultFilter() { - const filter = new Filter(client.credentials.userId); - filter.setTimelineLimit(self.opts.initialSyncLimit); - return filter; - } - - const checkLazyLoadStatus = async () => { - debuglog("Checking lazy load status..."); - if (this.opts.lazyLoadMembers && client.isGuest()) { - this.opts.lazyLoadMembers = false; - } - if (this.opts.lazyLoadMembers) { - debuglog("Checking server lazy load support..."); - const supported = await client.doesServerSupportLazyLoading(); - if (supported) { - debuglog("Enabling lazy load on sync filter..."); - if (!this.opts.filter) { - this.opts.filter = buildDefaultFilter(); - } - this.opts.filter.setLazyLoadMembers(true); - } else { - debuglog("LL: lazy loading requested but not supported " + - "by server, so disabling"); - this.opts.lazyLoadMembers = false; - } - } - // need to vape the store when enabling LL and wasn't enabled before - debuglog("Checking whether lazy loading has changed in store..."); - const shouldClear = await this._wasLazyLoadingToggled(this.opts.lazyLoadMembers); - if (shouldClear) { - this._storeIsInvalid = true; - const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; - const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); - this._updateSyncState("ERROR", { error }); - // bail out of the sync loop now: the app needs to respond to this error. - // we leave the state as 'ERROR' which isn't great since this normally means - // we're retrying. The client must be stopped before clearing the stores anyway - // so the app should stop the client, clear the store and start it again. - logger.warn("InvalidStoreError: store is not usable: stopping sync."); - return; - } - if (this.opts.lazyLoadMembers && this.opts.crypto) { - this.opts.crypto.enableLazyLoading(); - } - try { - debuglog("Storing client options..."); - await this.client.storeClientOptions(); - debuglog("Stored client options"); - } catch (err) { - logger.error("Storing client options failed", err); - throw err; - } - - getFilter(); // Now get the filter and start syncing - }; - - async function getFilter() { - debuglog("Getting filter..."); - let filter; - if (self.opts.filter) { - filter = self.opts.filter; - } else { - filter = buildDefaultFilter(); - } - - let filterId; - try { - filterId = await client.getOrCreateFilter( - getFilterName(client.credentials.userId), filter, - ); - } catch (err) { - logger.error("Getting filter failed", err); - if (self._shouldAbortSync(err)) return; - // wait for saved sync to complete before doing anything else, - // otherwise the sync state will end up being incorrect - debuglog("Waiting for saved sync before retrying filter..."); - await self.recoverFromSyncStartupError(savedSyncPromise, err); - getFilter(); - return; - } - // reset the notifications timeline to prepare it to paginate from - // the current point in time. - // The right solution would be to tie /sync pagination tokens into - // /notifications API somehow. - client.resetNotifTimelineSet(); - - if (self._currentSyncRequest === null) { - // Send this first sync request here so we can then wait for the saved - // sync data to finish processing before we process the results of this one. - debuglog("Sending first sync request..."); - self._currentSyncRequest = self._doSyncRequest({ filterId }, savedSyncToken); - } - - // Now wait for the saved sync to finish... - debuglog("Waiting for saved sync before starting sync processing..."); - await savedSyncPromise; - self._sync({ filterId }); - } - - if (client.isGuest()) { - // no push rules for guests, no access to POST filter for guests. - self._sync({}); - } else { - // Pull the saved sync token out first, before the worker starts sending - // all the sync data which could take a while. This will let us send our - // first incremental sync request before we've processed our saved data. - debuglog("Getting saved sync token..."); - savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { - debuglog("Got saved sync token"); - savedSyncToken = tok; - debuglog("Getting saved sync..."); - return client.store.getSavedSync(); - }).then((savedSync) => { - debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); - if (savedSync) { - return self._syncFromCache(savedSync); - } - }).catch(err => { - logger.error("Getting saved sync failed", err); - }); - // Now start the first incremental sync request: this can also - // take a while so if we set it going now, we can wait for it - // to finish while we process our saved sync data. - getPushRules(); - } -}; - -/** - * Stops the sync object from syncing. - */ -SyncApi.prototype.stop = function() { - debuglog("SyncApi.stop"); - if (global.window) { - global.window.removeEventListener("online", this._onOnlineBound, false); - this._onOnlineBound = undefined; - } - this._running = false; - if (this._currentSyncRequest) { - this._currentSyncRequest.abort(); - } - if (this._keepAliveTimer) { - clearTimeout(this._keepAliveTimer); - this._keepAliveTimer = null; - } -}; - -/** - * Retry a backed off syncing request immediately. This should only be used when - * the user explicitly attempts to retry their lost connection. - * @return {boolean} True if this resulted in a request being retried. - */ -SyncApi.prototype.retryImmediately = function() { - if (!this._connectionReturnedDefer) { - return false; - } - this._startKeepAlives(0); - return true; -}; -/** - * Process a single set of cached sync data. - * @param {Object} savedSync a saved sync that was persisted by a store. This - * should have been acquired via client.store.getSavedSync(). - */ -SyncApi.prototype._syncFromCache = async function(savedSync) { - debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); - - const nextSyncToken = savedSync.nextBatch; - - // Set sync token for future incremental syncing - this.client.store.setSyncToken(nextSyncToken); - - // No previous sync, set old token to null - const syncEventData = { - oldSyncToken: null, - nextSyncToken, - catchingUp: false, - fromCache: true, - }; - - const data = { - next_batch: nextSyncToken, - rooms: savedSync.roomsData, - groups: savedSync.groupsData, - account_data: { - events: savedSync.accountData, - }, - }; - - try { - await this._processSyncResponse(syncEventData, data); - } catch (e) { - logger.error("Error processing cached sync", e.stack || e); - } - - // Don't emit a prepared if we've bailed because the store is invalid: - // in this case the client will not be usable until stopped & restarted - // so this would be useless and misleading. - if (!this._storeIsInvalid) { - this._updateSyncState("PREPARED", syncEventData); - } -}; - -/** - * Invoke me to do /sync calls - * @param {Object} syncOptions - * @param {string} syncOptions.filterId - * @param {boolean} syncOptions.hasSyncedBefore - */ -SyncApi.prototype._sync = async function(syncOptions) { - const client = this.client; - - if (!this._running) { - debuglog("Sync no longer running: exiting."); - if (this._connectionReturnedDefer) { - this._connectionReturnedDefer.reject(); - this._connectionReturnedDefer = null; - } - this._updateSyncState("STOPPED"); - return; - } - - const syncToken = client.store.getSyncToken(); - - let data; - try { - //debuglog('Starting sync since=' + syncToken); - if (this._currentSyncRequest === null) { - this._currentSyncRequest = this._doSyncRequest(syncOptions, syncToken); - } - data = await this._currentSyncRequest; - } catch (e) { - this._onSyncError(e, syncOptions); - return; - } finally { - this._currentSyncRequest = null; - } - - //debuglog('Completed sync, next_batch=' + data.next_batch); - - // set the sync token NOW *before* processing the events. We do this so - // if something barfs on an event we can skip it rather than constantly - // polling with the same token. - client.store.setSyncToken(data.next_batch); - - // Reset after a successful sync - this._failedSyncCount = 0; - - await client.store.setSyncData(data); - - const syncEventData = { - oldSyncToken: syncToken, - nextSyncToken: data.next_batch, - catchingUp: this._catchingUp, - }; - - if (this.opts.crypto) { - // tell the crypto module we're about to process a sync - // response - await this.opts.crypto.onSyncWillProcess(syncEventData); - } - - try { - await this._processSyncResponse(syncEventData, data); - } catch (e) { - // log the exception with stack if we have it, else fall back - // to the plain description - logger.error("Caught /sync error", e.stack || e); - - // Emit the exception for client handling - this.client.emit("sync.unexpectedError", e); - } - - // update this as it may have changed - syncEventData.catchingUp = this._catchingUp; - - // emit synced events - if (!syncOptions.hasSyncedBefore) { - this._updateSyncState("PREPARED", syncEventData); - syncOptions.hasSyncedBefore = true; - } - - // tell the crypto module to do its processing. It may block (to do a - // /keys/changes request). - if (this.opts.crypto) { - await this.opts.crypto.onSyncCompleted(syncEventData); - } - - // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates - this._updateSyncState("SYNCING", syncEventData); - - if (client.store.wantsSave()) { - // We always save the device list (if it's dirty) before saving the sync data: - // this means we know the saved device list data is at least as fresh as the - // stored sync data which means we don't have to worry that we may have missed - // device changes. We can also skip the delay since we're not calling this very - // frequently (and we don't really want to delay the sync for it). - if (this.opts.crypto) { - await this.opts.crypto.saveDeviceList(0); - } - - // tell databases that everything is now in a consistent state and can be saved. - client.store.save(); - } - - // Begin next sync - this._sync(syncOptions); -}; - -SyncApi.prototype._doSyncRequest = function(syncOptions, syncToken) { - const qps = this._getSyncParams(syncOptions, syncToken); - return this.client.http.authedRequest( - undefined, "GET", "/sync", qps, undefined, - qps.timeout + BUFFER_PERIOD_MS, - ); -}; - -SyncApi.prototype._getSyncParams = function(syncOptions, syncToken) { - let pollTimeout = this.opts.pollTimeout; - - if (this.getSyncState() !== 'SYNCING' || this._catchingUp) { - // unless we are happily syncing already, we want the server to return - // as quickly as possible, even if there are no events queued. This - // serves two purposes: - // - // * When the connection dies, we want to know asap when it comes back, - // so that we can hide the error from the user. (We don't want to - // have to wait for an event or a timeout). - // - // * We want to know if the server has any to_device messages queued up - // for us. We do that by calling it with a zero timeout until it - // doesn't give us any more to_device messages. - this._catchingUp = true; - pollTimeout = 0; - } - - let filterId = syncOptions.filterId; - if (this.client.isGuest() && !filterId) { - filterId = this._getGuestFilter(); - } - - const qps = { - filter: filterId, - timeout: pollTimeout, - }; - - if (this.opts.disablePresence) { - qps.set_presence = "offline"; - } - - if (syncToken) { - qps.since = syncToken; - } else { - // use a cachebuster for initialsyncs, to make sure that - // we don't get a stale sync - // (https://github.com/vector-im/vector-web/issues/1354) - qps._cacheBuster = Date.now(); - } - - if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { - // we think the connection is dead. If it comes back up, we won't know - // about it till /sync returns. If the timeout= is high, this could - // be a long time. Set it to 0 when doing retries so we don't have to wait - // for an event or a timeout before emiting the SYNCING event. - qps.timeout = 0; - } - - return qps; -}; - -SyncApi.prototype._onSyncError = function(err, syncOptions) { - if (!this._running) { - debuglog("Sync no longer running: exiting"); - if (this._connectionReturnedDefer) { - this._connectionReturnedDefer.reject(); - this._connectionReturnedDefer = null; - } - this._updateSyncState("STOPPED"); - return; - } - - logger.error("/sync error %s", err); - logger.error(err); - - if (this._shouldAbortSync(err)) { - return; - } - - this._failedSyncCount++; - logger.log('Number of consecutive failed sync requests:', this._failedSyncCount); - - debuglog("Starting keep-alive"); - // Note that we do *not* mark the sync connection as - // lost yet: we only do this if a keepalive poke - // fails, since long lived HTTP connections will - // go away sometimes and we shouldn't treat this as - // erroneous. We set the state to 'reconnecting' - // instead, so that clients can observe this state - // if they wish. - this._startKeepAlives().then((connDidFail) => { - // Only emit CATCHUP if we detected a connectivity error: if we didn't, - // it's quite likely the sync will fail again for the same reason and we - // want to stay in ERROR rather than keep flip-flopping between ERROR - // and CATCHUP. - if (connDidFail && this.getSyncState() === 'ERROR') { - this._updateSyncState("CATCHUP", { - oldSyncToken: null, - nextSyncToken: null, - catchingUp: true, - }); - } - this._sync(syncOptions); - }); - - this._currentSyncRequest = null; - // Transition from RECONNECTING to ERROR after a given number of failed syncs - this._updateSyncState( - this._failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? - "ERROR" : "RECONNECTING", - { error: err }, - ); -}; - -/** - * Process data returned from a sync response and propagate it - * into the model objects - * - * @param {Object} syncEventData Object containing sync tokens associated with this sync - * @param {Object} data The response from /sync - */ -SyncApi.prototype._processSyncResponse = async function( - syncEventData, data, -) { - const client = this.client; - const self = this; - - // data looks like: - // { - // next_batch: $token, - // presence: { events: [] }, - // account_data: { events: [] }, - // device_lists: { changed: ["@user:server", ... ]}, - // to_device: { events: [] }, - // device_one_time_keys_count: { signed_curve25519: 42 }, - // rooms: { - // invite: { - // $roomid: { - // invite_state: { events: [] } - // } - // }, - // join: { - // $roomid: { - // state: { events: [] }, - // timeline: { events: [], prev_batch: $token, limited: true }, - // ephemeral: { events: [] }, - // summary: { - // m.heroes: [ $user_id ], - // m.joined_member_count: $count, - // m.invited_member_count: $count - // }, - // account_data: { events: [] }, - // unread_notifications: { - // highlight_count: 0, - // notification_count: 0, - // } - // } - // }, - // leave: { - // $roomid: { - // state: { events: [] }, - // timeline: { events: [], prev_batch: $token } - // } - // } - // }, - // groups: { - // invite: { - // $groupId: { - // inviter: $inviter, - // profile: { - // avatar_url: $avatarUrl, - // name: $groupName, - // }, - // }, - // }, - // join: {}, - // leave: {}, - // }, - // } - - // TODO-arch: - // - Each event we pass through needs to be emitted via 'event', can we - // do this in one place? - // - The isBrandNewRoom boilerplate is boilerplatey. - - // handle presence events (User objects) - if (data.presence && Array.isArray(data.presence.events)) { - data.presence.events.map(client.getEventMapper()).forEach( - function(presenceEvent) { - let user = client.store.getUser(presenceEvent.getSender()); - if (user) { - user.setPresenceEvent(presenceEvent); - } else { - user = createNewUser(client, presenceEvent.getSender()); - user.setPresenceEvent(presenceEvent); - client.store.storeUser(user); - } - client.emit("event", presenceEvent); - }); - } - - // handle non-room account_data - if (data.account_data && Array.isArray(data.account_data.events)) { - const events = data.account_data.events.map(client.getEventMapper()); - const prevEventsMap = events.reduce((m, c) => { - m[c.getId()] = client.store.getAccountData(c.getType()); - return m; - }, {}); - client.store.storeAccountDataEvents(events); - events.forEach( - function(accountDataEvent) { - // Honour push rules that come down the sync stream but also - // honour push rules that were previously cached. Base rules - // will be updated when we receive push rules via getPushRules - // (see SyncApi.prototype.sync) before syncing over the network. - if (accountDataEvent.getType() === 'm.push_rules') { - const rules = accountDataEvent.getContent(); - client.pushRules = PushProcessor.rewriteDefaultRules(rules); - } - const prevEvent = prevEventsMap[accountDataEvent.getId()]; - client.emit("accountData", accountDataEvent, prevEvent); - return accountDataEvent; - }, - ); - } - - // handle to-device events - if (data.to_device && Array.isArray(data.to_device.events) && - data.to_device.events.length > 0 - ) { - const cancelledKeyVerificationTxns = []; - data.to_device.events - .map(client.getEventMapper()) - .map((toDeviceEvent) => { // map is a cheap inline forEach - // We want to flag m.key.verification.start events as cancelled - // if there's an accompanying m.key.verification.cancel event, so - // we pull out the transaction IDs from the cancellation events - // so we can flag the verification events as cancelled in the loop - // below. - if (toDeviceEvent.getType() === "m.key.verification.cancel") { - const txnId = toDeviceEvent.getContent()['transaction_id']; - if (txnId) { - cancelledKeyVerificationTxns.push(txnId); - } - } - - // as mentioned above, .map is a cheap inline forEach, so return - // the unmodified event. - return toDeviceEvent; - }) - .forEach( - function(toDeviceEvent) { - const content = toDeviceEvent.getContent(); - if ( - toDeviceEvent.getType() == "m.room.message" && - content.msgtype == "m.bad.encrypted" - ) { - // the mapper already logged a warning. - logger.log( - 'Ignoring undecryptable to-device event from ' + - toDeviceEvent.getSender(), - ); - return; - } - - if (toDeviceEvent.getType() === "m.key.verification.start" - || toDeviceEvent.getType() === "m.key.verification.request") { - const txnId = content['transaction_id']; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); - } - } - - client.emit("toDeviceEvent", toDeviceEvent); - }, - ); - } else { - // no more to-device events: we can stop polling with a short timeout. - this._catchingUp = false; - } - - if (data.groups) { - if (data.groups.invite) { - this._processGroupSyncEntry(data.groups.invite, 'invite'); - } - - if (data.groups.join) { - this._processGroupSyncEntry(data.groups.join, 'join'); - } - - if (data.groups.leave) { - this._processGroupSyncEntry(data.groups.leave, 'leave'); - } - } - - // the returned json structure is a bit crap, so make it into a - // nicer form (array) after applying sanity to make sure we don't fail - // on missing keys (on the off chance) - let inviteRooms = []; - let joinRooms = []; - let leaveRooms = []; - - if (data.rooms) { - if (data.rooms.invite) { - inviteRooms = this._mapSyncResponseToRoomArray(data.rooms.invite); - } - if (data.rooms.join) { - joinRooms = this._mapSyncResponseToRoomArray(data.rooms.join); - } - if (data.rooms.leave) { - leaveRooms = this._mapSyncResponseToRoomArray(data.rooms.leave); - } - } - - this._notifEvents = []; - - // Handle invites - inviteRooms.forEach(function(inviteObj) { - const room = inviteObj.room; - const stateEvents = - self._mapSyncEventsFormat(inviteObj.invite_state, room); - - self._processRoomEvents(room, stateEvents); - if (inviteObj.isBrandNewRoom) { - room.recalculate(); - client.store.storeRoom(room); - client.emit("Room", room); - } - stateEvents.forEach(function(e) { - client.emit("event", e); - }); - room.updateMyMembership("invite"); - }); - - // Handle joins - await utils.promiseMapSeries(joinRooms, async function(joinObj) { - const room = joinObj.room; - const stateEvents = self._mapSyncEventsFormat(joinObj.state, room); - // Prevent events from being decrypted ahead of time - // this helps large account to speed up faster - // room::decryptCriticalEvent is in charge of decrypting all the events - // required for a client to function properly - const timelineEvents = self._mapSyncEventsFormat(joinObj.timeline, room, false); - const ephemeralEvents = self._mapSyncEventsFormat(joinObj.ephemeral); - const accountDataEvents = self._mapSyncEventsFormat(joinObj.account_data); - - const encrypted = client.isRoomEncrypted(room.roomId); - // we do this first so it's correct when any of the events fire - if (joinObj.unread_notifications) { - room.setUnreadNotificationCount( - 'total', joinObj.unread_notifications.notification_count, - ); - - // We track unread notifications ourselves in encrypted rooms, so don't - // bother setting it here. We trust our calculations better than the - // server's for this case, and therefore will assume that our non-zero - // count is accurate. - if (!encrypted - || (encrypted && room.getUnreadNotificationCount('highlight') <= 0)) { - room.setUnreadNotificationCount( - 'highlight', joinObj.unread_notifications.highlight_count, - ); - } - } - - joinObj.timeline = joinObj.timeline || {}; - - if (joinObj.isBrandNewRoom) { - // set the back-pagination token. Do this *before* adding any - // events so that clients can start back-paginating. - room.getLiveTimeline().setPaginationToken( - joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); - } else if (joinObj.timeline.limited) { - let limited = true; - - // we've got a limited sync, so we *probably* have a gap in the - // timeline, so should reset. But we might have been peeking or - // paginating and already have some of the events, in which - // case we just want to append any subsequent events to the end - // of the existing timeline. - // - // This is particularly important in the case that we already have - // *all* of the events in the timeline - in that case, if we reset - // the timeline, we'll end up with an entirely empty timeline, - // which we'll try to paginate but not get any new events (which - // will stop us linking the empty timeline into the chain). - // - for (let i = timelineEvents.length - 1; i >= 0; i--) { - const eventId = timelineEvents[i].getId(); - if (room.getTimelineForEvent(eventId)) { - debuglog("Already have event " + eventId + " in limited " + - "sync - not resetting"); - limited = false; - - // we might still be missing some of the events before i; - // we don't want to be adding them to the end of the - // timeline because that would put them out of order. - timelineEvents.splice(0, i); - - // XXX: there's a problem here if the skipped part of the - // timeline modifies the state set in stateEvents, because - // we'll end up using the state from stateEvents rather - // than the later state from timelineEvents. We probably - // need to wind stateEvents forward over the events we're - // skipping. - - break; - } - } - - if (limited) { - self._deregisterStateListeners(room); - room.resetLiveTimeline( - joinObj.timeline.prev_batch, - self.opts.canResetEntireTimeline(room.roomId) ? - null : syncEventData.oldSyncToken, - ); - - // We have to assume any gap in any timeline is - // reason to stop incrementally tracking notifications and - // reset the timeline. - client.resetNotifTimelineSet(); - - self._registerStateListeners(room); - } - } - - self._processRoomEvents(room, stateEvents, - timelineEvents, syncEventData.fromCache); - - // set summary after processing events, - // because it will trigger a name calculation - // which needs the room state to be up to date - if (joinObj.summary) { - room.setSummary(joinObj.summary); - } - - // we deliberately don't add ephemeral events to the timeline - room.addEphemeralEvents(ephemeralEvents); - - // we deliberately don't add accountData to the timeline - room.addAccountData(accountDataEvents); - - room.recalculate(); - if (joinObj.isBrandNewRoom) { - client.store.storeRoom(room); - client.emit("Room", room); - } - - self._processEventsForNotifs(room, timelineEvents); - - async function processRoomEvent(e) { - client.emit("event", e); - if (e.isState() && e.getType() == "m.room.encryption" && self.opts.crypto) { - await self.opts.crypto.onCryptoEvent(e); - } - if (e.isState() && e.getType() === "im.vector.user_status") { - let user = client.store.getUser(e.getStateKey()); - if (user) { - user._unstable_updateStatusMessage(e); - } else { - user = createNewUser(client, e.getStateKey()); - user._unstable_updateStatusMessage(e); - client.store.storeUser(user); - } - } - } - - await utils.promiseMapSeries(stateEvents, processRoomEvent); - await utils.promiseMapSeries(timelineEvents, processRoomEvent); - ephemeralEvents.forEach(function(e) { - client.emit("event", e); - }); - accountDataEvents.forEach(function(e) { - client.emit("event", e); - }); - - room.updateMyMembership("join"); - - // Decrypt only the last message in all rooms to make sure we can generate a preview - // And decrypt all events after the recorded read receipt to ensure an accurate - // notification count - room.decryptCriticalEvents(); - }); - - // Handle leaves (e.g. kicked rooms) - leaveRooms.forEach(function(leaveObj) { - const room = leaveObj.room; - const stateEvents = - self._mapSyncEventsFormat(leaveObj.state, room); - const timelineEvents = - self._mapSyncEventsFormat(leaveObj.timeline, room); - const accountDataEvents = - self._mapSyncEventsFormat(leaveObj.account_data); - - self._processRoomEvents(room, stateEvents, timelineEvents); - room.addAccountData(accountDataEvents); - - room.recalculate(); - if (leaveObj.isBrandNewRoom) { - client.store.storeRoom(room); - client.emit("Room", room); - } - - self._processEventsForNotifs(room, timelineEvents); - - stateEvents.forEach(function(e) { - client.emit("event", e); - }); - timelineEvents.forEach(function(e) { - client.emit("event", e); - }); - accountDataEvents.forEach(function(e) { - client.emit("event", e); - }); - - room.updateMyMembership("leave"); - }); - - // update the notification timeline, if appropriate. - // we only do this for live events, as otherwise we can't order them sanely - // in the timeline relative to ones paginated in by /notifications. - // XXX: we could fix this by making EventTimeline support chronological - // ordering... but it doesn't, right now. - if (syncEventData.oldSyncToken && this._notifEvents.length) { - this._notifEvents.sort(function(a, b) { - return a.getTs() - b.getTs(); - }); - this._notifEvents.forEach(function(event) { - client.getNotifTimelineSet().addLiveEvent(event); - }); - } - - // Handle device list updates - if (data.device_lists) { - if (this.opts.crypto) { - await this.opts.crypto.handleDeviceListChanges( - syncEventData, data.device_lists, - ); - } else { - // FIXME if we *don't* have a crypto module, we still need to - // invalidate the device lists. But that would require a - // substantial bit of rework :/. - } - } - - // Handle one_time_keys_count - if (this.opts.crypto && data.device_one_time_keys_count) { - const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; - this.opts.crypto.updateOneTimeKeyCount(currentCount); - } - if (this.opts.crypto && data["org.matrix.msc2732.device_unused_fallback_key_types"]) { - // The presence of device_unused_fallback_key_types indicates that the - // server supports fallback keys. If there's no unused - // signed_curve25519 fallback key we need a new one. - const unusedFallbackKeys = data["org.matrix.msc2732.device_unused_fallback_key_types"]; - this.opts.crypto.setNeedsNewFallback( - unusedFallbackKeys instanceof Array && - !unusedFallbackKeys.includes("signed_curve25519"), - ); - } -}; - -/** - * Starts polling the connectivity check endpoint - * @param {number} delay How long to delay until the first poll. - * defaults to a short, randomised interval (to prevent - * tightlooping if /versions succeeds but /sync etc. fail). - * @return {promise} which resolves once the connection returns - */ -SyncApi.prototype._startKeepAlives = function(delay) { - if (delay === undefined) { - delay = 2000 + Math.floor(Math.random() * 5000); - } - - if (this._keepAliveTimer !== null) { - clearTimeout(this._keepAliveTimer); - } - const self = this; - if (delay > 0) { - self._keepAliveTimer = setTimeout( - self._pokeKeepAlive.bind(self), - delay, - ); - } else { - self._pokeKeepAlive(); - } - if (!this._connectionReturnedDefer) { - this._connectionReturnedDefer = utils.defer(); - } - return this._connectionReturnedDefer.promise; -}; - -/** - * Make a dummy call to /_matrix/client/versions, to see if the HS is - * reachable. - * - * On failure, schedules a call back to itself. On success, resolves - * this._connectionReturnedDefer. - * - * @param {bool} connDidFail True if a connectivity failure has been detected. Optional. - */ -SyncApi.prototype._pokeKeepAlive = function(connDidFail) { - if (connDidFail === undefined) connDidFail = false; - const self = this; - function success() { - clearTimeout(self._keepAliveTimer); - if (self._connectionReturnedDefer) { - self._connectionReturnedDefer.resolve(connDidFail); - self._connectionReturnedDefer = null; - } - } - - this.client.http.request( - undefined, // callback - "GET", "/_matrix/client/versions", - undefined, // queryParams - undefined, // data - { - prefix: '', - localTimeoutMs: 15 * 1000, - }, - ).then(function() { - success(); - }, function(err) { - if (err.httpStatus == 400 || err.httpStatus == 404) { - // treat this as a success because the server probably just doesn't - // support /versions: point is, we're getting a response. - // We wait a short time though, just in case somehow the server - // is in a mode where it 400s /versions responses and sync etc. - // responses fail, this will mean we don't hammer in a loop. - self._keepAliveTimer = setTimeout(success, 2000); - } else { - connDidFail = true; - self._keepAliveTimer = setTimeout( - self._pokeKeepAlive.bind(self, connDidFail), - 5000 + Math.floor(Math.random() * 5000), - ); - // A keepalive has failed, so we emit the - // error state (whether or not this is the - // first failure). - // Note we do this after setting the timer: - // this lets the unit tests advance the mock - // clock when they get the error. - self._updateSyncState("ERROR", { error: err }); - } - }); -}; - -/** - * @param {Object} groupsSection Groups section object, eg. response.groups.invite - * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') - */ -SyncApi.prototype._processGroupSyncEntry = function(groupsSection, sectionName) { - // Processes entries from 'groups' section of the sync stream - for (const groupId of Object.keys(groupsSection)) { - const groupInfo = groupsSection[groupId]; - let group = this.client.store.getGroup(groupId); - const isBrandNew = group === null; - if (group === null) { - group = this.createGroup(groupId); - } - if (groupInfo.profile) { - group.setProfile( - groupInfo.profile.name, groupInfo.profile.avatar_url, - ); - } - if (groupInfo.inviter) { - group.setInviter({ userId: groupInfo.inviter }); - } - group.setMyMembership(sectionName); - if (isBrandNew) { - // Now we've filled in all the fields, emit the Group event - this.client.emit("Group", group); - } - } -}; - -/** - * @param {Object} obj - * @return {Object[]} - */ -SyncApi.prototype._mapSyncResponseToRoomArray = function(obj) { - // Maps { roomid: {stuff}, roomid: {stuff} } - // to - // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] - const client = this.client; - const self = this; - return Object.keys(obj).map(function(roomId) { - const arrObj = obj[roomId]; - let room = client.store.getRoom(roomId); - let isBrandNewRoom = false; - if (!room) { - room = self.createRoom(roomId); - isBrandNewRoom = true; - } - arrObj.room = room; - arrObj.isBrandNewRoom = isBrandNewRoom; - return arrObj; - }); -}; - -/** - * @param {Object} obj - * @param {Room} room - * @param {bool} decrypt - * @return {MatrixEvent[]} - */ -SyncApi.prototype._mapSyncEventsFormat = function(obj, room, decrypt = true) { - if (!obj || !Array.isArray(obj.events)) { - return []; - } - const mapper = this.client.getEventMapper({ decrypt }); - return obj.events.map(function(e) { - if (room) { - e.room_id = room.roomId; - } - return mapper(e); - }); -}; - -/** - * @param {Room} room - */ -SyncApi.prototype._resolveInvites = function(room) { - if (!room || !this.opts.resolveInvitesToProfiles) { - return; - } - const client = this.client; - // For each invited room member we want to give them a displayname/avatar url - // if they have one (the m.room.member invites don't contain this). - room.getMembersWithMembership("invite").forEach(function(member) { - if (member._requestedProfileInfo) { - return; - } - member._requestedProfileInfo = true; - // try to get a cached copy first. - const user = client.getUser(member.userId); - let promise; - if (user) { - promise = Promise.resolve({ - avatar_url: user.avatarUrl, - displayname: user.displayName, - }); - } else { - promise = client.getProfileInfo(member.userId); - } - promise.then(function(info) { - // slightly naughty by doctoring the invite event but this means all - // the code paths remain the same between invite/join display name stuff - // which is a worthy trade-off for some minor pollution. - const inviteEvent = member.events.member; - if (inviteEvent.getContent().membership !== "invite") { - // between resolving and now they have since joined, so don't clobber - return; - } - inviteEvent.getContent().avatar_url = info.avatar_url; - inviteEvent.getContent().displayname = info.displayname; - // fire listeners - member.setMembershipEvent(inviteEvent, room.currentState); - }, function(err) { - // OH WELL. - }); - }); -}; - -/** - * @param {Room} room - * @param {MatrixEvent[]} stateEventList A list of state events. This is the state - * at the *START* of the timeline list if it is supplied. - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index - * @param {boolean} fromCache whether the sync response came from cache - * is earlier in time. Higher index is later. - */ -SyncApi.prototype._processRoomEvents = function(room, stateEventList, - timelineEventList, fromCache) { - // If there are no events in the timeline yet, initialise it with - // the given state events - const liveTimeline = room.getLiveTimeline(); - const timelineWasEmpty = liveTimeline.getEvents().length == 0; - if (timelineWasEmpty) { - // Passing these events into initialiseState will freeze them, so we need - // to compute and cache the push actions for them now, otherwise sync dies - // with an attempt to assign to read only property. - // XXX: This is pretty horrible and is assuming all sorts of behaviour from - // these functions that it shouldn't be. We should probably either store the - // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise - // find some solution where MatrixEvents are immutable but allow for a cache - // field. - for (const ev of stateEventList) { - this.client.getPushActionsForEvent(ev); - } - liveTimeline.initialiseState(stateEventList); - } - - this._resolveInvites(room); - - // recalculate the room name at this point as adding events to the timeline - // may make notifications appear which should have the right name. - // XXX: This looks suspect: we'll end up recalculating the room once here - // and then again after adding events (_processSyncResponse calls it after - // calling us) even if no state events were added. It also means that if - // one of the room events in timelineEventList is something that needs - // a recalculation (like m.room.name) we won't recalculate until we've - // finished adding all the events, which will cause the notification to have - // the old room name rather than the new one. - room.recalculate(); - - // If the timeline wasn't empty, we process the state events here: they're - // defined as updates to the state before the start of the timeline, so this - // starts to roll the state forward. - // XXX: That's what we *should* do, but this can happen if we were previously - // peeking in a room, in which case we obviously do *not* want to add the - // state events here onto the end of the timeline. Historically, the js-sdk - // has just set these new state events on the old and new state. This seems - // very wrong because there could be events in the timeline that diverge the - // state, in which case this is going to leave things out of sync. However, - // for now I think it;s best to behave the same as the code has done previously. - if (!timelineWasEmpty) { - // XXX: As above, don't do this... - //room.addLiveEvents(stateEventList || []); - // Do this instead... - room.oldState.setStateEvents(stateEventList || []); - room.currentState.setStateEvents(stateEventList || []); - } - // execute the timeline events. This will continue to diverge the current state - // if the timeline has any state events in it. - // This also needs to be done before running push rules on the events as they need - // to be decorated with sender etc. - room.addLiveEvents(timelineEventList || [], null, fromCache); -}; - -/** - * Takes a list of timelineEvents and adds and adds to _notifEvents - * as appropriate. - * This must be called after the room the events belong to has been stored. - * - * @param {Room} room - * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index - * is earlier in time. Higher index is later. - */ -SyncApi.prototype._processEventsForNotifs = function(room, timelineEventList) { - // gather our notifications into this._notifEvents - if (this.client.getNotifTimelineSet()) { - for (let i = 0; i < timelineEventList.length; i++) { - const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]); - if (pushActions && pushActions.notify && - pushActions.tweaks && pushActions.tweaks.highlight) { - this._notifEvents.push(timelineEventList[i]); - } - } - } -}; - -/** - * @return {string} - */ -SyncApi.prototype._getGuestFilter = function() { - // Dev note: This used to be conditional to return a filter of 20 events maximum, but - // the condition never went to the other branch. This is now hardcoded. - return "{}"; -}; - -/** - * Sets the sync state and emits an event to say so - * @param {String} newState The new state string - * @param {Object} data Object of additional data to emit in the event - */ -SyncApi.prototype._updateSyncState = function(newState, data) { - const old = this._syncState; - this._syncState = newState; - this._syncStateData = data; - this.client.emit("sync", this._syncState, old, data); -}; - -/** - * Event handler for the 'online' event - * This event is generally unreliable and precise behaviour - * varies between browsers, so we poll for connectivity too, - * but this might help us reconnect a little faster. - */ -SyncApi.prototype._onOnline = function() { - debuglog("Browser thinks we are back online"); - this._startKeepAlives(0); -}; - -function createNewUser(client, userId) { - const user = new User(userId); - client.reEmitter.reEmit(user, [ - "User.avatarUrl", "User.displayName", "User.presence", - "User.currentlyActive", "User.lastPresenceTs", - ]); - return user; -} - diff --git a/src/sync.ts b/src/sync.ts new file mode 100644 index 000000000..831c7b084 --- /dev/null +++ b/src/sync.ts @@ -0,0 +1,1745 @@ +/* +Copyright 2015 - 2021 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. +*/ + +/* + * TODO: + * This class mainly serves to take all the syncing logic out of client.js and + * into a separate file. It's all very fluid, and this class gut wrenches a lot + * of MatrixClient props (e.g. http). Given we want to support WebSockets as + * an alternative syncing API, we may want to have a proper syncing interface + * for HTTP and WS at some point. + */ + +import { User } from "./models/user"; +import { NotificationCountType, Room } from "./models/room"; +import { Group } from "./models/group"; +import * as utils from "./utils"; +import { IDeferred } from "./utils"; +import { Filter } from "./filter"; +import { EventTimeline } from "./models/event-timeline"; +import { PushProcessor } from "./pushprocessor"; +import { logger } from './logger'; +import { InvalidStoreError } from './errors'; +import { IStoredClientOpts, MatrixClient, PendingEventOrdering } from "./client"; +import { SyncState } from "./sync.api"; +import { + Category, + IInvitedRoom, + IInviteState, + IJoinedRoom, + ILeftRoom, + IStateEvent, + IRoomEvent, + IStrippedState, + ISyncResponse, + ITimeline, + IEphemeral, + IMinimalEvent, +} from "./sync-accumulator"; +import { MatrixEvent } from "./models/event"; +import { MatrixError } from "./http-api"; +import { ISavedSync } from "./store"; + +const DEBUG = true; + +// /sync requests allow you to set a timeout= but the request may continue +// beyond that and wedge forever, so we need to track how long we are willing +// to keep open the connection. This constant is *ADDED* to the timeout= value +// to determine the max time we're willing to wait. +const BUFFER_PERIOD_MS = 80 * 1000; + +// Number of consecutive failed syncs that will lead to a syncState of ERROR as opposed +// to RECONNECTING. This is needed to inform the client of server issues when the +// keepAlive is successful but the server /sync fails. +const FAILED_SYNC_ERROR_THRESHOLD = 3; + +function getFilterName(userId: string, suffix?: string): string { + // scope this on the user ID because people may login on many accounts + // and they all need to be stored! + return "FILTER_SYNC_" + userId + (suffix ? "_" + suffix : ""); +} + +function debuglog(...params) { + if (!DEBUG) { + return; + } + logger.log(...params); +} + +interface ISyncOptions { + filterId?: string; + hasSyncedBefore?: boolean; +} + +export interface ISyncStateData { + error?: Error; + oldSyncToken?: string; + nextSyncToken?: string; + catchingUp?: boolean; + fromCache?: boolean; +} + +interface ISyncParams { + filter?: string; + timeout: number; + since?: string; + // eslint-disable-next-line camelcase + full_state?: boolean; + // eslint-disable-next-line camelcase + set_presence?: "offline" | "online" | "unavailable"; + _cacheBuster?: string | number; // not part of the API itself +} + +// http-api mangles an abort method onto its promises +interface IRequestPromise extends Promise { + abort(): void; +} + +type WrappedRoom = T & { + room: Room; + isBrandNewRoom: boolean; +}; + +/** + * Internal class - unstable. + * Construct an entity which is able to sync with a homeserver. + * @constructor + * @param {MatrixClient} client The matrix client instance to use. + * @param {Object} opts Config options + * @param {module:crypto=} opts.crypto Crypto manager + * @param {Function=} opts.canResetEntireTimeline A function which is called + * with a room ID and returns a boolean. It should return 'true' if the SDK can + * SAFELY remove events from this room. It may not be safe to remove events if + * there are other references to the timelines for this room. + * Default: returns false. + * @param {Boolean=} opts.disablePresence True to perform syncing without automatically + * updating presence. + */ +export class SyncApi { + private _peekRoom: Room = null; + private currentSyncRequest: IRequestPromise = null; + private syncState: SyncState = null; + private syncStateData: ISyncStateData = null; // additional data (eg. error object for failed sync) + private catchingUp = false; + private running = false; + private keepAliveTimer: NodeJS.Timeout = null; + private connectionReturnedDefer: IDeferred = null; + private notifEvents: MatrixEvent[] = []; // accumulator of sync events in the current sync response + private failedSyncCount = 0; // Number of consecutive failed /sync requests + private storeIsInvalid = false; // flag set if the store needs to be cleared before we can start + + constructor(private readonly client: MatrixClient, private readonly opts: Partial = {}) { + this.opts.initialSyncLimit = this.opts.initialSyncLimit ?? 8; + this.opts.resolveInvitesToProfiles = this.opts.resolveInvitesToProfiles || false; + this.opts.pollTimeout = this.opts.pollTimeout || (30 * 1000); + this.opts.pendingEventOrdering = this.opts.pendingEventOrdering || PendingEventOrdering.Chronological; + + if (!opts.canResetEntireTimeline) { + opts.canResetEntireTimeline = (roomId: string) => { + return false; + }; + } + + if (client.getNotifTimelineSet()) { + client.reEmitter.reEmit(client.getNotifTimelineSet(), + ["Room.timeline", "Room.timelineReset"]); + } + } + + /** + * @param {string} roomId + * @return {Room} + */ + public createRoom(roomId: string): Room { + const client = this.client; + const { + timelineSupport, + unstableClientRelationAggregation, + } = client; + const room = new Room(roomId, client, client.getUserId(), { + lazyLoadMembers: this.opts.lazyLoadMembers, + pendingEventOrdering: this.opts.pendingEventOrdering, + timelineSupport, + unstableClientRelationAggregation, + }); + client.reEmitter.reEmit(room, ["Room.name", "Room.timeline", + "Room.redaction", + "Room.redactionCancelled", + "Room.receipt", "Room.tags", + "Room.timelineReset", + "Room.localEchoUpdated", + "Room.accountData", + "Room.myMembership", + "Room.replaceEvent", + ]); + this.registerStateListeners(room); + return room; + } + + /** + * @param {string} groupId + * @return {Group} + */ + public createGroup(groupId: string): Group { + const client = this.client; + const group = new Group(groupId); + client.reEmitter.reEmit(group, ["Group.profile", "Group.myMembership"]); + client.store.storeGroup(group); + return group; + } + + /** + * @param {Room} room + * @private + */ + private registerStateListeners(room: Room): void { + const client = this.client; + // we need to also re-emit room state and room member events, so hook it up + // to the client now. We need to add a listener for RoomState.members in + // order to hook them correctly. (TODO: find a better way?) + client.reEmitter.reEmit(room.currentState, [ + "RoomState.events", "RoomState.members", "RoomState.newMember", + ]); + room.currentState.on("RoomState.newMember", function(event, state, member) { + member.user = client.getUser(member.userId); + client.reEmitter.reEmit( + member, + [ + "RoomMember.name", "RoomMember.typing", "RoomMember.powerLevel", + "RoomMember.membership", + ], + ); + }); + } + + /** + * @param {Room} room + * @private + */ + private deregisterStateListeners(room: Room): void { + // could do with a better way of achieving this. + room.currentState.removeAllListeners("RoomState.events"); + room.currentState.removeAllListeners("RoomState.members"); + room.currentState.removeAllListeners("RoomState.newMember"); + } + + /** + * Sync rooms the user has left. + * @return {Promise} Resolved when they've been added to the store. + */ + public syncLeftRooms() { + const client = this.client; + + // grab a filter with limit=1 and include_leave=true + const filter = new Filter(this.client.credentials.userId); + filter.setTimelineLimit(1); + filter.setIncludeLeaveRooms(true); + + const localTimeoutMs = this.opts.pollTimeout + BUFFER_PERIOD_MS; + const qps: ISyncParams = { + timeout: 0, // don't want to block since this is a single isolated req + }; + + return client.getOrCreateFilter( + getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter, + ).then(function(filterId) { + qps.filter = filterId; + return client.http.authedRequest( + undefined, "GET", "/sync", qps, undefined, localTimeoutMs, + ); + }).then((data) => { + let leaveRooms = []; + if (data.rooms && data.rooms.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + const rooms = []; + leaveRooms.forEach((leaveObj) => { + const room = leaveObj.room; + rooms.push(room); + if (!leaveObj.isBrandNewRoom) { + // the intention behind syncLeftRooms is to add in rooms which were + // *omitted* from the initial /sync. Rooms the user were joined to + // but then left whilst the app is running will appear in this list + // and we do not want to bother with them since they will have the + // current state already (and may get dupe messages if we add + // yet more timeline events!), so skip them. + // NB: When we persist rooms to localStorage this will be more + // complicated... + return; + } + leaveObj.timeline = leaveObj.timeline || {}; + const timelineEvents = + this.mapSyncEventsFormat(leaveObj.timeline, room); + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken(leaveObj.timeline.prev_batch, + EventTimeline.BACKWARDS); + + this.processRoomEvents(room, stateEvents, timelineEvents); + + room.recalculate(); + client.store.storeRoom(room); + client.emit("Room", room); + + this.processEventsForNotifs(room, timelineEvents); + }); + return rooms; + }); + } + + /** + * Peek into a room. This will result in the room in question being synced so it + * is accessible via getRooms(). Live updates for the room will be provided. + * @param {string} roomId The room ID to peek into. + * @return {Promise} A promise which resolves once the room has been added to the + * store. + */ + public peek(roomId: string): Promise { + if (this._peekRoom && this._peekRoom.roomId === roomId) { + return Promise.resolve(this._peekRoom); + } + + const client = this.client; + this._peekRoom = this.createRoom(roomId); + return this.client.roomInitialSync(roomId, 20).then((response) => { + // make sure things are init'd + response.messages = response.messages || {}; + response.messages.chunk = response.messages.chunk || []; + response.state = response.state || []; + + // FIXME: Mostly duplicated from processRoomEvents but not entirely + // because "state" in this API is at the BEGINNING of the chunk + const oldStateEvents = utils.deepCopy(response.state) + .map(client.getEventMapper()); + const stateEvents = response.state.map(client.getEventMapper()); + const messages = response.messages.chunk.map(client.getEventMapper()); + + // XXX: copypasted from /sync until we kill off this + // minging v1 API stuff) + // handle presence events (User objects) + if (response.presence && Array.isArray(response.presence)) { + response.presence.map(client.getEventMapper()).forEach( + function(presenceEvent) { + let user = client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit("event", presenceEvent); + }); + } + + // set the pagination token before adding the events in case people + // fire off pagination requests in response to the Room.timeline + // events. + if (response.messages.start) { + this._peekRoom.oldState.paginationToken = response.messages.start; + } + + // set the state of the room to as it was after the timeline executes + this._peekRoom.oldState.setStateEvents(oldStateEvents); + this._peekRoom.currentState.setStateEvents(stateEvents); + + this.resolveInvites(this._peekRoom); + this._peekRoom.recalculate(); + + // roll backwards to diverge old state. addEventsToTimeline + // will overwrite the pagination token, so make sure it overwrites + // it with the right thing. + this._peekRoom.addEventsToTimeline(messages.reverse(), true, + this._peekRoom.getLiveTimeline(), + response.messages.start); + + client.store.storeRoom(this._peekRoom); + client.emit("Room", this._peekRoom); + + this.peekPoll(this._peekRoom); + return this._peekRoom; + }); + } + + /** + * Stop polling for updates in the peeked room. NOPs if there is no room being + * peeked. + */ + public stopPeeking(): void { + this._peekRoom = null; + } + + /** + * Do a peek room poll. + * @param {Room} peekRoom + * @param {string?} token from= token + */ + private peekPoll(peekRoom: Room, token?: string): void { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + + // FIXME: gut wrenching; hard-coded timeout values + this.client.http.authedRequest(undefined, "GET", "/events", { + room_id: peekRoom.roomId, + timeout: 30 * 1000, + from: token, + }, undefined, 50 * 1000).then((res) => { + if (this._peekRoom !== peekRoom) { + debuglog("Stopped peeking in room %s", peekRoom.roomId); + return; + } + // We have a problem that we get presence both from /events and /sync + // however, /sync only returns presence for users in rooms + // you're actually joined to. + // in order to be sure to get presence for all of the users in the + // peeked room, we handle presence explicitly here. This may result + // in duplicate presence events firing for some users, which is a + // performance drain, but such is life. + // XXX: copypasted from /sync until we can kill this minging v1 stuff. + + res.chunk.filter(function(e) { + return e.type === "m.presence"; + }).map(this.client.getEventMapper()).forEach((presenceEvent) => { + let user = this.client.store.getUser(presenceEvent.getContent().user_id); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(this.client, presenceEvent.getContent().user_id); + user.setPresenceEvent(presenceEvent); + this.client.store.storeUser(user); + } + this.client.emit("event", presenceEvent); + }); + + // strip out events which aren't for the given room_id (e.g presence) + // and also ephemeral events (which we're assuming is anything without + // and event ID because the /events API doesn't separate them). + const events = res.chunk.filter(function(e) { + return e.room_id === peekRoom.roomId && e.event_id; + }).map(this.client.getEventMapper()); + + peekRoom.addLiveEvents(events); + this.peekPoll(peekRoom, res.end); + }, (err) => { + logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err); + setTimeout(() => { + this.peekPoll(peekRoom, token); + }, 30 * 1000); + }); + } + + /** + * Returns the current state of this sync object + * @see module:client~MatrixClient#event:"sync" + * @return {?String} + */ + public getSyncState(): SyncState { + return this.syncState; + } + + /** + * Returns the additional data object associated with + * the current sync state, or null if there is no + * such data. + * Sync errors, if available, are put in the 'error' key of + * this object. + * @return {?Object} + */ + public getSyncStateData(): ISyncStateData { + return this.syncStateData; + } + + public async recoverFromSyncStartupError(savedSyncPromise: Promise, err: Error): Promise { + // Wait for the saved sync to complete - we send the pushrules and filter requests + // before the saved sync has finished so they can run in parallel, but only process + // the results after the saved sync is done. Equivalently, we wait for it to finish + // before reporting failures from these functions. + await savedSyncPromise; + const keepaliveProm = this.startKeepAlives(); + this.updateSyncState(SyncState.Error, { error: err }); + await keepaliveProm; + } + + /** + * Is the lazy loading option different than in previous session? + * @param {boolean} lazyLoadMembers current options for lazy loading + * @return {boolean} whether or not the option has changed compared to the previous session */ + private async wasLazyLoadingToggled(lazyLoadMembers = false): Promise { + // assume it was turned off before + // if we don't know any better + let lazyLoadMembersBefore = false; + const isStoreNewlyCreated = await this.client.store.isNewlyCreated(); + if (!isStoreNewlyCreated) { + const prevClientOptions = await this.client.store.getClientOptions(); + if (prevClientOptions) { + lazyLoadMembersBefore = !!prevClientOptions.lazyLoadMembers; + } + return lazyLoadMembersBefore !== lazyLoadMembers; + } + return false; + } + + private shouldAbortSync(error: MatrixError): boolean { + if (error.errcode === "M_UNKNOWN_TOKEN") { + // The logout already happened, we just need to stop. + logger.warn("Token no longer valid - assuming logout"); + this.stop(); + return true; + } + return false; + } + + /** + * Main entry point + */ + public sync(): void { + const client = this.client; + + this.running = true; + + if (global.window && global.window.addEventListener) { + global.window.addEventListener("online", this.onOnline, false); + } + + let savedSyncPromise = Promise.resolve(); + let savedSyncToken = null; + + // We need to do one-off checks before we can begin the /sync loop. + // These are: + // 1) We need to get push rules so we can check if events should bing as we get + // them from /sync. + // 2) We need to get/create a filter which we can use for /sync. + // 3) We need to check the lazy loading option matches what was used in the + // stored sync. If it doesn't, we can't use the stored sync. + + const getPushRules = async () => { + try { + debuglog("Getting push rules..."); + const result = await client.getPushRules(); + debuglog("Got push rules"); + + client.pushRules = result; + } catch (err) { + logger.error("Getting push rules failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying push rules..."); + await this.recoverFromSyncStartupError(savedSyncPromise, err); + getPushRules(); + return; + } + checkLazyLoadStatus(); // advance to the next stage + }; + + const buildDefaultFilter = () => { + const filter = new Filter(client.credentials.userId); + filter.setTimelineLimit(this.opts.initialSyncLimit); + return filter; + }; + + const checkLazyLoadStatus = async () => { + debuglog("Checking lazy load status..."); + if (this.opts.lazyLoadMembers && client.isGuest()) { + this.opts.lazyLoadMembers = false; + } + if (this.opts.lazyLoadMembers) { + debuglog("Checking server lazy load support..."); + const supported = await client.doesServerSupportLazyLoading(); + if (supported) { + debuglog("Enabling lazy load on sync filter..."); + if (!this.opts.filter) { + this.opts.filter = buildDefaultFilter(); + } + this.opts.filter.setLazyLoadMembers(true); + } else { + debuglog("LL: lazy loading requested but not supported " + + "by server, so disabling"); + this.opts.lazyLoadMembers = false; + } + } + // need to vape the store when enabling LL and wasn't enabled before + debuglog("Checking whether lazy loading has changed in store..."); + const shouldClear = await this.wasLazyLoadingToggled(this.opts.lazyLoadMembers); + if (shouldClear) { + this.storeIsInvalid = true; + const reason = InvalidStoreError.TOGGLED_LAZY_LOADING; + const error = new InvalidStoreError(reason, !!this.opts.lazyLoadMembers); + this.updateSyncState(SyncState.Error, { error }); + // bail out of the sync loop now: the app needs to respond to this error. + // we leave the state as 'ERROR' which isn't great since this normally means + // we're retrying. The client must be stopped before clearing the stores anyway + // so the app should stop the client, clear the store and start it again. + logger.warn("InvalidStoreError: store is not usable: stopping sync."); + return; + } + if (this.opts.lazyLoadMembers && this.opts.crypto) { + this.opts.crypto.enableLazyLoading(); + } + try { + debuglog("Storing client options..."); + await this.client.storeClientOptions(); + debuglog("Stored client options"); + } catch (err) { + logger.error("Storing client options failed", err); + throw err; + } + + getFilter(); // Now get the filter and start syncing + }; + + const getFilter = async () => { + debuglog("Getting filter..."); + let filter; + if (this.opts.filter) { + filter = this.opts.filter; + } else { + filter = buildDefaultFilter(); + } + + let filterId; + try { + filterId = await client.getOrCreateFilter(getFilterName(client.credentials.userId), filter); + } catch (err) { + logger.error("Getting filter failed", err); + if (this.shouldAbortSync(err)) return; + // wait for saved sync to complete before doing anything else, + // otherwise the sync state will end up being incorrect + debuglog("Waiting for saved sync before retrying filter..."); + await this.recoverFromSyncStartupError(savedSyncPromise, err); + getFilter(); + return; + } + // reset the notifications timeline to prepare it to paginate from + // the current point in time. + // The right solution would be to tie /sync pagination tokens into + // /notifications API somehow. + client.resetNotifTimelineSet(); + + if (this.currentSyncRequest === null) { + // Send this first sync request here so we can then wait for the saved + // sync data to finish processing before we process the results of this one. + debuglog("Sending first sync request..."); + this.currentSyncRequest = this.doSyncRequest({ filterId }, savedSyncToken); + } + + // Now wait for the saved sync to finish... + debuglog("Waiting for saved sync before starting sync processing..."); + await savedSyncPromise; + this._sync({ filterId }); + }; + + if (client.isGuest()) { + // no push rules for guests, no access to POST filter for guests. + this._sync({}); + } else { + // Pull the saved sync token out first, before the worker starts sending + // all the sync data which could take a while. This will let us send our + // first incremental sync request before we've processed our saved data. + debuglog("Getting saved sync token..."); + savedSyncPromise = client.store.getSavedSyncToken().then((tok) => { + debuglog("Got saved sync token"); + savedSyncToken = tok; + debuglog("Getting saved sync..."); + return client.store.getSavedSync(); + }).then((savedSync) => { + debuglog(`Got reply from saved sync, exists? ${!!savedSync}`); + if (savedSync) { + return this.syncFromCache(savedSync); + } + }).catch(err => { + logger.error("Getting saved sync failed", err); + }); + // Now start the first incremental sync request: this can also + // take a while so if we set it going now, we can wait for it + // to finish while we process our saved sync data. + getPushRules(); + } + } + + /** + * Stops the sync object from syncing. + */ + public stop(): void { + debuglog("SyncApi.stop"); + if (global.window) { + global.window.removeEventListener("online", this.onOnline, false); + } + this.running = false; + if (this.currentSyncRequest) { + this.currentSyncRequest.abort(); + } + if (this.keepAliveTimer) { + clearTimeout(this.keepAliveTimer); + this.keepAliveTimer = null; + } + } + + /** + * Retry a backed off syncing request immediately. This should only be used when + * the user explicitly attempts to retry their lost connection. + * @return {boolean} True if this resulted in a request being retried. + */ + public retryImmediately(): boolean { + if (!this.connectionReturnedDefer) { + return false; + } + this.startKeepAlives(0); + return true; + } + /** + * Process a single set of cached sync data. + * @param {Object} savedSync a saved sync that was persisted by a store. This + * should have been acquired via client.store.getSavedSync(). + */ + private async syncFromCache(savedSync: ISavedSync): Promise { + debuglog("sync(): not doing HTTP hit, instead returning stored /sync data"); + + const nextSyncToken = savedSync.nextBatch; + + // Set sync token for future incremental syncing + this.client.store.setSyncToken(nextSyncToken); + + // No previous sync, set old token to null + const syncEventData = { + oldSyncToken: null, + nextSyncToken, + catchingUp: false, + fromCache: true, + }; + + const data: ISyncResponse = { + next_batch: nextSyncToken, + rooms: savedSync.roomsData, + groups: savedSync.groupsData, + account_data: { + events: savedSync.accountData, + }, + }; + + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + logger.error("Error processing cached sync", e.stack || e); + } + + // Don't emit a prepared if we've bailed because the store is invalid: + // in this case the client will not be usable until stopped & restarted + // so this would be useless and misleading. + if (!this.storeIsInvalid) { + this.updateSyncState(SyncState.Prepared, syncEventData); + } + } + + /** + * Invoke me to do /sync calls + * @param {Object} syncOptions + * @param {string} syncOptions.filterId + * @param {boolean} syncOptions.hasSyncedBefore + */ + private async _sync(syncOptions: ISyncOptions): Promise { + const client = this.client; + + if (!this.running) { + debuglog("Sync no longer running: exiting."); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = null; + } + this.updateSyncState(SyncState.Stopped); + return; + } + + const syncToken = client.store.getSyncToken(); + + let data; + try { + //debuglog('Starting sync since=' + syncToken); + if (this.currentSyncRequest === null) { + this.currentSyncRequest = this.doSyncRequest(syncOptions, syncToken); + } + data = await this.currentSyncRequest; + } catch (e) { + this.onSyncError(e, syncOptions); + return; + } finally { + this.currentSyncRequest = null; + } + + //debuglog('Completed sync, next_batch=' + data.next_batch); + + // set the sync token NOW *before* processing the events. We do this so + // if something barfs on an event we can skip it rather than constantly + // polling with the same token. + client.store.setSyncToken(data.next_batch); + + // Reset after a successful sync + this.failedSyncCount = 0; + + await client.store.setSyncData(data); + + const syncEventData = { + oldSyncToken: syncToken, + nextSyncToken: data.next_batch, + catchingUp: this.catchingUp, + }; + + if (this.opts.crypto) { + // tell the crypto module we're about to process a sync + // response + await this.opts.crypto.onSyncWillProcess(syncEventData); + } + + try { + await this.processSyncResponse(syncEventData, data); + } catch (e) { + // log the exception with stack if we have it, else fall back + // to the plain description + logger.error("Caught /sync error", e.stack || e); + + // Emit the exception for client handling + this.client.emit("sync.unexpectedError", e); + } + + // update this as it may have changed + syncEventData.catchingUp = this.catchingUp; + + // emit synced events + if (!syncOptions.hasSyncedBefore) { + this.updateSyncState(SyncState.Prepared, syncEventData); + syncOptions.hasSyncedBefore = true; + } + + // tell the crypto module to do its processing. It may block (to do a + // /keys/changes request). + if (this.opts.crypto) { + await this.opts.crypto.onSyncCompleted(syncEventData); + } + + // keep emitting SYNCING -> SYNCING for clients who want to do bulk updates + this.updateSyncState(SyncState.Syncing, syncEventData); + + if (client.store.wantsSave()) { + // We always save the device list (if it's dirty) before saving the sync data: + // this means we know the saved device list data is at least as fresh as the + // stored sync data which means we don't have to worry that we may have missed + // device changes. We can also skip the delay since we're not calling this very + // frequently (and we don't really want to delay the sync for it). + if (this.opts.crypto) { + await this.opts.crypto.saveDeviceList(0); + } + + // tell databases that everything is now in a consistent state and can be saved. + client.store.save(); + } + + // Begin next sync + this._sync(syncOptions); + } + + private doSyncRequest(syncOptions: ISyncOptions, syncToken: string): IRequestPromise { + const qps = this.getSyncParams(syncOptions, syncToken); + return this.client.http.authedRequest( + undefined, "GET", "/sync", qps, undefined, + qps.timeout + BUFFER_PERIOD_MS, + ); + } + + private getSyncParams(syncOptions: ISyncOptions, syncToken: string): ISyncParams { + let pollTimeout = this.opts.pollTimeout; + + if (this.getSyncState() !== 'SYNCING' || this.catchingUp) { + // unless we are happily syncing already, we want the server to return + // as quickly as possible, even if there are no events queued. This + // serves two purposes: + // + // * When the connection dies, we want to know asap when it comes back, + // so that we can hide the error from the user. (We don't want to + // have to wait for an event or a timeout). + // + // * We want to know if the server has any to_device messages queued up + // for us. We do that by calling it with a zero timeout until it + // doesn't give us any more to_device messages. + this.catchingUp = true; + pollTimeout = 0; + } + + let filterId = syncOptions.filterId; + if (this.client.isGuest() && !filterId) { + filterId = this.getGuestFilter(); + } + + const qps: ISyncParams = { + filter: filterId, + timeout: pollTimeout, + }; + + if (this.opts.disablePresence) { + qps.set_presence = "offline"; + } + + if (syncToken) { + qps.since = syncToken; + } else { + // use a cachebuster for initialsyncs, to make sure that + // we don't get a stale sync + // (https://github.com/vector-im/vector-web/issues/1354) + qps._cacheBuster = Date.now(); + } + + if (this.getSyncState() == 'ERROR' || this.getSyncState() == 'RECONNECTING') { + // we think the connection is dead. If it comes back up, we won't know + // about it till /sync returns. If the timeout= is high, this could + // be a long time. Set it to 0 when doing retries so we don't have to wait + // for an event or a timeout before emiting the SYNCING event. + qps.timeout = 0; + } + + return qps; + } + + private onSyncError(err: Error, syncOptions: ISyncOptions): void { + if (!this.running) { + debuglog("Sync no longer running: exiting"); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.reject(); + this.connectionReturnedDefer = null; + } + this.updateSyncState(SyncState.Stopped); + return; + } + + logger.error("/sync error %s", err); + logger.error(err); + + if (this.shouldAbortSync(err)) { + return; + } + + this.failedSyncCount++; + logger.log('Number of consecutive failed sync requests:', this.failedSyncCount); + + debuglog("Starting keep-alive"); + // Note that we do *not* mark the sync connection as + // lost yet: we only do this if a keepalive poke + // fails, since long lived HTTP connections will + // go away sometimes and we shouldn't treat this as + // erroneous. We set the state to 'reconnecting' + // instead, so that clients can observe this state + // if they wish. + this.startKeepAlives().then((connDidFail) => { + // Only emit CATCHUP if we detected a connectivity error: if we didn't, + // it's quite likely the sync will fail again for the same reason and we + // want to stay in ERROR rather than keep flip-flopping between ERROR + // and CATCHUP. + if (connDidFail && this.getSyncState() === SyncState.Error) { + this.updateSyncState(SyncState.Catchup, { + oldSyncToken: null, + nextSyncToken: null, + catchingUp: true, + }); + } + this._sync(syncOptions); + }); + + this.currentSyncRequest = null; + // Transition from RECONNECTING to ERROR after a given number of failed syncs + this.updateSyncState( + this.failedSyncCount >= FAILED_SYNC_ERROR_THRESHOLD ? + SyncState.Error : SyncState.Reconnecting, + { error: err }, + ); + } + + /** + * Process data returned from a sync response and propagate it + * into the model objects + * + * @param {Object} syncEventData Object containing sync tokens associated with this sync + * @param {Object} data The response from /sync + */ + private async processSyncResponse(syncEventData: ISyncStateData, data: ISyncResponse): Promise { + const client = this.client; + + // data looks like: + // { + // next_batch: $token, + // presence: { events: [] }, + // account_data: { events: [] }, + // device_lists: { changed: ["@user:server", ... ]}, + // to_device: { events: [] }, + // device_one_time_keys_count: { signed_curve25519: 42 }, + // rooms: { + // invite: { + // $roomid: { + // invite_state: { events: [] } + // } + // }, + // join: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token, limited: true }, + // ephemeral: { events: [] }, + // summary: { + // m.heroes: [ $user_id ], + // m.joined_member_count: $count, + // m.invited_member_count: $count + // }, + // account_data: { events: [] }, + // unread_notifications: { + // highlight_count: 0, + // notification_count: 0, + // } + // } + // }, + // leave: { + // $roomid: { + // state: { events: [] }, + // timeline: { events: [], prev_batch: $token } + // } + // } + // }, + // groups: { + // invite: { + // $groupId: { + // inviter: $inviter, + // profile: { + // avatar_url: $avatarUrl, + // name: $groupName, + // }, + // }, + // }, + // join: {}, + // leave: {}, + // }, + // } + + // TODO-arch: + // - Each event we pass through needs to be emitted via 'event', can we + // do this in one place? + // - The isBrandNewRoom boilerplate is boilerplatey. + + // handle presence events (User objects) + if (data.presence && Array.isArray(data.presence.events)) { + data.presence.events.map(client.getEventMapper()).forEach( + function(presenceEvent) { + let user = client.store.getUser(presenceEvent.getSender()); + if (user) { + user.setPresenceEvent(presenceEvent); + } else { + user = createNewUser(client, presenceEvent.getSender()); + user.setPresenceEvent(presenceEvent); + client.store.storeUser(user); + } + client.emit("event", presenceEvent); + }); + } + + // handle non-room account_data + if (data.account_data && Array.isArray(data.account_data.events)) { + const events = data.account_data.events.map(client.getEventMapper()); + const prevEventsMap = events.reduce((m, c) => { + m[c.getId()] = client.store.getAccountData(c.getType()); + return m; + }, {}); + client.store.storeAccountDataEvents(events); + events.forEach( + function(accountDataEvent) { + // Honour push rules that come down the sync stream but also + // honour push rules that were previously cached. Base rules + // will be updated when we receive push rules via getPushRules + // (see sync) before syncing over the network. + if (accountDataEvent.getType() === 'm.push_rules') { + const rules = accountDataEvent.getContent(); + client.pushRules = PushProcessor.rewriteDefaultRules(rules); + } + const prevEvent = prevEventsMap[accountDataEvent.getId()]; + client.emit("accountData", accountDataEvent, prevEvent); + return accountDataEvent; + }, + ); + } + + // handle to-device events + if (data.to_device && Array.isArray(data.to_device.events) && + data.to_device.events.length > 0 + ) { + const cancelledKeyVerificationTxns = []; + data.to_device.events + .map(client.getEventMapper()) + .map((toDeviceEvent) => { // map is a cheap inline forEach + // We want to flag m.key.verification.start events as cancelled + // if there's an accompanying m.key.verification.cancel event, so + // we pull out the transaction IDs from the cancellation events + // so we can flag the verification events as cancelled in the loop + // below. + if (toDeviceEvent.getType() === "m.key.verification.cancel") { + const txnId = toDeviceEvent.getContent()['transaction_id']; + if (txnId) { + cancelledKeyVerificationTxns.push(txnId); + } + } + + // as mentioned above, .map is a cheap inline forEach, so return + // the unmodified event. + return toDeviceEvent; + }) + .forEach( + function(toDeviceEvent) { + const content = toDeviceEvent.getContent(); + if ( + toDeviceEvent.getType() == "m.room.message" && + content.msgtype == "m.bad.encrypted" + ) { + // the mapper already logged a warning. + logger.log( + 'Ignoring undecryptable to-device event from ' + + toDeviceEvent.getSender(), + ); + return; + } + + if (toDeviceEvent.getType() === "m.key.verification.start" + || toDeviceEvent.getType() === "m.key.verification.request") { + const txnId = content['transaction_id']; + if (cancelledKeyVerificationTxns.includes(txnId)) { + toDeviceEvent.flagCancelled(); + } + } + + client.emit("toDeviceEvent", toDeviceEvent); + }, + ); + } else { + // no more to-device events: we can stop polling with a short timeout. + this.catchingUp = false; + } + + if (data.groups) { + if (data.groups.invite) { + this.processGroupSyncEntry(data.groups.invite, Category.Invite); + } + + if (data.groups.join) { + this.processGroupSyncEntry(data.groups.join, Category.Join); + } + + if (data.groups.leave) { + this.processGroupSyncEntry(data.groups.leave, Category.Leave); + } + } + + // the returned json structure is a bit crap, so make it into a + // nicer form (array) after applying sanity to make sure we don't fail + // on missing keys (on the off chance) + let inviteRooms: WrappedRoom[] = []; + let joinRooms: WrappedRoom[] = []; + let leaveRooms: WrappedRoom[] = []; + + if (data.rooms) { + if (data.rooms.invite) { + inviteRooms = this.mapSyncResponseToRoomArray(data.rooms.invite); + } + if (data.rooms.join) { + joinRooms = this.mapSyncResponseToRoomArray(data.rooms.join); + } + if (data.rooms.leave) { + leaveRooms = this.mapSyncResponseToRoomArray(data.rooms.leave); + } + } + + this.notifEvents = []; + + // Handle invites + inviteRooms.forEach((inviteObj) => { + const room = inviteObj.room; + const stateEvents = this.mapSyncEventsFormat(inviteObj.invite_state, room); + + this.processRoomEvents(room, stateEvents); + if (inviteObj.isBrandNewRoom) { + room.recalculate(); + client.store.storeRoom(room); + client.emit("Room", room); + } + stateEvents.forEach(function(e) { + client.emit("event", e); + }); + room.updateMyMembership("invite"); + }); + + // Handle joins + await utils.promiseMapSeries(joinRooms, async (joinObj) => { + const room = joinObj.room; + const stateEvents = this.mapSyncEventsFormat(joinObj.state, room); + // Prevent events from being decrypted ahead of time + // this helps large account to speed up faster + // room::decryptCriticalEvent is in charge of decrypting all the events + // required for a client to function properly + const timelineEvents = this.mapSyncEventsFormat(joinObj.timeline, room, false); + const ephemeralEvents = this.mapSyncEventsFormat(joinObj.ephemeral); + const accountDataEvents = this.mapSyncEventsFormat(joinObj.account_data); + + const encrypted = client.isRoomEncrypted(room.roomId); + // we do this first so it's correct when any of the events fire + if (joinObj.unread_notifications) { + room.setUnreadNotificationCount( + NotificationCountType.Total, + joinObj.unread_notifications.notification_count, + ); + + // We track unread notifications ourselves in encrypted rooms, so don't + // bother setting it here. We trust our calculations better than the + // server's for this case, and therefore will assume that our non-zero + // count is accurate. + if (!encrypted + || (encrypted && room.getUnreadNotificationCount(NotificationCountType.Highlight) <= 0)) { + room.setUnreadNotificationCount( + NotificationCountType.Highlight, + joinObj.unread_notifications.highlight_count, + ); + } + } + + joinObj.timeline = joinObj.timeline || {} as ITimeline; + + if (joinObj.isBrandNewRoom) { + // set the back-pagination token. Do this *before* adding any + // events so that clients can start back-paginating. + room.getLiveTimeline().setPaginationToken( + joinObj.timeline.prev_batch, EventTimeline.BACKWARDS); + } else if (joinObj.timeline.limited) { + let limited = true; + + // we've got a limited sync, so we *probably* have a gap in the + // timeline, so should reset. But we might have been peeking or + // paginating and already have some of the events, in which + // case we just want to append any subsequent events to the end + // of the existing timeline. + // + // This is particularly important in the case that we already have + // *all* of the events in the timeline - in that case, if we reset + // the timeline, we'll end up with an entirely empty timeline, + // which we'll try to paginate but not get any new events (which + // will stop us linking the empty timeline into the chain). + // + for (let i = timelineEvents.length - 1; i >= 0; i--) { + const eventId = timelineEvents[i].getId(); + if (room.getTimelineForEvent(eventId)) { + debuglog("Already have event " + eventId + " in limited " + + "sync - not resetting"); + limited = false; + + // we might still be missing some of the events before i; + // we don't want to be adding them to the end of the + // timeline because that would put them out of order. + timelineEvents.splice(0, i); + + // XXX: there's a problem here if the skipped part of the + // timeline modifies the state set in stateEvents, because + // we'll end up using the state from stateEvents rather + // than the later state from timelineEvents. We probably + // need to wind stateEvents forward over the events we're + // skipping. + + break; + } + } + + if (limited) { + this.deregisterStateListeners(room); + room.resetLiveTimeline( + joinObj.timeline.prev_batch, + this.opts.canResetEntireTimeline(room.roomId) ? + null : syncEventData.oldSyncToken, + ); + + // We have to assume any gap in any timeline is + // reason to stop incrementally tracking notifications and + // reset the timeline. + client.resetNotifTimelineSet(); + + this.registerStateListeners(room); + } + } + + this.processRoomEvents(room, stateEvents, timelineEvents, syncEventData.fromCache); + + // set summary after processing events, + // because it will trigger a name calculation + // which needs the room state to be up to date + if (joinObj.summary) { + room.setSummary(joinObj.summary); + } + + // we deliberately don't add ephemeral events to the timeline + room.addEphemeralEvents(ephemeralEvents); + + // we deliberately don't add accountData to the timeline + room.addAccountData(accountDataEvents); + + room.recalculate(); + if (joinObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit("Room", room); + } + + this.processEventsForNotifs(room, timelineEvents); + + const processRoomEvent = async (e) => { + client.emit("event", e); + if (e.isState() && e.getType() == "m.room.encryption" && this.opts.crypto) { + await this.opts.crypto.onCryptoEvent(e); + } + if (e.isState() && e.getType() === "im.vector.user_status") { + let user = client.store.getUser(e.getStateKey()); + if (user) { + user.unstable_updateStatusMessage(e); + } else { + user = createNewUser(client, e.getStateKey()); + user.unstable_updateStatusMessage(e); + client.store.storeUser(user); + } + } + }; + + await utils.promiseMapSeries(stateEvents, processRoomEvent); + await utils.promiseMapSeries(timelineEvents, processRoomEvent); + ephemeralEvents.forEach(function(e) { + client.emit("event", e); + }); + accountDataEvents.forEach(function(e) { + client.emit("event", e); + }); + + room.updateMyMembership("join"); + + // Decrypt only the last message in all rooms to make sure we can generate a preview + // And decrypt all events after the recorded read receipt to ensure an accurate + // notification count + room.decryptCriticalEvents(); + }); + + // Handle leaves (e.g. kicked rooms) + leaveRooms.forEach((leaveObj) => { + const room = leaveObj.room; + const stateEvents = this.mapSyncEventsFormat(leaveObj.state, room); + const timelineEvents = this.mapSyncEventsFormat(leaveObj.timeline, room); + const accountDataEvents = this.mapSyncEventsFormat(leaveObj.account_data); + + this.processRoomEvents(room, stateEvents, timelineEvents); + room.addAccountData(accountDataEvents); + + room.recalculate(); + if (leaveObj.isBrandNewRoom) { + client.store.storeRoom(room); + client.emit("Room", room); + } + + this.processEventsForNotifs(room, timelineEvents); + + stateEvents.forEach(function(e) { + client.emit("event", e); + }); + timelineEvents.forEach(function(e) { + client.emit("event", e); + }); + accountDataEvents.forEach(function(e) { + client.emit("event", e); + }); + + room.updateMyMembership("leave"); + }); + + // update the notification timeline, if appropriate. + // we only do this for live events, as otherwise we can't order them sanely + // in the timeline relative to ones paginated in by /notifications. + // XXX: we could fix this by making EventTimeline support chronological + // ordering... but it doesn't, right now. + if (syncEventData.oldSyncToken && this.notifEvents.length) { + this.notifEvents.sort(function(a, b) { + return a.getTs() - b.getTs(); + }); + this.notifEvents.forEach(function(event) { + client.getNotifTimelineSet().addLiveEvent(event); + }); + } + + // Handle device list updates + if (data.device_lists) { + if (this.opts.crypto) { + await this.opts.crypto.handleDeviceListChanges(syncEventData, data.device_lists); + } else { + // FIXME if we *don't* have a crypto module, we still need to + // invalidate the device lists. But that would require a + // substantial bit of rework :/. + } + } + + // Handle one_time_keys_count + if (this.opts.crypto && data.device_one_time_keys_count) { + const currentCount = data.device_one_time_keys_count.signed_curve25519 || 0; + this.opts.crypto.updateOneTimeKeyCount(currentCount); + } + if (this.opts.crypto && data["org.matrix.msc2732.device_unused_fallback_key_types"]) { + // The presence of device_unused_fallback_key_types indicates that the + // server supports fallback keys. If there's no unused + // signed_curve25519 fallback key we need a new one. + const unusedFallbackKeys = data["org.matrix.msc2732.device_unused_fallback_key_types"]; + this.opts.crypto.setNeedsNewFallback( + unusedFallbackKeys instanceof Array && + !unusedFallbackKeys.includes("signed_curve25519"), + ); + } + } + + /** + * Starts polling the connectivity check endpoint + * @param {number} delay How long to delay until the first poll. + * defaults to a short, randomised interval (to prevent + * tightlooping if /versions succeeds but /sync etc. fail). + * @return {promise} which resolves once the connection returns + */ + private startKeepAlives(delay?: number): Promise { + if (delay === undefined) { + delay = 2000 + Math.floor(Math.random() * 5000); + } + + if (this.keepAliveTimer !== null) { + clearTimeout(this.keepAliveTimer); + } + if (delay > 0) { + this.keepAliveTimer = setTimeout(this.pokeKeepAlive.bind(this), delay); + } else { + this.pokeKeepAlive(); + } + if (!this.connectionReturnedDefer) { + this.connectionReturnedDefer = utils.defer(); + } + return this.connectionReturnedDefer.promise; + } + + /** + * Make a dummy call to /_matrix/client/versions, to see if the HS is + * reachable. + * + * On failure, schedules a call back to itself. On success, resolves + * this.connectionReturnedDefer. + * + * @param {boolean} connDidFail True if a connectivity failure has been detected. Optional. + */ + private pokeKeepAlive(connDidFail = false): void { + const success = () => { + clearTimeout(this.keepAliveTimer); + if (this.connectionReturnedDefer) { + this.connectionReturnedDefer.resolve(connDidFail); + this.connectionReturnedDefer = null; + } + }; + + this.client.http.request( + undefined, // callback + "GET", "/_matrix/client/versions", + undefined, // queryParams + undefined, // data + { + prefix: '', + localTimeoutMs: 15 * 1000, + }, + ).then(() => { + success(); + }, (err) => { + if (err.httpStatus == 400 || err.httpStatus == 404) { + // treat this as a success because the server probably just doesn't + // support /versions: point is, we're getting a response. + // We wait a short time though, just in case somehow the server + // is in a mode where it 400s /versions responses and sync etc. + // responses fail, this will mean we don't hammer in a loop. + this.keepAliveTimer = setTimeout(success, 2000); + } else { + connDidFail = true; + this.keepAliveTimer = setTimeout( + this.pokeKeepAlive.bind(this, connDidFail), + 5000 + Math.floor(Math.random() * 5000), + ); + // A keepalive has failed, so we emit the + // error state (whether or not this is the + // first failure). + // Note we do this after setting the timer: + // this lets the unit tests advance the mock + // clock when they get the error. + this.updateSyncState(SyncState.Error, { error: err }); + } + }); + } + + /** + * @param {Object} groupsSection Groups section object, eg. response.groups.invite + * @param {string} sectionName Which section this is ('invite', 'join' or 'leave') + */ + private processGroupSyncEntry(groupsSection: object, sectionName: Category) { + // Processes entries from 'groups' section of the sync stream + for (const groupId of Object.keys(groupsSection)) { + const groupInfo = groupsSection[groupId]; + let group = this.client.store.getGroup(groupId); + const isBrandNew = group === null; + if (group === null) { + group = this.createGroup(groupId); + } + if (groupInfo.profile) { + group.setProfile( + groupInfo.profile.name, groupInfo.profile.avatar_url, + ); + } + if (groupInfo.inviter) { + group.setInviter({ userId: groupInfo.inviter }); + } + group.setMyMembership(sectionName); + if (isBrandNew) { + // Now we've filled in all the fields, emit the Group event + this.client.emit("Group", group); + } + } + } + + /** + * @param {Object} obj + * @return {Object[]} + */ + private mapSyncResponseToRoomArray( + obj: Record, + ): Array> { + // Maps { roomid: {stuff}, roomid: {stuff} } + // to + // [{stuff+Room+isBrandNewRoom}, {stuff+Room+isBrandNewRoom}] + const client = this.client; + return Object.keys(obj).map((roomId) => { + const arrObj = obj[roomId] as T & { room: Room, isBrandNewRoom: boolean }; + let room = client.store.getRoom(roomId); + let isBrandNewRoom = false; + if (!room) { + room = this.createRoom(roomId); + isBrandNewRoom = true; + } + arrObj.room = room; + arrObj.isBrandNewRoom = isBrandNewRoom; + return arrObj; + }); + } + + /** + * @param {Object} obj + * @param {Room} room + * @param {boolean} decrypt + * @return {MatrixEvent[]} + */ + private mapSyncEventsFormat( + obj: IInviteState | ITimeline | IEphemeral, + room?: Room, + decrypt = true, + ): MatrixEvent[] { + if (!obj || !Array.isArray(obj.events)) { + return []; + } + const mapper = this.client.getEventMapper({ decrypt }); + return (obj.events as Array).map(function(e) { + if (room) { + e["room_id"] = room.roomId; + } + return mapper(e); + }); + } + + /** + * @param {Room} room + */ + private resolveInvites(room: Room): void { + if (!room || !this.opts.resolveInvitesToProfiles) { + return; + } + const client = this.client; + // For each invited room member we want to give them a displayname/avatar url + // if they have one (the m.room.member invites don't contain this). + room.getMembersWithMembership("invite").forEach(function(member) { + if (member._requestedProfileInfo) return; + member._requestedProfileInfo = true; + // try to get a cached copy first. + const user = client.getUser(member.userId); + let promise; + if (user) { + promise = Promise.resolve({ + avatar_url: user.avatarUrl, + displayname: user.displayName, + }); + } else { + promise = client.getProfileInfo(member.userId); + } + promise.then(function(info) { + // slightly naughty by doctoring the invite event but this means all + // the code paths remain the same between invite/join display name stuff + // which is a worthy trade-off for some minor pollution. + const inviteEvent = member.events.member; + if (inviteEvent.getContent().membership !== "invite") { + // between resolving and now they have since joined, so don't clobber + return; + } + inviteEvent.getContent().avatar_url = info.avatar_url; + inviteEvent.getContent().displayname = info.displayname; + // fire listeners + member.setMembershipEvent(inviteEvent, room.currentState); + }, function(err) { + // OH WELL. + }); + }); + } + + /** + * @param {Room} room + * @param {MatrixEvent[]} stateEventList A list of state events. This is the state + * at the *START* of the timeline list if it is supplied. + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * @param {boolean} fromCache whether the sync response came from cache + * is earlier in time. Higher index is later. + */ + private processRoomEvents( + room: Room, + stateEventList: MatrixEvent[], + timelineEventList?: MatrixEvent[], + fromCache = false, + ): void { + // If there are no events in the timeline yet, initialise it with + // the given state events + const liveTimeline = room.getLiveTimeline(); + const timelineWasEmpty = liveTimeline.getEvents().length == 0; + if (timelineWasEmpty) { + // Passing these events into initialiseState will freeze them, so we need + // to compute and cache the push actions for them now, otherwise sync dies + // with an attempt to assign to read only property. + // XXX: This is pretty horrible and is assuming all sorts of behaviour from + // these functions that it shouldn't be. We should probably either store the + // push actions cache elsewhere so we can freeze MatrixEvents, or otherwise + // find some solution where MatrixEvents are immutable but allow for a cache + // field. + for (const ev of stateEventList) { + this.client.getPushActionsForEvent(ev); + } + liveTimeline.initialiseState(stateEventList); + } + + this.resolveInvites(room); + + // recalculate the room name at this point as adding events to the timeline + // may make notifications appear which should have the right name. + // XXX: This looks suspect: we'll end up recalculating the room once here + // and then again after adding events (processSyncResponse calls it after + // calling us) even if no state events were added. It also means that if + // one of the room events in timelineEventList is something that needs + // a recalculation (like m.room.name) we won't recalculate until we've + // finished adding all the events, which will cause the notification to have + // the old room name rather than the new one. + room.recalculate(); + + // If the timeline wasn't empty, we process the state events here: they're + // defined as updates to the state before the start of the timeline, so this + // starts to roll the state forward. + // XXX: That's what we *should* do, but this can happen if we were previously + // peeking in a room, in which case we obviously do *not* want to add the + // state events here onto the end of the timeline. Historically, the js-sdk + // has just set these new state events on the old and new state. This seems + // very wrong because there could be events in the timeline that diverge the + // state, in which case this is going to leave things out of sync. However, + // for now I think it;s best to behave the same as the code has done previously. + if (!timelineWasEmpty) { + // XXX: As above, don't do this... + //room.addLiveEvents(stateEventList || []); + // Do this instead... + room.oldState.setStateEvents(stateEventList || []); + room.currentState.setStateEvents(stateEventList || []); + } + // execute the timeline events. This will continue to diverge the current state + // if the timeline has any state events in it. + // This also needs to be done before running push rules on the events as they need + // to be decorated with sender etc. + room.addLiveEvents(timelineEventList || [], null, fromCache); + } + + /** + * Takes a list of timelineEvents and adds and adds to notifEvents + * as appropriate. + * This must be called after the room the events belong to has been stored. + * + * @param {Room} room + * @param {MatrixEvent[]} [timelineEventList] A list of timeline events. Lower index + * is earlier in time. Higher index is later. + */ + private processEventsForNotifs(room: Room, timelineEventList: MatrixEvent[]): void { + // gather our notifications into this.notifEvents + if (this.client.getNotifTimelineSet()) { + for (let i = 0; i < timelineEventList.length; i++) { + const pushActions = this.client.getPushActionsForEvent(timelineEventList[i]); + if (pushActions && pushActions.notify && + pushActions.tweaks && pushActions.tweaks.highlight) { + this.notifEvents.push(timelineEventList[i]); + } + } + } + } + + /** + * @return {string} + */ + private getGuestFilter(): string { + // Dev note: This used to be conditional to return a filter of 20 events maximum, but + // the condition never went to the other branch. This is now hardcoded. + return "{}"; + } + + /** + * Sets the sync state and emits an event to say so + * @param {String} newState The new state string + * @param {Object} data Object of additional data to emit in the event + */ + private updateSyncState(newState: SyncState, data?: ISyncStateData): void { + const old = this.syncState; + this.syncState = newState; + this.syncStateData = data; + this.client.emit("sync", this.syncState, old, data); + } + + /** + * Event handler for the 'online' event + * This event is generally unreliable and precise behaviour + * varies between browsers, so we poll for connectivity too, + * but this might help us reconnect a little faster. + */ + private onOnline = (): void => { + debuglog("Browser thinks we are back online"); + this.startKeepAlives(0); + }; +} + +function createNewUser(client: MatrixClient, userId: string): User { + const user = new User(userId); + client.reEmitter.reEmit(user, [ + "User.avatarUrl", "User.displayName", "User.presence", + "User.currentlyActive", "User.lastPresenceTs", + ]); + return user; +} + diff --git a/src/timeline-window.js b/src/timeline-window.js deleted file mode 100644 index 0fc9f3ae4..000000000 --- a/src/timeline-window.js +++ /dev/null @@ -1,521 +0,0 @@ -/* -Copyright 2016 OpenMarket Ltd -Copyright 2019 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. -*/ - -/** @module timeline-window */ - -import { EventTimeline } from './models/event-timeline'; -import { logger } from './logger'; - -/** - * @private - */ -const DEBUG = false; - -/** - * @private - */ -const debuglog = DEBUG ? logger.log.bind(logger) : function() {}; - -/** - * the number of times we ask the server for more events before giving up - * - * @private - */ -const DEFAULT_PAGINATE_LOOP_LIMIT = 5; - -/** - * Construct a TimelineWindow. - * - *

This abstracts the separate timelines in a Matrix {@link - * module:models/room|Room} into a single iterable thing. It keeps track of - * the start and endpoints of the window, which can be advanced with the help - * of pagination requests. - * - *

Before the window is useful, it must be initialised by calling {@link - * module:timeline-window~TimelineWindow#load|load}. - * - *

Note that the window will not automatically extend itself when new events - * are received from /sync; you should arrange to call {@link - * module:timeline-window~TimelineWindow#paginate|paginate} on {@link - * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events. - * - * @param {MatrixClient} client MatrixClient to be used for context/pagination - * requests. - * - * @param {EventTimelineSet} timelineSet The timelineSet to track - * - * @param {Object} [opts] Configuration options for this window - * - * @param {number} [opts.windowLimit = 1000] maximum number of events to keep - * in the window. If more events are retrieved via pagination requests, - * excess events will be dropped from the other end of the window. - * - * @constructor - */ -export function TimelineWindow(client, timelineSet, opts) { - opts = opts || {}; - this._client = client; - this._timelineSet = timelineSet; - - // these will be TimelineIndex objects; they delineate the 'start' and - // 'end' of the window. - // - // _start.index is inclusive; _end.index is exclusive. - this._start = null; - this._end = null; - - this._eventCount = 0; - this._windowLimit = opts.windowLimit || 1000; -} - -/** - * Initialise the window to point at a given event, or the live timeline - * - * @param {string} [initialEventId] If given, the window will contain the - * given event - * @param {number} [initialWindowSize = 20] Size of the initial window - * - * @return {Promise} - */ -TimelineWindow.prototype.load = function(initialEventId, initialWindowSize) { - const self = this; - initialWindowSize = initialWindowSize || 20; - - // given an EventTimeline, find the event we were looking for, and initialise our - // fields so that the event in question is in the middle of the window. - const initFields = function(timeline) { - let eventIndex; - - const events = timeline.getEvents(); - - if (!initialEventId) { - // we were looking for the live timeline: initialise to the end - eventIndex = events.length; - } else { - for (let i = 0; i < events.length; i++) { - if (events[i].getId() == initialEventId) { - eventIndex = i; - break; - } - } - - if (eventIndex === undefined) { - throw new Error("getEventTimeline result didn't include requested event"); - } - } - - const endIndex = Math.min(events.length, - eventIndex + Math.ceil(initialWindowSize / 2)); - const startIndex = Math.max(0, endIndex - initialWindowSize); - self._start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); - self._end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); - self._eventCount = endIndex - startIndex; - }; - - // We avoid delaying the resolution of the promise by a reactor tick if - // we already have the data we need, which is important to keep room-switching - // feeling snappy. - // - if (initialEventId) { - const timeline = this._timelineSet.getTimelineForEvent(initialEventId); - if (timeline) { - // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. - initFields(timeline); - return Promise.resolve(timeline); - } - - const prom = this._client.getEventTimeline(this._timelineSet, initialEventId); - return prom.then(initFields); - } else { - const tl = this._timelineSet.getLiveTimeline(); - initFields(tl); - return Promise.resolve(); - } -}; - -/** - * Get the TimelineIndex of the window in the given direction. - * - * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex - * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at - * the end. - * - * @return {TimelineIndex} The requested timeline index if one exists, null - * otherwise. - */ -TimelineWindow.prototype.getTimelineIndex = function(direction) { - if (direction == EventTimeline.BACKWARDS) { - return this._start; - } else if (direction == EventTimeline.FORWARDS) { - return this._end; - } else { - throw new Error("Invalid direction '" + direction + "'"); - } -}; - -/** - * Try to extend the window using events that are already in the underlying - * TimelineIndex. - * - * @param {string} direction EventTimeline.BACKWARDS to try extending it - * backwards; EventTimeline.FORWARDS to try extending it forwards. - * @param {number} size number of events to try to extend by. - * - * @return {boolean} true if the window was extended, false otherwise. - */ -TimelineWindow.prototype.extend = function(direction, size) { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - const count = (direction == EventTimeline.BACKWARDS) ? - tl.retreat(size) : tl.advance(size); - - if (count) { - this._eventCount += count; - debuglog("TimelineWindow: increased cap by " + count + - " (now " + this._eventCount + ")"); - // remove some events from the other end, if necessary - const excess = this._eventCount - this._windowLimit; - if (excess > 0) { - this.unpaginate(excess, direction != EventTimeline.BACKWARDS); - } - return true; - } - - return false; -}; - -/** - * Check if this window can be extended - * - *

This returns true if we either have more events, or if we have a - * pagination token which means we can paginate in that direction. It does not - * necessarily mean that there are more events available in that direction at - * this time. - * - * @param {string} direction EventTimeline.BACKWARDS to check if we can - * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards - * - * @return {boolean} true if we can paginate in the given direction - */ -TimelineWindow.prototype.canPaginate = function(direction) { - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return false; - } - - if (direction == EventTimeline.BACKWARDS) { - if (tl.index > tl.minIndex()) { - return true; - } - } else { - if (tl.index < tl.maxIndex()) { - return true; - } - } - - return Boolean(tl.timeline.getNeighbouringTimeline(direction) || - tl.timeline.getPaginationToken(direction)); -}; - -/** - * Attempt to extend the window - * - * @param {string} direction EventTimeline.BACKWARDS to extend the window - * backwards (towards older events); EventTimeline.FORWARDS to go forwards. - * - * @param {number} size number of events to try to extend by. If fewer than this - * number are immediately available, then we return immediately rather than - * making an API call. - * - * @param {boolean} [makeRequest = true] whether we should make API calls to - * fetch further events if we don't have any at all. (This has no effect if - * the room already knows about additional events in the relevant direction, - * even if there are fewer than 'size' of them, as we will just return those - * we already know about.) - * - * @param {number} [requestLimit = 5] limit for the number of API requests we - * should make. - * - * @return {Promise} Resolves to a boolean which is true if more events - * were successfully retrieved. - */ -TimelineWindow.prototype.paginate = function(direction, size, makeRequest, - requestLimit) { - // Either wind back the message cap (if there are enough events in the - // timeline to do so), or fire off a pagination request. - - if (makeRequest === undefined) { - makeRequest = true; - } - - if (requestLimit === undefined) { - requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT; - } - - const tl = this.getTimelineIndex(direction); - - if (!tl) { - debuglog("TimelineWindow: no timeline yet"); - return Promise.resolve(false); - } - - if (tl.pendingPaginate) { - return tl.pendingPaginate; - } - - // try moving the cap - if (this.extend(direction, size)) { - return Promise.resolve(true); - } - - if (!makeRequest || requestLimit === 0) { - // todo: should we return something different to indicate that there - // might be more events out there, but we haven't found them yet? - return Promise.resolve(false); - } - - // try making a pagination request - const token = tl.timeline.getPaginationToken(direction); - if (!token) { - debuglog("TimelineWindow: no token"); - return Promise.resolve(false); - } - - debuglog("TimelineWindow: starting request"); - const self = this; - - const prom = this._client.paginateEventTimeline(tl.timeline, { - backwards: direction == EventTimeline.BACKWARDS, - limit: size, - }).finally(function() { - tl.pendingPaginate = null; - }).then(function(r) { - debuglog("TimelineWindow: request completed with result " + r); - if (!r) { - // end of timeline - return false; - } - - // recurse to advance the index into the results. - // - // If we don't get any new events, we want to make sure we keep asking - // the server for events for as long as we have a valid pagination - // token. In particular, we want to know if we've actually hit the - // start of the timeline, or if we just happened to know about all of - // the events thanks to https://matrix.org/jira/browse/SYN-645. - // - // On the other hand, we necessarily want to wait forever for the - // server to make its mind up about whether there are other events, - // because it gives a bad user experience - // (https://github.com/vector-im/vector-web/issues/1204). - return self.paginate(direction, size, true, requestLimit - 1); - }); - tl.pendingPaginate = prom; - return prom; -}; - -/** - * Remove `delta` events from the start or end of the timeline. - * - * @param {number} delta number of events to remove from the timeline - * @param {boolean} startOfTimeline if events should be removed from the start - * of the timeline. - */ -TimelineWindow.prototype.unpaginate = function(delta, startOfTimeline) { - const tl = startOfTimeline ? this._start : this._end; - - // sanity-check the delta - if (delta > this._eventCount || delta < 0) { - throw new Error("Attemting to unpaginate " + delta + " events, but " + - "only have " + this._eventCount + " in the timeline"); - } - - while (delta > 0) { - const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); - if (count <= 0) { - // sadness. This shouldn't be possible. - throw new Error( - "Unable to unpaginate any further, but still have " + - this._eventCount + " events"); - } - - delta -= count; - this._eventCount -= count; - debuglog("TimelineWindow.unpaginate: dropped " + count + - " (now " + this._eventCount + ")"); - } -}; - -/** - * Get a list of the events currently in the window - * - * @return {MatrixEvent[]} the events in the window - */ -TimelineWindow.prototype.getEvents = function() { - if (!this._start) { - // not yet loaded - return []; - } - - const result = []; - - // iterate through each timeline between this._start and this._end - // (inclusive). - let timeline = this._start.timeline; - while (true) { - const events = timeline.getEvents(); - - // For the first timeline in the chain, we want to start at - // this._start.index. For the last timeline in the chain, we want to - // stop before this._end.index. Otherwise, we want to copy all of the - // events in the timeline. - // - // (Note that both this._start.index and this._end.index are relative - // to their respective timelines' BaseIndex). - // - let startIndex = 0; - let endIndex = events.length; - if (timeline === this._start.timeline) { - startIndex = this._start.index + timeline.getBaseIndex(); - } - if (timeline === this._end.timeline) { - endIndex = this._end.index + timeline.getBaseIndex(); - } - - for (let i = startIndex; i < endIndex; i++) { - result.push(events[i]); - } - - // if we're not done, iterate to the next timeline. - if (timeline === this._end.timeline) { - break; - } else { - timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS); - } - } - - return result; -}; - -/** - * a thing which contains a timeline reference, and an index into it. - * - * @constructor - * @param {EventTimeline} timeline - * @param {number} index - * @private - */ -export function TimelineIndex(timeline, index) { - this.timeline = timeline; - - // the indexes are relative to BaseIndex, so could well be negative. - this.index = index; -} - -/** - * @return {number} the minimum possible value for the index in the current - * timeline - */ -TimelineIndex.prototype.minIndex = function() { - return this.timeline.getBaseIndex() * -1; -}; - -/** - * @return {number} the maximum possible value for the index in the current - * timeline (exclusive - ie, it actually returns one more than the index - * of the last element). - */ -TimelineIndex.prototype.maxIndex = function() { - return this.timeline.getEvents().length - this.timeline.getBaseIndex(); -}; - -/** - * Try move the index forward, or into the neighbouring timeline - * - * @param {number} delta number of events to advance by - * @return {number} number of events successfully advanced by - */ -TimelineIndex.prototype.advance = function(delta) { - if (!delta) { - return 0; - } - - // first try moving the index in the current timeline. See if there is room - // to do so. - let cappedDelta; - if (delta < 0) { - // we want to wind the index backwards. - // - // (this.minIndex() - this.index) is a negative number whose magnitude - // is the amount of room we have to wind back the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.max(delta, this.minIndex() - this.index); - if (cappedDelta < 0) { - this.index += cappedDelta; - return cappedDelta; - } - } else { - // we want to wind the index forwards. - // - // (this.maxIndex() - this.index) is a (positive) number whose magnitude - // is the amount of room we have to wind forward the index in the current - // timeline. We cap delta to this quantity. - cappedDelta = Math.min(delta, this.maxIndex() - this.index); - if (cappedDelta > 0) { - this.index += cappedDelta; - return cappedDelta; - } - } - - // the index is already at the start/end of the current timeline. - // - // next see if there is a neighbouring timeline to switch to. - const neighbour = this.timeline.getNeighbouringTimeline( - delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); - if (neighbour) { - this.timeline = neighbour; - if (delta < 0) { - this.index = this.maxIndex(); - } else { - this.index = this.minIndex(); - } - - debuglog("paginate: switched to new neighbour"); - - // recurse, using the next timeline - return this.advance(delta); - } - - return 0; -}; - -/** - * Try move the index backwards, or into the neighbouring timeline - * - * @param {number} delta number of events to retreat by - * @return {number} number of events successfully retreated by - */ -TimelineIndex.prototype.retreat = function(delta) { - return this.advance(delta * -1) * -1; -}; diff --git a/src/timeline-window.ts b/src/timeline-window.ts new file mode 100644 index 000000000..21912585d --- /dev/null +++ b/src/timeline-window.ts @@ -0,0 +1,522 @@ +/* +Copyright 2016 - 2021 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. +*/ + +/** @module timeline-window */ + +import { Direction, EventTimeline } from './models/event-timeline'; +import { logger } from './logger'; +import { MatrixClient } from "./client"; +import { EventTimelineSet } from "./models/event-timeline-set"; +import { MatrixEvent } from "./models/event"; + +/** + * @private + */ +const DEBUG = false; + +/** + * @private + */ +const debuglog = DEBUG ? logger.log.bind(logger) : function() {}; + +/** + * the number of times we ask the server for more events before giving up + * + * @private + */ +const DEFAULT_PAGINATE_LOOP_LIMIT = 5; + +interface IOpts { + windowLimit?: number; +} + +export class TimelineWindow { + private readonly windowLimit: number; + // these will be TimelineIndex objects; they delineate the 'start' and + // 'end' of the window. + // + // start.index is inclusive; end.index is exclusive. + private start?: TimelineIndex = null; + private end?: TimelineIndex = null; + private eventCount = 0; + + /** + * Construct a TimelineWindow. + * + *

This abstracts the separate timelines in a Matrix {@link + * module:models/room|Room} into a single iterable thing. It keeps track of + * the start and endpoints of the window, which can be advanced with the help + * of pagination requests. + * + *

Before the window is useful, it must be initialised by calling {@link + * module:timeline-window~TimelineWindow#load|load}. + * + *

Note that the window will not automatically extend itself when new events + * are received from /sync; you should arrange to call {@link + * module:timeline-window~TimelineWindow#paginate|paginate} on {@link + * module:client~MatrixClient.event:"Room.timeline"|Room.timeline} events. + * + * @param {MatrixClient} client MatrixClient to be used for context/pagination + * requests. + * + * @param {EventTimelineSet} timelineSet The timelineSet to track + * + * @param {Object} [opts] Configuration options for this window + * + * @param {number} [opts.windowLimit = 1000] maximum number of events to keep + * in the window. If more events are retrieved via pagination requests, + * excess events will be dropped from the other end of the window. + * + * @constructor + */ + constructor( + private readonly client: MatrixClient, + private readonly timelineSet: EventTimelineSet, + opts: IOpts = {}, + ) { + this.windowLimit = opts.windowLimit || 1000; + } + + /** + * Initialise the window to point at a given event, or the live timeline + * + * @param {string} [initialEventId] If given, the window will contain the + * given event + * @param {number} [initialWindowSize = 20] Size of the initial window + * + * @return {Promise} + */ + public load(initialEventId: string, initialWindowSize = 20): Promise { + // given an EventTimeline, find the event we were looking for, and initialise our + // fields so that the event in question is in the middle of the window. + const initFields = (timeline: EventTimeline) => { + let eventIndex; + + const events = timeline.getEvents(); + + if (!initialEventId) { + // we were looking for the live timeline: initialise to the end + eventIndex = events.length; + } else { + for (let i = 0; i < events.length; i++) { + if (events[i].getId() == initialEventId) { + eventIndex = i; + break; + } + } + + if (eventIndex === undefined) { + throw new Error("getEventTimeline result didn't include requested event"); + } + } + + const endIndex = Math.min(events.length, + eventIndex + Math.ceil(initialWindowSize / 2)); + const startIndex = Math.max(0, endIndex - initialWindowSize); + this.start = new TimelineIndex(timeline, startIndex - timeline.getBaseIndex()); + this.end = new TimelineIndex(timeline, endIndex - timeline.getBaseIndex()); + this.eventCount = endIndex - startIndex; + }; + + // We avoid delaying the resolution of the promise by a reactor tick if + // we already have the data we need, which is important to keep room-switching + // feeling snappy. + // + if (initialEventId) { + const timeline = this.timelineSet.getTimelineForEvent(initialEventId); + if (timeline) { + // hot-path optimization to save a reactor tick by replicating the sync check getTimelineForEvent does. + initFields(timeline); + return Promise.resolve(timeline); + } + + const prom = this.client.getEventTimeline(this.timelineSet, initialEventId); + return prom.then(initFields); + } else { + const tl = this.timelineSet.getLiveTimeline(); + initFields(tl); + return Promise.resolve(); + } + } + + /** + * Get the TimelineIndex of the window in the given direction. + * + * @param {string} direction EventTimeline.BACKWARDS to get the TimelineIndex + * at the start of the window; EventTimeline.FORWARDS to get the TimelineIndex at + * the end. + * + * @return {TimelineIndex} The requested timeline index if one exists, null + * otherwise. + */ + public getTimelineIndex(direction: Direction): TimelineIndex { + if (direction == EventTimeline.BACKWARDS) { + return this.start; + } else if (direction == EventTimeline.FORWARDS) { + return this.end; + } else { + throw new Error("Invalid direction '" + direction + "'"); + } + } + + /** + * Try to extend the window using events that are already in the underlying + * TimelineIndex. + * + * @param {string} direction EventTimeline.BACKWARDS to try extending it + * backwards; EventTimeline.FORWARDS to try extending it forwards. + * @param {number} size number of events to try to extend by. + * + * @return {boolean} true if the window was extended, false otherwise. + */ + public extend(direction: Direction, size: number): boolean { + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + + const count = (direction == EventTimeline.BACKWARDS) ? + tl.retreat(size) : tl.advance(size); + + if (count) { + this.eventCount += count; + debuglog("TimelineWindow: increased cap by " + count + + " (now " + this.eventCount + ")"); + // remove some events from the other end, if necessary + const excess = this.eventCount - this.windowLimit; + if (excess > 0) { + this.unpaginate(excess, direction != EventTimeline.BACKWARDS); + } + return true; + } + + return false; + } + + /** + * Check if this window can be extended + * + *

This returns true if we either have more events, or if we have a + * pagination token which means we can paginate in that direction. It does not + * necessarily mean that there are more events available in that direction at + * this time. + * + * @param {string} direction EventTimeline.BACKWARDS to check if we can + * paginate backwards; EventTimeline.FORWARDS to check if we can go forwards + * + * @return {boolean} true if we can paginate in the given direction + */ + public canPaginate(direction: Direction): boolean { + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return false; + } + + if (direction == EventTimeline.BACKWARDS) { + if (tl.index > tl.minIndex()) { + return true; + } + } else { + if (tl.index < tl.maxIndex()) { + return true; + } + } + + return Boolean(tl.timeline.getNeighbouringTimeline(direction) || + tl.timeline.getPaginationToken(direction)); + } + + /** + * Attempt to extend the window + * + * @param {string} direction EventTimeline.BACKWARDS to extend the window + * backwards (towards older events); EventTimeline.FORWARDS to go forwards. + * + * @param {number} size number of events to try to extend by. If fewer than this + * number are immediately available, then we return immediately rather than + * making an API call. + * + * @param {boolean} [makeRequest = true] whether we should make API calls to + * fetch further events if we don't have any at all. (This has no effect if + * the room already knows about additional events in the relevant direction, + * even if there are fewer than 'size' of them, as we will just return those + * we already know about.) + * + * @param {number} [requestLimit = 5] limit for the number of API requests we + * should make. + * + * @return {Promise} Resolves to a boolean which is true if more events + * were successfully retrieved. + */ + public paginate( + direction: Direction, + size: number, + makeRequest = true, + requestLimit = DEFAULT_PAGINATE_LOOP_LIMIT, + ): Promise { + // Either wind back the message cap (if there are enough events in the + // timeline to do so), or fire off a pagination request. + const tl = this.getTimelineIndex(direction); + + if (!tl) { + debuglog("TimelineWindow: no timeline yet"); + return Promise.resolve(false); + } + + if (tl.pendingPaginate) { + return tl.pendingPaginate; + } + + // try moving the cap + if (this.extend(direction, size)) { + return Promise.resolve(true); + } + + if (!makeRequest || requestLimit === 0) { + // todo: should we return something different to indicate that there + // might be more events out there, but we haven't found them yet? + return Promise.resolve(false); + } + + // try making a pagination request + const token = tl.timeline.getPaginationToken(direction); + if (!token) { + debuglog("TimelineWindow: no token"); + return Promise.resolve(false); + } + + debuglog("TimelineWindow: starting request"); + + const prom = this.client.paginateEventTimeline(tl.timeline, { + backwards: direction == EventTimeline.BACKWARDS, + limit: size, + }).finally(function() { + tl.pendingPaginate = null; + }).then((r) => { + debuglog("TimelineWindow: request completed with result " + r); + if (!r) { + // end of timeline + return false; + } + + // recurse to advance the index into the results. + // + // If we don't get any new events, we want to make sure we keep asking + // the server for events for as long as we have a valid pagination + // token. In particular, we want to know if we've actually hit the + // start of the timeline, or if we just happened to know about all of + // the events thanks to https://matrix.org/jira/browse/SYN-645. + // + // On the other hand, we necessarily want to wait forever for the + // server to make its mind up about whether there are other events, + // because it gives a bad user experience + // (https://github.com/vector-im/vector-web/issues/1204). + return this.paginate(direction, size, true, requestLimit - 1); + }); + tl.pendingPaginate = prom; + return prom; + } + + /** + * Remove `delta` events from the start or end of the timeline. + * + * @param {number} delta number of events to remove from the timeline + * @param {boolean} startOfTimeline if events should be removed from the start + * of the timeline. + */ + public unpaginate(delta: number, startOfTimeline: boolean): void { + const tl = startOfTimeline ? this.start : this.end; + + // sanity-check the delta + if (delta > this.eventCount || delta < 0) { + throw new Error("Attemting to unpaginate " + delta + " events, but " + + "only have " + this.eventCount + " in the timeline"); + } + + while (delta > 0) { + const count = startOfTimeline ? tl.advance(delta) : tl.retreat(delta); + if (count <= 0) { + // sadness. This shouldn't be possible. + throw new Error( + "Unable to unpaginate any further, but still have " + + this.eventCount + " events"); + } + + delta -= count; + this.eventCount -= count; + debuglog("TimelineWindow.unpaginate: dropped " + count + + " (now " + this.eventCount + ")"); + } + } + + /** + * Get a list of the events currently in the window + * + * @return {MatrixEvent[]} the events in the window + */ + public getEvents(): MatrixEvent[] { + if (!this.start) { + // not yet loaded + return []; + } + + const result = []; + + // iterate through each timeline between this.start and this.end + // (inclusive). + let timeline = this.start.timeline; + // eslint-disable-next-line no-constant-condition + while (true) { + const events = timeline.getEvents(); + + // For the first timeline in the chain, we want to start at + // this.start.index. For the last timeline in the chain, we want to + // stop before this.end.index. Otherwise, we want to copy all of the + // events in the timeline. + // + // (Note that both this.start.index and this.end.index are relative + // to their respective timelines' BaseIndex). + // + let startIndex = 0; + let endIndex = events.length; + if (timeline === this.start.timeline) { + startIndex = this.start.index + timeline.getBaseIndex(); + } + if (timeline === this.end.timeline) { + endIndex = this.end.index + timeline.getBaseIndex(); + } + + for (let i = startIndex; i < endIndex; i++) { + result.push(events[i]); + } + + // if we're not done, iterate to the next timeline. + if (timeline === this.end.timeline) { + break; + } else { + timeline = timeline.getNeighbouringTimeline(EventTimeline.FORWARDS); + } + } + + return result; + } +} + +/** + * a thing which contains a timeline reference, and an index into it. + * + * @constructor + * @param {EventTimeline} timeline + * @param {number} index + * @private + */ +export class TimelineIndex { + public pendingPaginate?: Promise; + + // index: the indexes are relative to BaseIndex, so could well be negative. + constructor(public timeline: EventTimeline, public index: number) {} + + /** + * @return {number} the minimum possible value for the index in the current + * timeline + */ + public minIndex(): number { + return this.timeline.getBaseIndex() * -1; + } + + /** + * @return {number} the maximum possible value for the index in the current + * timeline (exclusive - ie, it actually returns one more than the index + * of the last element). + */ + public maxIndex(): number { + return this.timeline.getEvents().length - this.timeline.getBaseIndex(); + } + + /** + * Try move the index forward, or into the neighbouring timeline + * + * @param {number} delta number of events to advance by + * @return {number} number of events successfully advanced by + */ + public advance(delta: number): number { + if (!delta) { + return 0; + } + + // first try moving the index in the current timeline. See if there is room + // to do so. + let cappedDelta; + if (delta < 0) { + // we want to wind the index backwards. + // + // (this.minIndex() - this.index) is a negative number whose magnitude + // is the amount of room we have to wind back the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.max(delta, this.minIndex() - this.index); + if (cappedDelta < 0) { + this.index += cappedDelta; + return cappedDelta; + } + } else { + // we want to wind the index forwards. + // + // (this.maxIndex() - this.index) is a (positive) number whose magnitude + // is the amount of room we have to wind forward the index in the current + // timeline. We cap delta to this quantity. + cappedDelta = Math.min(delta, this.maxIndex() - this.index); + if (cappedDelta > 0) { + this.index += cappedDelta; + return cappedDelta; + } + } + + // the index is already at the start/end of the current timeline. + // + // next see if there is a neighbouring timeline to switch to. + const neighbour = this.timeline.getNeighbouringTimeline( + delta < 0 ? EventTimeline.BACKWARDS : EventTimeline.FORWARDS); + if (neighbour) { + this.timeline = neighbour; + if (delta < 0) { + this.index = this.maxIndex(); + } else { + this.index = this.minIndex(); + } + + debuglog("paginate: switched to new neighbour"); + + // recurse, using the next timeline + return this.advance(delta); + } + + return 0; + } + + /** + * Try move the index backwards, or into the neighbouring timeline + * + * @param {number} delta number of events to retreat by + * @return {number} number of events successfully retreated by + */ + public retreat(delta: number): number { + return this.advance(delta * -1) * -1; + } +} diff --git a/src/utils.ts b/src/utils.ts index a4a50153a..008388509 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -20,7 +20,9 @@ limitations under the License. * @module utils */ -import unhomoglyph from 'unhomoglyph'; +import unhomoglyph from "unhomoglyph"; +import promiseRetry from "p-retry"; +import type NodeCrypto from "crypto"; /** * Encode a dictionary of query parameters. @@ -235,6 +237,32 @@ export function deepCompare(x: any, y: any): boolean { return true; } +// Dev note: This returns a tuple, but jsdoc doesn't like that. https://github.com/jsdoc/jsdoc/issues/1703 +/** + * Creates an array of object properties/values (entries) then + * sorts the result by key, recursively. The input object must + * ensure it does not have loops. If the input is not an object + * then it will be returned as-is. + * @param {*} obj The object to get entries of + * @returns {Array} The entries, sorted by key. + */ +export function deepSortedObjectEntries(obj: any): [string, any][] { + if (typeof(obj) !== "object") return obj; + + // Apparently these are object types... + if (obj === null || obj === undefined || Array.isArray(obj)) return obj; + + const pairs: [string, any][] = []; + for (const [k, v] of Object.entries(obj)) { + pairs.push([k, deepSortedObjectEntries(v)]); + } + + // lexicographicCompare is faster than localeCompare, so let's use that. + pairs.sort((a, b) => lexicographicCompare(a[0], b[0])); + + return pairs; +} + /** * Copy properties from one object to another. * @@ -408,12 +436,18 @@ export function isNullOrUndefined(val: any): boolean { return val === null || val === undefined; } +export interface IDeferred { + resolve: (value: T) => void; + reject: (reason?: any) => void; + promise: Promise; +} + // Returns a Deferred -export function defer() { +export function defer(): IDeferred { let resolve; let reject; - const promise = new Promise((_resolve, _reject) => { + const promise = new Promise((_resolve, _reject) => { resolve = _resolve; reject = _reject; }); @@ -422,10 +456,10 @@ export function defer() { } export async function promiseMapSeries( - promises: Promise[], + promises: T[], fn: (t: T) => void, ): Promise { - for (const o of await promises) { + for (const o of promises) { await fn(await o); } } @@ -443,16 +477,196 @@ export async function chunkPromises(fns: (() => Promise)[], chunkSize: num return results; } +/** + * Retries the function until it succeeds or is interrupted. The given function must return + * a promise which throws/rejects on error, otherwise the retry will assume the request + * succeeded. The promise chain returned will contain the successful promise. The given function + * should always return a new promise. + * @param {Function} promiseFn The function to call to get a fresh promise instance. Takes an + * attempt count as an argument, for logging/debugging purposes. + * @returns {Promise} The promise for the retried operation. + */ +export function simpleRetryOperation(promiseFn: (attempt: number) => Promise): Promise { + return promiseRetry((attempt: number) => { + return promiseFn(attempt); + }, { + forever: true, + factor: 2, + minTimeout: 3000, // ms + maxTimeout: 15000, // ms + }); +} + // We need to be able to access the Node.js crypto library from within the // Matrix SDK without needing to `require("crypto")`, which will fail in // browsers. So `index.ts` will call `setCrypto` to store it, and when we need // it, we can call `getCrypto`. -let crypto: Object; +let crypto: typeof NodeCrypto; -export function setCrypto(c: Object) { +export function setCrypto(c: typeof NodeCrypto) { crypto = c; } -export function getCrypto(): Object { +export function getCrypto(): typeof NodeCrypto { return crypto; } + +// String averaging inspired by https://stackoverflow.com/a/2510816 +// Dev note: We make the alphabet a string because it's easier to write syntactically +// than arrays. Thankfully, strings implement the useful parts of the Array interface +// anyhow. + +/** + * The default alphabet used by string averaging in this SDK. This matches + * all usefully printable ASCII characters (0x20-0x7E, inclusive). + */ +export const DEFAULT_ALPHABET = (() => { + let str = ""; + for (let c = 0x20; c <= 0x7E; c++) { + str += String.fromCharCode(c); + } + return str; +})(); + +/** + * Pads a string using the given alphabet as a base. The returned string will be + * padded at the end with the first character in the alphabet. + * + * This is intended for use with string averaging. + * @param {string} s The string to pad. + * @param {number} n The length to pad to. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The padded string. + */ +export function alphabetPad(s: string, n: number, alphabet = DEFAULT_ALPHABET): string { + return s.padEnd(n, alphabet[0]); +} + +/** + * Converts a baseN number to a string, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param {bigint} n The baseN number. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The baseN number encoded as a string from the alphabet. + */ +export function baseToString(n: bigint, alphabet = DEFAULT_ALPHABET): string { + // Developer note: the stringToBase() function offsets the character set by 1 so that repeated + // characters (ie: "aaaaaa" in a..z) don't come out as zero. We have to reverse this here as + // otherwise we'll be wrong in our conversion. Undoing a +1 before an exponent isn't very fun + // though, so we rely on a lengthy amount of `x - 1` and integer division rules to reach a + // sane state. This also means we have to do rollover detection: see below. + + const len = BigInt(alphabet.length); + if (n <= len) { + return alphabet[Number(n) - 1] ?? ""; + } + + let d = n / len; + let r = Number(n % len) - 1; + + // Rollover detection: if the remainder is negative, it means that the string needs + // to roll over by 1 character downwards (ie: in a..z, the previous to "aaa" would be + // "zz"). + if (r < 0) { + d -= BigInt(Math.abs(r)); // abs() is just to be clear what we're doing. Could also `+= r`. + r = Number(len) - 1; + } + + return baseToString(d, alphabet) + alphabet[r]; +} + +/** + * Converts a string to a baseN number, where N is the alphabet's length. + * + * This is intended for use with string averaging. + * @param {string} s The string to convert to a number. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {bigint} The baseN number. + */ +export function stringToBase(s: string, alphabet = DEFAULT_ALPHABET): bigint { + const len = BigInt(alphabet.length); + + // In our conversion to baseN we do a couple performance optimizations to avoid using + // excess CPU and such. To create baseN numbers, the input string needs to be reversed + // so the exponents stack up appropriately, as the last character in the unreversed + // string has less impact than the first character (in "abc" the A is a lot more important + // for lexicographic sorts). We also do a trick with the character codes to optimize the + // alphabet lookup, avoiding an index scan of `alphabet.indexOf(reversedStr[i])` - we know + // that the alphabet and (theoretically) the input string are constrained on character sets + // and thus can do simple subtraction to end up with the same result. + + // Developer caution: we carefully cast to BigInt here to avoid losing precision. We cannot + // rely on Math.pow() (for example) to be capable of handling our insane numbers. + + let result = BigInt(0); + for (let i = s.length - 1, j = BigInt(0); i >= 0; i--, j++) { + const charIndex = s.charCodeAt(i) - alphabet.charCodeAt(0); + + // We add 1 to the char index to offset the whole numbering scheme. We unpack this in + // the baseToString() function. + result += BigInt(1 + charIndex) * (len ** j); + } + return result; +} + +/** + * Averages two strings, returning the midpoint between them. This is accomplished by + * converting both to baseN numbers (where N is the alphabet's length) then averaging + * those before re-encoding as a string. + * @param {string} a The first string. + * @param {string} b The second string. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The midpoint between the strings, as a string. + */ +export function averageBetweenStrings(a: string, b: string, alphabet = DEFAULT_ALPHABET): string { + const padN = Math.max(a.length, b.length); + const baseA = stringToBase(alphabetPad(a, padN, alphabet), alphabet); + const baseB = stringToBase(alphabetPad(b, padN, alphabet), alphabet); + const avg = (baseA + baseB) / BigInt(2); + + // Detect integer division conflicts. This happens when two numbers are divided too close so + // we lose a .5 precision. We need to add a padding character in these cases. + if (avg === baseA || avg == baseB) { + return baseToString(avg, alphabet) + alphabet[0]; + } + + return baseToString(avg, alphabet); +} + +/** + * Finds the next string using the alphabet provided. This is done by converting the + * string to a baseN number, where N is the alphabet's length, then adding 1 before + * converting back to a string. + * @param {string} s The string to start at. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The string which follows the input string. + */ +export function nextString(s: string, alphabet = DEFAULT_ALPHABET): string { + return baseToString(stringToBase(s, alphabet) + BigInt(1), alphabet); +} + +/** + * Finds the previous string using the alphabet provided. This is done by converting the + * string to a baseN number, where N is the alphabet's length, then subtracting 1 before + * converting back to a string. + * @param {string} s The string to start at. + * @param {string} alphabet The alphabet to use as a single string. + * @returns {string} The string which precedes the input string. + */ +export function prevString(s: string, alphabet = DEFAULT_ALPHABET): string { + return baseToString(stringToBase(s, alphabet) - BigInt(1), alphabet); +} + +/** + * Compares strings lexicographically as a sort-safe function. + * @param {string} a The first (reference) string. + * @param {string} b The second (compare) string. + * @returns {number} Negative if the reference string is before the compare string; + * positive if the reference string is after; and zero if equal. + */ +export function lexicographicCompare(a: string, b: string): number { + // Dev note: this exists because I'm sad that you can use math operators on strings, so I've + // hidden the operation in this function. + return (a < b) ? -1 : ((a === b) ? 0 : 1); +} diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index b4fee0045..ed1af7b90 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -24,7 +24,7 @@ limitations under the License. import { logger } from '../logger'; import { EventEmitter } from 'events'; import * as utils from '../utils'; -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { EventType } from '../@types/event'; import { RoomMember } from '../models/room-member'; import { randomString } from '../randomstring'; @@ -56,22 +56,22 @@ import { CallFeed } from './callFeed'; */ interface CallOpts { - roomId?: string, - client?: any, // Fix when client is TSified - forceTURN?: boolean, - turnServers?: Array, + roomId?: string; + client?: any; // Fix when client is TSified + forceTURN?: boolean; + turnServers?: Array; } interface TurnServer { - urls: Array, - username?: string, - password?: string, - ttl?: number, + urls: Array; + username?: string; + password?: string; + ttl?: number; } interface AssertedIdentity { - id: string, - displayName: string, + id: string; + displayName: string; } export enum CallState { @@ -192,7 +192,12 @@ export enum CallErrorCode { /** * The remote party is busy */ - UserBusy = 'user_busy' + UserBusy = 'user_busy', + + /** + * We transferred the call off to somewhere else + */ + Transfered = 'transferred', } enum ConstraintsType { @@ -289,10 +294,6 @@ export class MatrixCall extends EventEmitter { // This flag represents whether we want the other party to be on hold private remoteOnHold; - // and this one we set when we're transitioning out of the hold state because we - // can't tell the difference between that and the other party holding us - private unholdingRemote; - private micMuted; private vidMuted; @@ -344,7 +345,6 @@ export class MatrixCall extends EventEmitter { this.makingOffer = false; this.remoteOnHold = false; - this.unholdingRemote = false; this.micMuted = false; this.vidMuted = false; @@ -728,12 +728,12 @@ export class MatrixCall extends EventEmitter { setRemoteOnHold(onHold: boolean) { if (this.isRemoteOnHold() === onHold) return; this.remoteOnHold = onHold; - if (!onHold) this.unholdingRemote = true; for (const tranceiver of this.peerConn.getTransceivers()) { - // We set 'inactive' rather than 'sendonly' because we're not planning on - // playing music etc. to the other side. - tranceiver.direction = onHold ? 'inactive' : 'sendrecv'; + // We don't send hold music or anything so we're not actually + // sending anything, but sendrecv is fairly standard for hold and + // it makes it a lot easier to figure out who's put who on hold. + tranceiver.direction = onHold ? 'sendonly' : 'sendrecv'; } this.updateMuteStatus(); @@ -742,15 +742,11 @@ export class MatrixCall extends EventEmitter { /** * Indicates whether we are 'on hold' to the remote party (ie. if true, - * they cannot hear us). Note that this will return true when we put the - * remote on hold too due to the way hold is implemented (since we don't - * wish to play hold music when we put a call on hold, we use 'inactive' - * rather than 'sendonly') + * they cannot hear us). * @returns true if the other party has put us on hold */ isLocalOnHold(): boolean { if (this.state !== CallState.Connected) return false; - if (this.unholdingRemote) return false; let callOnHold = true; @@ -1096,12 +1092,6 @@ export class MatrixCall extends EventEmitter { const prevLocalOnHold = this.isLocalOnHold(); - if (description.type === 'answer') { - // whenever we get an answer back, clear the flag we set whilst trying to un-hold - // the other party: the state of the channels now reflects reality - this.unholdingRemote = false; - } - try { await this.peerConn.setRemoteDescription(description); @@ -1443,7 +1433,7 @@ export class MatrixCall extends EventEmitter { await this.sendVoipEvent(EventType.CallReplaces, body); - await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); + await this.terminate(CallParty.Local, CallErrorCode.Transfered, true); } /* @@ -1483,7 +1473,7 @@ export class MatrixCall extends EventEmitter { await this.sendVoipEvent(EventType.CallReplaces, bodyToTransferee); await this.terminate(CallParty.Local, CallErrorCode.Replaced, true); - await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Replaced, true); + await transferTargetCall.terminate(CallParty.Local, CallErrorCode.Transfered, true); } private async terminate(hangupParty: CallParty, hangupReason: CallErrorCode, shouldEmit: boolean) { diff --git a/src/webrtc/callEventHandler.ts b/src/webrtc/callEventHandler.ts index 5394f1cbd..9d62375e9 100644 --- a/src/webrtc/callEventHandler.ts +++ b/src/webrtc/callEventHandler.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import MatrixEvent from '../models/event'; +import { MatrixEvent } from '../models/event'; import { logger } from '../logger'; import { createNewMatrixCall, MatrixCall, CallErrorCode, CallState, CallDirection } from './call'; import { EventType } from '../@types/event'; @@ -244,7 +244,7 @@ export class CallEventHandler { } else { call.onRemoteIceCandidatesReceived(event); } - } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType())) { + } else if ([EventType.CallHangup, EventType.CallReject].includes(event.getType() as EventType)) { // Note that we also observe our own hangups here so we can see // if we've already rejected a call that would otherwise be valid if (!call) { diff --git a/yarn.lock b/yarn.lock index 04057af8b..076bd2252 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1231,6 +1231,13 @@ dependencies: "@types/babel-types" "*" +"@types/bs58@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@types/bs58/-/bs58-4.0.1.tgz#3d51222aab067786d3bc3740a84a7f5a0effaa37" + integrity sha512-yfAgiWgVLjFCmRv8zAcOIHywYATEwiTVccTLnRp6UxTNavT55M9d/uhK3T03St/+8/z/wW+CRjGKUNmEqoHHCA== + dependencies: + base-x "^3.0.6" + "@types/caseless@*": version "0.12.2" resolved "https://registry.yarnpkg.com/@types/caseless/-/caseless-0.12.2.tgz#f65d3d6389e01eeb458bd54dc8f52b95a9463bc8" @@ -1305,6 +1312,11 @@ "@types/tough-cookie" "*" form-data "^2.5.0" +"@types/retry@^0.12.0": + version "0.12.0" + resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" + integrity sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA== + "@types/stack-utils@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-2.0.0.tgz#7036640b4e21cc2f259ae826ce843d277dad8cff" @@ -1772,7 +1784,7 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= -base-x@^3.0.2: +base-x@^3.0.2, base-x@^3.0.6: version "3.0.8" resolved "https://registry.yarnpkg.com/base-x/-/base-x-3.0.8.tgz#1e1106c2537f0162e8b52474a557ebb09000018d" integrity sha512-Rl/1AWP4J/zRrk54hhlxH4drNxPJXYUaKffODVI53/dAsV4t9fBxyxYKAVPU1XBHxYwOWP9h9H0hM2MVw4YfJA== @@ -2846,7 +2858,7 @@ eslint-config-google@^0.14.0: "eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main": version "0.3.2" - resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74" + resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/e8197938dca66849ffdac4baca7c05275df12835" eslint-rule-composer@^0.3.0: version "0.3.0" @@ -4699,12 +4711,7 @@ lodash.sortby@^4.7.0: resolved "https://registry.yarnpkg.com/lodash.sortby/-/lodash.sortby-4.7.0.tgz#edd14c824e2cc9c1e0b0a1b42bb5210516a42438" integrity sha1-7dFMgk4sycHgsKG0K7UhBRakJDg= -lodash@^4.17.14, lodash@^4.17.19, lodash@^4.17.20: - version "4.17.20" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.20.tgz#b44a9b6297bcb698f1c51a3545a2b3b368d59c52" - integrity sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA== - -lodash@^4.17.15, lodash@^4.17.4: +lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.4: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== @@ -5220,6 +5227,14 @@ p-locate@^4.1.0: dependencies: p-limit "^2.2.0" +p-retry@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/p-retry/-/p-retry-4.5.0.tgz#6685336b3672f9ee8174d3769a660cb5e488521d" + integrity sha512-5Hwh4aVQSu6BEP+w2zKlVXtFAaYQe1qWuVADSgoeVlLjwe/Q/AMSoRR4MDeaAfu8llT+YNbEijWu/YF3m6avkg== + dependencies: + "@types/retry" "^0.12.0" + retry "^0.12.0" + p-try@^2.0.0: version "2.2.0" resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" @@ -5943,6 +5958,11 @@ ret@~0.1.10: resolved "https://registry.yarnpkg.com/ret/-/ret-0.1.15.tgz#b8a4825d5bdb1fc3f6f53c2bc33f81388681c7bc" integrity sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/retry/-/retry-0.12.0.tgz#1b42a6266a21f07421d1b0b54b7dc167b01c013b" + integrity sha1-G0KmJmoh8HQh0bC1S33BZ7AcATs= + reusify@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"