Compare commits

...

110 Commits

Author SHA1 Message Date
RiotRobot 467b75f2dc v12.0.0 2021-06-21 16:20:26 +01:00
RiotRobot 1f728cab92 Prepare changelog for v12.0.0 2021-06-21 16:20:25 +01:00
RiotRobot 68c1171294 v12.0.0-rc.1 2021-06-15 16:10:48 +01:00
RiotRobot f4f01913fe Prepare changelog for v12.0.0-rc.1 2021-06-15 16:10:48 +01:00
Travis Ralston 9f6ed4fb33 Merge pull request #1730 from SimonBrandner/show-username
Rework how disambiguation is handled
2021-06-15 00:24:06 -06:00
Michael Telatynski 434117c771 Merge pull request #1735 from matrix-org/t3chguy/fix/17282 2021-06-14 22:15:12 +01:00
Michael Telatynski cd95b8724a Merge branch 'develop' of github.com:matrix-org/matrix-js-sdk into t3chguy/fix/17282 2021-06-14 21:46:31 +01:00
Michael Telatynski ec2a4d473e Iterate algorithm, base it on new js-sdk string lib 2021-06-14 21:28:33 +01:00
Travis Ralston 47eaf3cd58 Merge pull request #1734 from matrix-org/travis/fix-types
Move various types from the react-sdk to the js-sdk
2021-06-14 13:48:07 -06:00
Travis Ralston d7b23a8634 liiiinttteeerrrr 2021-06-14 13:37:19 -06:00
Travis Ralston 6db7972f04 preset 2021-06-14 13:35:20 -06:00
Travis Ralston 6840ee077c Appease the linter forever 2021-06-14 13:34:37 -06:00
Travis Ralston 3c85bcc3c9 Move various types from the react-sdk to the js-sdk 2021-06-14 13:32:28 -06:00
Travis Ralston 759eca6479 Merge pull request #1732 from matrix-org/travis/file-trees
Unstable implementation of MSC3089: File Trees
2021-06-14 12:04:09 -06:00
Travis Ralston 4488f174aa const 2021-06-14 12:00:59 -06:00
Travis Ralston 991a255041 Fix average on .5 2021-06-14 11:55:41 -06:00
Travis Ralston cfef635e1b Appease the linter 2021-06-14 10:41:32 -06:00
Travis Ralston d3027e1fe8 Offset the alphabet by 1 2021-06-14 10:28:26 -06:00
Travis Ralston d99ea1c6b4 Update src/utils.ts
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2021-06-14 08:21:09 -06:00
Travis Ralston 9af214007e APPEASE. THE. LINTER. 2021-06-11 11:37:44 -06:00
Travis Ralston 0541b7f3c5 Remove a layer of indirection 2021-06-11 11:36:52 -06:00
Travis Ralston 63fa774af7 Another round of appeasement 2021-06-11 11:34:37 -06:00
Travis Ralston 4b19b36de1 Crude JS->TS conversion on utils test because of linter and BigInt 2021-06-11 11:27:46 -06:00
Travis Ralston 5715df6b18 BigInt, rollover, and developer lint 2021-06-11 11:18:18 -06:00
Michael Telatynski 22bcacc715 Merge pull request #1729 from matrix-org/t3chguy/fix/17282
Add MSC3230 event type to enum
2021-06-11 12:40:23 +01:00
Travis Ralston f1e270ca9d appease the linter 2021-06-10 15:54:56 -06:00
Travis Ralston f535e7535c Update string averaging utils 2021-06-10 15:38:49 -06:00
David Baker 912c5e13e1 Merge pull request #1731 from matrix-org/dbkr/transferred_reason_code
Add separate reason code for transferred calls
2021-06-10 13:21:41 +01:00
Travis Ralston 4eb44ee2ea de-lint 2 2021-06-09 22:14:11 -06:00
Travis Ralston e41a2beb65 de-lint 2021-06-09 22:09:26 -06:00
Travis Ralston 1f6ba31a3f Use a sane padStart instead 2021-06-09 21:56:46 -06:00
Travis Ralston bcccc909c5 Pre-lint format 2021-06-09 21:55:06 -06:00
Travis Ralston b3a11030f2 Early file management APIs 2021-06-09 21:54:17 -06:00
Travis Ralston baaf76668f Early directory management 2021-06-09 21:54:17 -06:00
David Baker 36e2533164 Add separate reason code for transferred calls
'Replaced' is special cased so the media isn't torn down, but we
were passing Replaced for calls we transferred off elsewhere which
meant we never closed the media capture. Calls transferred elsewhere
aren't really replaced: they may as well have their own code.
2021-06-09 16:52:58 +01:00
Travis Ralston 9084b4e7aa Early implementation of MSC3089 (file trees)
MSC: https://github.com/matrix-org/matrix-doc/pull/3089
Includes part of MSC3088 (room subtyping): https://github.com/matrix-org/matrix-doc/pull/3088

The NamespacedValue stuff is borrowed from the Extensible Events implementation PR in the react-sdk as a useful thing to put here. When/if the MSCs become stable, we'd convert the values to enums and drop the constants (or keep them for migration purposes, but switch to stable). 

This flags the whole thing as unstable because it's highly subject to change.
2021-06-08 14:43:20 -06:00
Šimon Brandner f724da7e84 Merge remote-tracking branch 'upstream/develop' into disambiguate-prop 2021-06-08 11:30:50 +02:00
David Baker 4b8f47e2b4 Merge pull request #1728 from matrix-org/dbkr/hold_with_sendonly
Use sendonly for call hold
2021-06-08 09:26:57 +01:00
RiotRobot 35ecbed29d Resetting package fields for development 2021-06-07 17:00:30 +01:00
RiotRobot 30ed153ad1 Merge branch 'master' into develop 2021-06-07 17:00:30 +01:00
RiotRobot a1098989ff v11.2.0 2021-06-07 16:57:42 +01:00
RiotRobot 42bb63e07d Prepare changelog for v11.2.0 2021-06-07 16:57:42 +01:00
Michael Telatynski 683aae5ed5 Add MSC3230 event type to enum 2021-06-07 09:00:08 +01:00
David Baker 49ef274a83 Use sendonly for call hold
Instead of inactive. Remove the logic for trying to remember who
had who on hold, which is now unnecesasary. Appears to work fine
with element android & ios too.
2021-06-04 17:22:14 +01:00
J. Ryan Stinnett af16bba1ec Merge pull request #1727 from matrix-org/t3chguy/arg
Stop breeding sync listeners
2021-06-04 14:09:45 +01:00
Michael Telatynski 781086608e Stop breeding sync listeners 2021-06-04 13:58:19 +01:00
J. Ryan Stinnett e9c98b03b0 Merge pull request #1724 from matrix-org/jryans/semi-linting
Fix semicolons in TS files
2021-06-04 10:31:56 +01:00
J. Ryan Stinnett 75370f7855 Reduce allowable warning count 2021-06-04 10:24:55 +01:00
J. Ryan Stinnett 4a79e13410 Auto-fix more errors 2021-06-04 10:24:09 +01:00
J. Ryan Stinnett 220061f022 Merge remote-tracking branch 'origin/develop' into jryans/semi-linting 2021-06-04 10:22:06 +01:00
Travis Ralston 6c317b7f28 Fix types on checkOwnCrossSigningTrust 2021-06-04 00:10:33 -06:00
Travis Ralston 3788fbf607 Merge pull request #1718 from matrix-org/travis/ts-mtxcli
[BREAKING] Convert MatrixClient to TypeScript
2021-06-03 19:09:23 -06:00
Travis Ralston 382854c04c Appease linter 2021-06-03 19:04:49 -06:00
Travis Ralston c2fae3bad8 Fix missed conversion fallout 2021-06-03 19:02:46 -06:00
Travis Ralston 92ebd39391 Reincorporate crypto changes
https://github.com/matrix-org/matrix-js-sdk/pull/1697
2021-06-03 18:59:01 -06:00
Travis Ralston f53a32a6b4 Merge branch 'develop' into travis/ts-mtxcli 2021-06-03 18:49:08 -06:00
Hubert Chathi 6c882e6605 Merge pull request #1697 from uhoreg/backup_refactor
Factor out backup management to a separate module
2021-06-03 18:56:19 -04:00
Hubert Chathi ca85dfc6ff re-lint 2021-06-03 18:52:06 -04:00
Hubert Chathi e22ecc6b6d Merge branch 'develop' into backup_refactor 2021-06-03 18:43:46 -04:00
Hubert Chathi 2608dd2d64 mark members as public 2021-06-03 18:15:05 -04:00
J. Ryan Stinnett 7d20d24249 Convert small blocks to inline expressions 2021-06-03 15:51:33 +01:00
Travis Ralston a3ac3692af private->protected 2021-06-02 22:05:53 -06:00
Travis Ralston 0070c8f843 Merge pull request #1723 from schmop/power-levels-unknown-state-key
Ignore power_levels events with unknown state_key on room-state initialization
2021-06-02 19:04:13 -06:00
Travis Ralston b360fc8308 Fix test failure 2021-06-02 18:42:52 -06:00
Travis Ralston bf9ba65ac4 Fix olmVersion types 2021-06-02 13:42:20 -06:00
Travis Ralston 43abfbc537 Upstream build pass 2 2021-06-02 13:42:20 -06:00
Travis Ralston 2700f0acf6 Upstream build pass 1 2021-06-02 13:42:20 -06:00
Travis Ralston 9156bed961 Tests pass 2 2021-06-02 13:42:20 -06:00
Travis Ralston 71dc0bac56 Tests pass 1 2021-06-02 13:42:20 -06:00
Travis Ralston 40f55b2964 Lint pass 4 2021-06-02 13:42:20 -06:00
Travis Ralston 9307f9f345 Build pass 2 2021-06-02 13:42:20 -06:00
Travis Ralston dfb918adc3 Lint pass 3 2021-06-02 13:42:20 -06:00
Travis Ralston 2f87a4859e Lint pass 2 2021-06-02 13:42:20 -06:00
Travis Ralston 191f73e0d0 Appease typescript 2021-06-02 13:42:20 -06:00
Travis Ralston 263e55f25d Build pass 1 2021-06-02 13:42:20 -06:00
Travis Ralston 5c55dce13e Lint pass 1 2021-06-02 13:42:20 -06:00
Travis Ralston a1a6ec6dfa Fix remaining hot paths 2021-06-02 13:42:20 -06:00
Travis Ralston e1edd84700 Early pass to fix runtime/build errors 2021-06-02 13:42:20 -06:00
Travis Ralston 07ee256756 Incorporate https://github.com/matrix-org/matrix-js-sdk/pull/1720 2021-06-02 13:42:20 -06:00
Travis Ralston 48888e530e Defer types 2021-06-02 13:42:20 -06:00
Travis Ralston 4ef50bef55 define this.identityServer 2021-06-02 13:42:20 -06:00
Travis Ralston 486369e97c Clean up "base-apis" find&replace 2021-06-02 13:42:20 -06:00
Travis Ralston 67994f7a53 Move new MatrixClient into place 2021-06-02 13:42:20 -06:00
Travis Ralston f027ddaf35 Autoformat 2021-06-02 13:42:20 -06:00
Travis Ralston f3b27d1e06 Cleanup 2021-06-02 13:42:20 -06:00
Travis Ralston 92e18b32dc Import MatrixError 2021-06-02 13:42:20 -06:00
Travis Ralston 4030ec9c8b Fix easy typing errors 2021-06-02 13:42:20 -06:00
Travis Ralston 497c2dc8df Bring in BaseApis to MatrixClient 2021-06-02 13:42:20 -06:00
Travis Ralston 8a1d34c419 [Combined] First pass of JS->TS for MatrixClient 2021-06-02 13:42:20 -06:00
Travis Ralston caab5befaa Rename client.js -> 1client.ts for future commits 2021-06-02 13:42:20 -06:00
Travis Ralston 0d316e3d3e Move useful docs to ICreateClientOpts 2021-06-02 13:42:20 -06:00
David Baker 8f9a682d34 Merge pull request #1725 from matrix-org/dbkr/revert_1579
Revert 1579 (Fix extra negotiate message in Firefox)
2021-06-02 15:04:17 +01:00
David Baker 102d5acf09 Revert 1579
This is causing all sorts of problems, like
https://github.com/vector-im/element-web/issues/17450
and also issues where we fail to respond to negotiates because
we can't adjust a direction of a stopped transceiver. Until
s/inactive/sendonly/ let's live with the minor firefox bug rather
than all this brokenness.
2021-06-02 14:41:55 +01:00
J. Ryan Stinnett e2ec8952e3 Fix semicolons in TS files
This updates the linting config to include the semi fix in
https://github.com/matrix-org/eslint-plugin-matrix-org/pull/8. The various semi
errors have been auto-fixed.
2021-06-02 11:49:39 +01:00
Lars Richard b4eff9b996 Ignore m.room.power_levels events with unknown state_key on room-state initialization
Signed-off-by: Lars Richard <lars.richard@iserv.eu>
2021-06-02 11:42:50 +02:00
Hubert Chathi c543358826 add unit test and minor fixes 2021-05-31 21:52:20 -04:00
Hubert Chathi 07bfa5532e fix more unit tests 2021-05-26 18:18:30 -04:00
Hubert Chathi e73b969066 lint 2021-05-25 22:10:15 -04:00
Hubert Chathi 98e2154f0f fix unit tests 2021-05-25 21:59:08 -04:00
Hubert Chathi 3ec89a89df fix some types 2021-05-18 18:40:36 -04:00
Hubert Chathi 9e6b72bf38 some linting 2021-05-18 18:31:19 -04:00
Hubert Chathi 747723c8fb factor out backup management to a separate module 2021-05-18 18:15:22 -04:00
Šimon Brandner e4fbbd56a9 Improve wording
Co-authored-by: Travis Ralston <travpc@gmail.com>
2021-04-22 07:36:53 +02:00
Šimon Brandner 4290e8e56b Merge branch 'develop' into disambiguate-prop 2021-04-21 18:07:50 +02:00
Šimon Brandner a416fd562b Oops - remove log
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-04-20 11:11:33 +02:00
Šimon Brandner 323a096dd7 Merge branch 'disambiguate-prop' of https://github.com/SimonBrandner/matrix-js-sdk into disambiguate-prop 2021-04-20 11:01:20 +02:00
Šimon Brandner 628dd7bf41 Return false by default
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-04-20 11:01:13 +02:00
Šimon Brandner 71c6d71cae Fix docs
Co-authored-by: Jonathan de Jong <jonathandejong02@gmail.com>
2021-04-20 10:53:39 +02:00
Šimon Brandner e049edd449 Add docs
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-04-20 10:44:17 +02:00
Šimon Brandner 0fdfc3ff53 Rework how disambiguation is handled
Signed-off-by: Šimon Brandner <simon.bra.ag@gmail.com>
2021-04-18 21:01:53 +02:00
63 changed files with 12614 additions and 9530 deletions
+44
View File
@@ -1,3 +1,47 @@
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)
* No changes since rc.1
Changes in [11.2.0-rc.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v11.2.0-rc.1) (2021-06-01)
============================================================================================================
[Full Changelog](https://github.com/matrix-org/matrix-js-sdk/compare/v11.1.0...v11.2.0-rc.1)
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "11.2.0-rc.1",
"version": "12.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"scripts": {
"prepublishOnly": "yarn build",
@@ -15,7 +15,7 @@
"build:minify-browser": "terser dist/browser-matrix.js --compress --mangle --source-map --output dist/browser-matrix.min.js",
"gendoc": "jsdoc -c jsdoc.json -P package.json",
"lint": "yarn lint:types && yarn lint:js",
"lint:js": "eslint --max-warnings 72 src spec",
"lint:js": "eslint --max-warnings 57 src spec",
"lint:types": "tsc --noEmit",
"test": "jest",
"test:watch": "jest --watch",
+27
View File
@@ -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<number>[]) {
parts.forEach(p => Array.from(p).forEach(e => this.contents.push(e)));
}
public get size(): number {
return this.contents.length;
}
}
+1 -1
View File
@@ -69,7 +69,7 @@ export function TestClient(
this.deviceKeys = null;
this.oneTimeKeys = {};
this._callEventHandler = {
this.callEventHandler = {
calls: new Map(),
};
}
+8 -8
View File
@@ -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'];
+1 -1
View File
@@ -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];
+1 -1
View File
@@ -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);
+3 -3
View File
@@ -998,7 +998,7 @@ describe("megolm", function() {
...rawEvent,
room: ROOM_ID,
});
return event.attemptDecryption(testClient.client._crypto, true).then(() => {
return event.attemptDecryption(testClient.client.crypto, true).then(() => {
expect(event.isKeySourceUntrusted()).toBeTruthy();
});
}).then(() => {
@@ -1013,14 +1013,14 @@ describe("megolm", function() {
event: true,
});
event._senderCurve25519Key = testSenderKey;
return testClient.client._crypto._onRoomKeyEvent(event);
return testClient.client.crypto._onRoomKeyEvent(event);
}).then(() => {
const event = testUtils.mkEvent({
event: true,
...rawEvent,
room: ROOM_ID,
});
return event.attemptDecryption(testClient.client._crypto, true).then(() => {
return event.attemptDecryption(testClient.client.crypto, true).then(() => {
expect(event.isKeySourceUntrusted()).toBeFalsy();
});
});
+5 -5
View File
@@ -357,12 +357,12 @@ export function setHttpResponses(
);
const httpReq = httpResponseObj.request.bind(httpResponseObj);
client._http = [
client.http = [
"authedRequest", "authedRequestWithPrefix", "getContentUri",
"request", "requestWithPrefix", "uploadContent",
].reduce((r, k) => {r[k] = jest.fn(); return r;}, {});
client._http.authedRequest.mockImplementation(httpReq);
client._http.authedRequestWithPrefix.mockImplementation(httpReq);
client._http.requestWithPrefix.mockImplementation(httpReq);
client._http.request.mockImplementation(httpReq);
client.http.authedRequest.mockImplementation(httpReq);
client.http.authedRequestWithPrefix.mockImplementation(httpReq);
client.http.requestWithPrefix.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
}
+78
View File
@@ -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");
}
});
});
+8 -8
View File
@@ -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,
@@ -273,19 +273,19 @@ describe("Crypto", function() {
await Promise.all(events.map(async (event) => {
// 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);
await aliceClient.crypto.encryptEvent(event, aliceRoom);
event._clearEvent = {};
event._senderCurve25519Key = null;
event._claimedEd25519Key = null;
try {
await bobClient._crypto.decryptEvent(event);
await bobClient.crypto.decryptEvent(event);
} catch (e) {
// we expect this to fail because we don't have the
// decryption keys yet
}
}));
const bobDecryptor = bobClient._crypto._getRoomDecryptor(
const bobDecryptor = bobClient.crypto._getRoomDecryptor(
roomId, olmlib.MEGOLM_ALGORITHM,
);
@@ -302,7 +302,7 @@ describe("Crypto", function() {
expect(events[0].getContent().msgtype).toBe("m.bad.encrypted");
expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted");
const cryptoStore = bobClient._cryptoStore;
const cryptoStore = bobClient.cryptoStore;
const eventContent = events[0].getWireContent();
const senderKey = eventContent.sender_key;
const sessionId = eventContent.session_id;
@@ -344,7 +344,7 @@ describe("Crypto", function() {
},
});
await aliceClient.cancelAndResendEventRoomKeyRequest(event);
const cryptoStore = aliceClient._cryptoStore;
const cryptoStore = aliceClient.cryptoStore;
const roomKeyRequestBody = {
algorithm: olmlib.MEGOLM_ALGORITHM,
room_id: "!someroom",
@@ -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);
+25 -22
View File
@@ -257,6 +257,9 @@ describe("MegolmDecryption", function() {
});
it("re-uses sessions for sequential messages", async function() {
mockCrypto._backupManager = {
backupGroupSession: () => {},
};
const mockStorage = new MockStorageApi();
const cryptoStore = new MemoryCryptoStore(mockStorage);
@@ -362,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",
@@ -401,10 +404,10 @@ describe("MegolmDecryption", function() {
},
};
aliceClient._crypto._deviceList.storeDevicesForUser(
aliceClient.crypto._deviceList.storeDevicesForUser(
"@bob:example.com", BOB_DEVICES,
);
aliceClient._crypto._deviceList.downloadKeys = async function(userIds) {
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
return this._getDevicesFromStore(userIds);
};
@@ -445,7 +448,7 @@ describe("MegolmDecryption", function() {
body: "secret",
},
});
await aliceClient._crypto.encryptEvent(event, room);
await aliceClient.crypto.encryptEvent(event, room);
expect(run).toBe(true);
@@ -465,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",
@@ -505,10 +508,10 @@ describe("MegolmDecryption", function() {
},
};
aliceClient._crypto._deviceList.storeDevicesForUser(
aliceClient.crypto._deviceList.storeDevicesForUser(
"@bob:example.com", BOB_DEVICES,
);
aliceClient._crypto._deviceList.downloadKeys = async function(userIds) {
aliceClient.crypto._deviceList.downloadKeys = async function(userIds) {
return this._getDevicesFromStore(userIds);
};
@@ -543,7 +546,7 @@ describe("MegolmDecryption", function() {
event_id: "$event",
content: {},
});
await aliceClient._crypto.encryptEvent(event, aliceRoom);
await aliceClient.crypto.encryptEvent(event, aliceRoom);
await sendPromise;
});
@@ -558,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: {
@@ -575,7 +578,7 @@ describe("MegolmDecryption", function() {
},
}));
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
event_id: "$event",
@@ -601,14 +604,14 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
aliceClient._crypto.downloadKeys = async () => {};
const bobDevice = bobClient._crypto._olmDevice;
aliceClient.crypto.downloadKeys = async () => {};
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: {
@@ -625,7 +628,7 @@ describe("MegolmDecryption", function() {
setTimeout(resolve, 100);
});
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
event_id: "$event",
@@ -652,15 +655,15 @@ describe("MegolmDecryption", function() {
aliceClient.initCrypto(),
bobClient.initCrypto(),
]);
const bobDevice = bobClient._crypto._olmDevice;
aliceClient._crypto.downloadKeys = async () => {};
const bobDevice = bobClient.crypto._olmDevice;
aliceClient.crypto.downloadKeys = async () => {};
const roomId = "!someroom";
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: {
@@ -675,7 +678,7 @@ describe("MegolmDecryption", function() {
setTimeout(resolve, 100);
});
await expect(aliceClient._crypto.decryptEvent(new MatrixEvent({
await expect(aliceClient.crypto.decryptEvent(new MatrixEvent({
type: "m.room.encrypted",
sender: "@bob:example.com",
event_id: "$event",
+74 -51
View File
@@ -28,6 +28,7 @@ import * as testUtils from "../../test-utils";
import { OlmDevice } from "../../../src/crypto/OlmDevice";
import { Crypto } from "../../../src/crypto";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BackupManager } from "../../../src/crypto/backup";
const Olm = global.Olm;
@@ -73,7 +74,7 @@ const KEY_BACKUP_DATA = {
};
const BACKUP_INFO = {
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -138,6 +139,7 @@ describe("MegolmBackup", function() {
let megolmDecryption;
beforeEach(async function() {
mockCrypto = testUtils.mock(Crypto, 'Crypto');
mockCrypto._backupManager = testUtils.mock(BackupManager, "BackupManager");
mockCrypto.backupKey = new Olm.PkEncryption();
mockCrypto.backupKey.set_recipient_key(
"hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -215,12 +217,14 @@ describe("MegolmBackup", function() {
};
mockCrypto.cancelRoomKeyRequest = function() {};
mockCrypto.backupGroupSession = jest.fn();
mockCrypto._backupManager = {
backupGroupSession: jest.fn(),
};
return event.attemptDecryption(mockCrypto).then(() => {
return megolmDecryption.onRoomKeyEvent(event);
}).then(() => {
expect(mockCrypto.backupGroupSession).toHaveBeenCalled();
expect(mockCrypto._backupManager.backupGroupSession).toHaveBeenCalled();
});
});
@@ -264,7 +268,7 @@ describe("MegolmBackup", function() {
})
.then(() => {
client.enableKeyBackup({
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -272,7 +276,7 @@ describe("MegolmBackup", function() {
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
@@ -292,12 +296,9 @@ describe("MegolmBackup", function() {
resolve();
return Promise.resolve({});
};
client._crypto.backupGroupSession(
"roomId",
client.crypto._backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(1);
@@ -335,39 +336,48 @@ describe("MegolmBackup", function() {
});
await resetCrossSigningKeys(client);
let numCalls = 0;
await new Promise((resolve, reject) => {
client._http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(1);
if (numCalls >= 2) {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many timmes"));
return Promise.resolve({});
}
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
try {
// make sure auth_data is signed by the master key
olmlib.pkVerify(
data.auth_data, client.getCrossSigningId(), "@alice:bar",
);
} catch (e) {
reject(e);
return Promise.resolve({});
}
resolve();
return Promise.resolve({});
};
await Promise.all([
new Promise((resolve, reject) => {
let backupInfo;
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
expect(numCalls).toBeLessThanOrEqual(2);
if (numCalls === 1) {
expect(method).toBe("POST");
expect(path).toBe("/room_keys/version");
try {
// make sure auth_data is signed by the master key
olmlib.pkVerify(
data.auth_data, client.getCrossSigningId(), "@alice:bar",
);
} catch (e) {
reject(e);
return Promise.resolve({});
}
backupInfo = data;
return Promise.resolve({});
} else if (numCalls === 2) {
expect(method).toBe("GET");
expect(path).toBe("/room_keys/version");
resolve();
return Promise.resolve(backupInfo);
} else {
// exit out of retry loop if there's something wrong
reject(new Error("authedRequest called too many times"));
return Promise.resolve({});
}
};
}),
client.createKeyBackupVersion({
algorithm: "m.megolm_backup.v1",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
},
});
});
expect(numCalls).toBe(1);
}),
]);
expect(numCalls).toBe(2);
});
it('retries when a backup fails', function() {
@@ -434,7 +444,7 @@ describe("MegolmBackup", function() {
})
.then(() => {
client.enableKeyBackup({
algorithm: "foobar",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
version: 1,
auth_data: {
public_key: "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
@@ -442,7 +452,7 @@ describe("MegolmBackup", function() {
});
let numCalls = 0;
return new Promise((resolve, reject) => {
client._http.authedRequest = function(
client.http.authedRequest = function(
callback, method, path, queryParams, data, opts,
) {
++numCalls;
@@ -468,12 +478,9 @@ describe("MegolmBackup", function() {
);
}
};
client._crypto.backupGroupSession(
"roomId",
client.crypto._backupManager.backupGroupSession(
"F0Q2NmyJNgUVj9DGsb4ZQt3aVxhVcUQhg7+gvW0oyKI",
[],
groupSession.session_id(),
groupSession.session_key(),
);
}).then(() => {
expect(numCalls).toBe(2);
@@ -506,7 +513,7 @@ describe("MegolmBackup", function() {
});
it('can restore from backup', function() {
client._http.authedRequest = function() {
client.http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
return client.restoreKeyBackupWithRecoveryKey(
@@ -523,7 +530,7 @@ describe("MegolmBackup", function() {
});
it('can restore backup by room', function() {
client._http.authedRequest = function() {
client.http.authedRequest = function() {
return Promise.resolve({
rooms: {
[ROOM_ID]: {
@@ -546,15 +553,15 @@ describe("MegolmBackup", function() {
it('has working cache functions', async function() {
const key = Uint8Array.from([1, 2, 3, 4, 5, 6, 7, 8]);
await client._crypto.storeSessionBackupPrivateKey(key);
const result = await client._crypto.getSessionBackupPrivateKey();
await client.crypto.storeSessionBackupPrivateKey(key);
const result = await client.crypto.getSessionBackupPrivateKey();
expect(new Uint8Array(result)).toEqual(key);
});
it('caches session backup keys as it encounters them', async function() {
const cachedNull = await client._crypto.getSessionBackupPrivateKey();
const cachedNull = await client.crypto.getSessionBackupPrivateKey();
expect(cachedNull).toBeNull();
client._http.authedRequest = function() {
client.http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
await new Promise((resolve) => {
@@ -566,8 +573,24 @@ describe("MegolmBackup", function() {
{ cacheCompleteCallback: resolve },
);
});
const cachedKey = await client._crypto.getSessionBackupPrivateKey();
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
expect(cachedKey).not.toBeNull();
});
it("fails if an known algorithm is used", async function() {
const BAD_BACKUP_INFO = Object.assign({}, BACKUP_INFO, {
algorithm: "this.algorithm.does.not.exist",
});
client.http.authedRequest = function() {
return Promise.resolve(KEY_BACKUP_DATA);
};
await expect(client.restoreKeyBackupWithRecoveryKey(
"EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d",
ROOM_ID,
SESSION_ID,
BAD_BACKUP_INFO,
)).rejects.toThrow();
});
});
});
+44 -40
View File
@@ -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",
@@ -201,24 +201,28 @@ describe("Cross Signing", function() {
const uploadSigsPromise = new Promise((resolve, reject) => {
alice.uploadKeySignatures = jest.fn(async (content) => {
await olmlib.verifySignature(
alice._crypto._olmDevice,
content["@alice:example.com"][
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
],
"@alice:example.com",
"Osborne2", alice._crypto._olmDevice.deviceEd25519Key,
);
olmlib.pkVerify(
content["@alice:example.com"]["Osborne2"],
"EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ",
"@alice:example.com",
);
resolve();
try {
await olmlib.verifySignature(
alice.crypto._olmDevice,
content["@alice:example.com"][
"nqOvzeuGWT/sRx3h7+MHoInYj3Uk2LD/unI9kDYcHwk"
],
"@alice:example.com",
"Osborne2", alice.crypto._olmDevice.deviceEd25519Key,
);
olmlib.pkVerify(
content["@alice:example.com"]["Osborne2"],
"EmkqvokUn8p+vQAGZitOk4PWjp7Ukp3txV2TbMPEiBQ",
"@alice:example.com",
);
resolve();
} catch (e) {
reject(e);
}
});
});
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",
@@ -226,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
@@ -354,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",
@@ -383,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
@@ -417,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 () => {};
@@ -433,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",
@@ -448,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();
@@ -602,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",
@@ -625,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
@@ -669,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",
@@ -697,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
@@ -729,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",
@@ -766,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,
});
@@ -801,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 () => {};
@@ -834,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");
@@ -844,9 +848,9 @@ 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);
alice.crypto.on("userTrustStatusChanged", resolve);
});
await upgradePromise;
+1 -1
View File
@@ -6,7 +6,7 @@ export async function resetCrossSigningKeys(client, {
level,
authUploadDeviceSigningKeys = async func => await func(),
} = {}) {
const crypto = client._crypto;
const crypto = client.crypto;
const oldKeys = Object.assign({}, crypto._crossSigningInfo.keys);
try {
+18 -18
View File
@@ -47,7 +47,7 @@ async function makeTestClient(userInfo, options) {
await client.initCrypto();
// No need to download keys for these tests
client._crypto.downloadKeys = async function() {};
client.crypto.downloadKeys = async function() {};
return client;
}
@@ -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.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",
@@ -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: {
+6 -6
View File
@@ -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,
},
);
@@ -69,7 +69,7 @@ describe("self-verifications", () => {
const restoreKeyBackupWithCache = jest.fn(() => Promise.resolve());
const client = {
_crypto: {
crypto: {
_crossSigningInfo,
_secretStorage,
storeSessionBackupPrivateKey,
+1 -1
View File
@@ -36,7 +36,7 @@ export async function makeTestClients(userInfos, options) {
});
const client = clientMap[userId][deviceId];
const decryptionPromise = event.isEncrypted() ?
event.attemptDecryption(client._crypto) :
event.attemptDecryption(client.crypto) :
Promise.resolve();
decryptionPromise.then(
+171 -5
View File
@@ -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();
@@ -144,12 +156,12 @@ describe("MatrixClient", function() {
scheduler: scheduler,
userId: userId,
});
// FIXME: We shouldn't be yanking _http like this.
client._http = [
// FIXME: We shouldn't be yanking http like this.
client.http = [
"authedRequest", "getContentUri", "request", "uploadContent",
].reduce((r, k) => { r[k] = jest.fn(); return r; }, {});
client._http.authedRequest.mockImplementation(httpReq);
client._http.request.mockImplementation(httpReq);
client.http.authedRequest.mockImplementation(httpReq);
client.http.request.mockImplementation(httpReq);
// set reasonable working defaults
acceptKeepalives = true;
@@ -166,11 +178,165 @@ describe("MatrixClient", function() {
// means they may call /events and then fail an expect() which will fail
// a DIFFERENT test (pollution between tests!) - we return unresolved
// promises to stop the client from continuing to run.
client._http.authedRequest.mockImplementation(function() {
client.http.authedRequest.mockImplementation(function() {
return new Promise(() => {});
});
});
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);
+155
View File
@@ -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 = <MatrixClient>{
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<EventTimeline>; // 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, '\\$&')}$`),
});
});
});
+857
View File
@@ -0,0 +1,857 @@
/*
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";
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 = <MatrixClient>{
getRoom: (fetchRoomId: string) => {
if (fetchRoomId === roomId) {
return room;
} else {
throw new Error("Unexpected fetch for unknown room");
}
},
};
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.mock.calls.length).toBe(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);
expect(fn.mock.calls.length).toBe(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<typeof client.sendStateEvent>;
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;
(<any>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
(<any>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);
});
});
+2 -2
View File
@@ -104,7 +104,7 @@ describe("Relations", function() {
{
const relationsCreated = new Promise(resolve => {
targetEvent.once("Event.relationsCreated", resolve);
})
});
const timelineSet = new EventTimelineSet(room, {
unstableClientRelationAggregation: true,
@@ -119,7 +119,7 @@ describe("Relations", function() {
{
const relationsCreated = new Promise(resolve => {
targetEvent.once("Event.relationsCreated", resolve);
})
});
const timelineSet = new EventTimelineSet(room, {
unstableClientRelationAggregation: true,
+2 -2
View File
@@ -1314,7 +1314,7 @@ describe("Room", function() {
isRoomEncrypted: function() {
return false;
},
_http: {
http: {
serverResponse,
authedRequest: function() {
if (this.serverResponse instanceof Error) {
@@ -1384,7 +1384,7 @@ describe("Room", function() {
}
expect(hasThrown).toEqual(true);
client._http.serverResponse = [memberEvent];
client.http.serverResponse = [memberEvent];
await room.loadMembersIfNeeded();
const memberA = room.getMember("@user_a:bar");
expect(memberA.name).toEqual("User A");
-262
View File
@@ -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);
});
});
});
+432
View File
@@ -0,0 +1,432 @@
import * as utils from "../../src/utils";
import {
alphabetPad,
averageBetweenStrings,
baseToString,
DEFAULT_ALPHABET,
lexicographicCompare,
nextString,
prevString,
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('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);
});
});
});
+24
View File
@@ -0,0 +1,24 @@
/*
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 interface IIdentityServerProvider {
/**
* Gets an access token for use against the identity server,
* for the associated client.
* @returns {Promise<string>} Resolves to the access token.
*/
getAccessToken(): Promise<string>;
}
+53
View File
@@ -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",
@@ -100,3 +103,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;
}
+39
View File
@@ -0,0 +1,39 @@
/*
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 interface IImageInfo {
size?: number;
mimetype?: string;
thumbnail_info?: { // eslint-disable-line camelcase
w?: number;
h?: number;
size?: number;
mimetype?: string;
};
w?: number;
h?: number;
}
export enum Visibility {
Public = "public",
Private = "private",
}
export enum Preset {
PrivateChat = "private_chat",
TrustedPrivateChat = "trusted_private_chat",
PublicChat = "public_chat",
}
+122
View File
@@ -0,0 +1,122 @@
/*
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 { Callback } from "../client";
import { Preset, Visibility } from "./partials";
// allow camelcase as these are things go onto the wire
/* eslint-disable camelcase */
export interface IJoinRoomOpts {
/**
* True to do a room initial sync on the resulting
* room. If false, the <strong>returned Room object will have no current state.
* </strong> Default: true.
*/
syncRoom?: boolean;
/**
* If the caller has a keypair 3pid invite, the signing URL is passed in this parameter.
*/
inviteSignUrl?: string;
/**
* The server names to try and join through in addition to those that are automatically chosen.
*/
viaServers?: string[];
}
export interface IRedactOpts {
reason?: string;
}
export interface ISendEventResponse {
event_id: string;
}
export interface IPresenceOpts {
presence: "online" | "offline" | "unavailable";
status_msg?: string;
}
export interface IPaginateOpts {
backwards?: boolean;
limit?: number;
}
export interface IGuestAccessOpts {
allowJoin: boolean;
allowRead: boolean;
}
export interface ISearchOpts {
keys?: string[];
query: string;
}
export interface IEventSearchOpts {
filter: any; // TODO: Types
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;
visibility?: Visibility;
name?: string;
topic?: string;
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 {
server?: string;
limit?: number;
since?: string;
// TODO: Proper types
filter?: any & {generic_search_term: string};
}
export interface IUploadOpts {
name?: string;
includeFilename?: boolean;
type?: string;
rawResponse?: boolean;
onlyContentUri?: boolean;
callback?: Callback;
progressHandler?: (state: {loaded: number, total: number}) => void;
}
/* eslint-enable camelcase */
+21
View File
@@ -0,0 +1,21 @@
/*
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 interface ISignatures {
[entity: string]: {
[keyId: string]: string;
};
}
+93
View File
@@ -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<S extends string, U extends string> {
// 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<string, _, _>` as a default type for that namespace.
public findIn<T>(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<S extends string, U extends string> extends NamespacedValue<S, U> {
// 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;
}
}
-2427
View File
File diff suppressed because it is too large Load Diff
-6057
View File
File diff suppressed because it is too large Load Diff
+8472
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -725,7 +725,7 @@ export function createCryptoStoreCacheCallbacks(store, olmdevice) {
/**
* Request cross-signing keys from another device during verification.
*
* @param {module:base-apis~MatrixBaseApis} baseApis base Matrix API interface
* @param {MatrixClient} baseApis base Matrix API interface
* @param {string} userId The user ID being verified
* @param {string} deviceId The device ID being verified
*/
@@ -739,7 +739,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
@@ -775,7 +775,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
// also request and cache the key backup key
const backupKeyPromise = new Promise(async resolve => {
const cachedKey = await client._crypto.getSessionBackupPrivateKey();
const cachedKey = await client.crypto.getSessionBackupPrivateKey();
if (!cachedKey) {
logger.info("No cached backup key found. Requesting...");
const secretReq = client.requestSecret(
@@ -785,7 +785,7 @@ export async function requestKeysDuringVerification(baseApis, userId, deviceId)
logger.info("Got key backup key, decoding...");
const decodedKey = decodeBase64(base64Key);
logger.info("Decoded backup key, storing...");
client._crypto.storeSessionBackupPrivateKey(
client.crypto.storeSessionBackupPrivateKey(
Uint8Array.from(decodedKey),
);
logger.info("Backup key stored. Starting backup restore...");
+2 -2
View File
@@ -204,7 +204,7 @@ export class EncryptionSetupOperation {
// 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(
await baseApis.http.authedRequest(
undefined, "PUT", "/room_keys/version/" + this._keyBackupInfo.version,
undefined, {
algorithm: this._keyBackupInfo.algorithm,
@@ -214,7 +214,7 @@ export class EncryptionSetupOperation {
);
} else {
// add new key backup
await baseApis._http.authedRequest(
await baseApis.http.authedRequest(
undefined, "POST", "/room_keys/version",
undefined, this._keyBackupInfo,
{ prefix: PREFIX_UNSTABLE },
+5 -5
View File
@@ -444,7 +444,7 @@ export class SecretStorage extends EventEmitter {
&& this._incomingRequests[deviceId][content.request_id]) {
logger.info("received request cancellation for secret (" + sender
+ ", " + deviceId + ", " + content.request_id + ")");
this.baseApis.emit("crypto.secrets.requestCancelled", {
this._baseApis.emit("crypto.secrets.requestCancelled", {
user_id: sender,
device_id: deviceId,
request_id: content.request_id,
@@ -480,11 +480,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]: [
@@ -496,7 +496,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,
@@ -527,7 +527,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(),
);
+2 -2
View File
@@ -46,7 +46,7 @@ export const DECRYPTION_CLASSES = {};
* @param {string} params.deviceId The identifier for this device.
* @param {module:crypto} params.crypto crypto core
* @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
* @param {MatrixClient} baseApis base matrix api interface
* @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
*/
@@ -102,7 +102,7 @@ export class EncryptionAlgorithm {
* @param {string} params.userId The UserID for the local user
* @param {module:crypto} params.crypto crypto core
* @param {module:crypto/OlmDevice} params.olmDevice olm.js wrapper
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
* @param {MatrixClient} baseApis base matrix api interface
* @param {string=} params.roomId The ID of the room we will be receiving
* from. Null for to-device events.
*/
+5 -16
View File
@@ -413,9 +413,8 @@ MegolmEncryption.prototype._prepareNewSession = async function(sharedHistory) {
);
// don't wait for it to complete
this._crypto.backupGroupSession(
this._roomId, this._olmDevice.deviceCurve25519Key, [],
sessionId, key.key,
this._crypto._backupManager.backupGroupSession(
this._olmDevice.deviceCurve25519Key, sessionId,
);
return new OutboundSessionInfo(sessionId, sharedHistory);
@@ -1425,11 +1424,7 @@ MegolmDecryption.prototype.onRoomKeyEvent = function(event) {
});
}).then(() => {
// don't wait for the keys to be backed up for the server
this._crypto.backupGroupSession(
content.room_id, senderKey, forwardingKeyChain,
content.session_id, content.session_key, keysClaimed,
exportFormat,
);
this._crypto._backupManager.backupGroupSession(senderKey, content.session_id);
}).catch((e) => {
logger.error(`Error handling m.room_key_event: ${e}`);
});
@@ -1645,14 +1640,8 @@ MegolmDecryption.prototype.importRoomKey = function(session, opts = {}) {
).then(() => {
if (opts.source !== "backup") {
// don't wait for it to complete
this._crypto.backupGroupSession(
session.room_id,
session.sender_key,
session.forwarding_curve25519_key_chain,
session.session_id,
session.session_key,
session.sender_claimed_keys,
true,
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.
+131
View File
@@ -0,0 +1,131 @@
/*
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 { DeviceInfo } from "./deviceinfo";
import { IKeyBackupVersion } from "./keybackup";
import { ISecretStorageKeyInfo } from "../matrix";
// TODO: Merge this with crypto.js once converted
export enum CrossSigningKey {
Master = "master",
SelfSigning = "self_signing",
UserSigning = "user_signing",
}
export interface IEncryptedEventInfo {
/**
* whether the event is encrypted (if not encrypted, some of the other properties may not be set)
*/
encrypted: boolean;
/**
* the sender's key
*/
senderKey: string;
/**
* the algorithm used to encrypt the event
*/
algorithm: string;
/**
* whether we can be sure that the owner of the senderKey sent the event
*/
authenticated: boolean;
/**
* the sender's device information, if available
*/
sender?: DeviceInfo;
/**
* if the event's ed25519 and curve25519 keys don't match (only meaningful if `sender` is set)
*/
mismatchedSender: boolean;
}
export interface IRecoveryKey {
keyInfo: {
pubkey: Uint8Array;
passphrase?: {
algorithm: string;
iterations: number;
salt: string;
};
};
privateKey: Uint8Array;
encodedPrivateKey: string;
}
export interface ICreateSecretStorageOpts {
/**
* Function called to await a secret storage key creation flow.
* Returns:
* {Promise<Object>} 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.
*/
createSecretStorageKey?: () => Promise<IRecoveryKey>;
/**
* The current key backup object. If passed,
* the passphrase and recovery key from this backup will be used.
*/
keyBackupInfo?: IKeyBackupVersion;
/**
* 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.
*/
setupNewKeyBackup?: boolean;
/**
* Reset even if keys already exist.
*/
setupNewSecretStorage?: boolean;
/**
* Function called to get the user's
* current key backup passphrase. Should return a promise that resolves with a Uint8Array
* containing the key, or rejects if the key cannot be obtained.
*/
getKeyBackupPassphrase?: () => Promise<Uint8Array>;
}
export interface ISecretStorageKey {
keyId: string;
keyInfo: ISecretStorageKeyInfo;
}
export interface IAddSecretStorageKeyOpts {
// depends on algorithm
// TODO: Types
}
export interface IImportOpts {
stage: string; // TODO: Enum
successes: number;
failures: number;
total: number;
}
export interface IImportRoomKeysOpts {
progressCallback: (stage: IImportOpts) => void;
untrusted?: boolean;
source?: string; // TODO: Enum
}
+651
View File
@@ -0,0 +1,651 @@
/*
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.
*/
/**
* @module crypto/backup
*
* Classes for dealing with key backup.
*/
import { MatrixClient } from "../client";
import { logger } from "../logger";
import { MEGOLM_ALGORITHM, verifySignature } from "./olmlib";
import { DeviceInfo } from "./deviceinfo";
import { DeviceTrustLevel } from './CrossSigning';
import { keyFromPassphrase } from './key_passphrase';
import { sleep } from "../utils";
import { IndexedDBCryptoStore } from './store/indexeddb-crypto-store';
import { encodeRecoveryKey } from './recoverykey';
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
type AuthData = Record<string, any>;
type BackupInfo = {
algorithm: string,
auth_data: AuthData, // eslint-disable-line camelcase
[properties: string]: any,
};
type SigInfo = {
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[],
};
/** A function used to get the secret key for a backup.
*/
type GetKey = () => Promise<Uint8Array>;
interface BackupAlgorithmClass {
algorithmName: string;
// initialize from an existing backup
init(authData: AuthData, getKey: GetKey): Promise<BackupAlgorithm>;
// prepare a brand new backup
prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]>;
}
interface BackupAlgorithm {
encryptSession(data: Record<string, any>): Promise<any>;
decryptSessions(ciphertexts: Record<string, any>): Promise<Record<string, any>[]>;
authData: AuthData;
keyMatches(key: Uint8Array): Promise<boolean>;
free(): void;
}
/**
* Manages the key backup.
*/
export class BackupManager {
private algorithm: BackupAlgorithm | undefined;
private backupInfo: BackupInfo | 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) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
public get version(): string | undefined {
return this.backupInfo && this.backupInfo.version;
}
public static async makeAlgorithm(info: BackupInfo, getKey: GetKey): Promise<BackupAlgorithm> {
const Algorithm = algorithmsByName[info.algorithm];
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
return await Algorithm.init(info.auth_data, getKey);
}
public async enableKeyBackup(info: BackupInfo): Promise<void> {
this.backupInfo = info;
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
this.baseApis.emit('crypto.keyBackupStatus', true);
// There may be keys left over from a partially completed backup, so
// schedule a send to check.
this.scheduleKeyBackupSend();
}
/**
* Disable backing up of keys.
*/
public disableKeyBackup(): void {
if (this.algorithm) {
this.algorithm.free();
}
this.algorithm = undefined;
this.backupInfo = undefined;
this.baseApis.emit('crypto.keyBackupStatus', false);
}
public getKeyBackupEnabled(): boolean | null {
if (!this.checkedForBackup) {
return null;
}
return Boolean(this.algorithm);
}
public async prepareKeyBackupVersion(
key?: string | Uint8Array | null,
algorithm?: string | undefined,
): Promise<BackupInfo> {
const Algorithm = algorithm ? algorithmsByName[algorithm] : DefaultAlgorithm;
if (!Algorithm) {
throw new Error("Unknown backup algorithm");
}
const [privateKey, authData] = await Algorithm.prepare(key);
const recoveryKey = encodeRecoveryKey(privateKey);
return {
algorithm: Algorithm.algorithmName,
auth_data: authData,
recovery_key: recoveryKey,
privateKey,
};
}
public async createKeyBackupVersion(info: BackupInfo): Promise<void> {
this.algorithm = await BackupManager.makeAlgorithm(info, this.getKey);
}
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
public async checkAndStart(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
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;
try {
backupInfo = await this.baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this.checkedForBackup = true;
}
return null;
}
this.checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
await this.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this.disableKeyBackup();
await this.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return { backupInfo, trustInfo };
}
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
public async checkKeyBackup(): Promise<{backupInfo: BackupInfo, trustInfo: TrustInfo}> {
this.checkedForBackup = false;
return this.checkAndStart();
}
/**
* Check if the given backup info is trusted.
*
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
public async isKeyBackupTrusted(backupInfo: BackupInfo): Promise<TrustInfo> {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this.baseApis.crypto._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this.baseApis.getUserId()] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
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();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await verifySignature(
this.baseApis.crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warn(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// 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(
this.baseApis.getUserId(), sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.baseApis.checkDeviceTrust(
this.baseApis.getUserId(), sigInfo.deviceId,
);
try {
await verifySignature(
this.baseApis.crypto._olmDevice,
backupInfo.auth_data,
this.baseApis.getUserId(),
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this.baseApis.getUserId() +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable = ret.usable || ret.trusted_locally;
return ret;
}
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
public async scheduleKeyBackupSend(maxDelay = 10000): Promise<void> {
if (this.sendingBackups) return;
this.sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay, undefined);
let numFailures = 0; // number of consecutive failures
for (;;) {
if (!this.algorithm) {
return;
}
try {
const numBackedUp =
await this.backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.baseApis.crypto.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)), undefined);
}
}
} finally {
this.sendingBackups = false;
}
}
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
private async backupPendingKeys(limit: number): Promise<number> {
const sessions = await this.baseApis.crypto._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = { sessions: {} };
}
const sessionData = await this.baseApis.crypto._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = MEGOLM_ALGORITHM;
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this.baseApis.crypto._deviceList.getUserByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const device = this.baseApis.crypto._deviceList.getDeviceByIdentityKey(
MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this.baseApis.crypto._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: sessionData.first_known_index,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: await this.algorithm.encryptSession(sessionData),
};
}
await this.baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{ rooms: data },
);
await this.baseApis.crypto._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis.crypto.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
}
public async backupGroupSession(
senderKey: string, sessionId: string,
): Promise<void> {
await this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// this.enableKeyBackup is called
}
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
}
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
public async flagAllGroupSessionsForBackup(): Promise<number> {
await this.baseApis.crypto._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this.baseApis.crypto._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this.baseApis.crypto._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
this.baseApis.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
}
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
public countSessionsNeedingBackup(): Promise<number> {
return this.baseApis.crypto._cryptoStore.countSessionsNeedingBackup();
}
}
export class Curve25519 implements BackupAlgorithm {
public static algorithmName = "m.megolm_backup.v1.curve25519-aes-sha2";
constructor(
public authData: AuthData,
private publicKey: any, // FIXME: PkEncryption
private getKey: () => Promise<Uint8Array>,
) {}
public static async init(
authData: AuthData,
getKey: () => Promise<Uint8Array>,
): Promise<Curve25519> {
if (!authData || !authData.public_key) {
throw new Error("auth_data missing required information");
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return new Curve25519(authData, publicKey, getKey);
}
public static async prepare(
key: string | Uint8Array | null,
): Promise<[Uint8Array, AuthData]> {
const decryption = new global.Olm.PkDecryption();
try {
const authData: AuthData = {};
if (!key) {
authData.public_key = decryption.generate_key();
} else if (key instanceof Uint8Array) {
authData.public_key = decryption.init_with_private_key(key);
} else {
const derivation = await keyFromPassphrase(key);
authData.private_key_salt = derivation.salt;
authData.private_key_iterations = derivation.iterations;
authData.public_key = decryption.init_with_private_key(derivation.key);
}
const publicKey = new global.Olm.PkEncryption();
publicKey.set_recipient_key(authData.public_key);
return [
decryption.get_private_key(),
authData,
];
} finally {
decryption.free();
}
}
public async encryptSession(data: Record<string, any>): Promise<any> {
const plainText: Record<string, any> = Object.assign({}, data);
delete plainText.session_id;
delete plainText.room_id;
delete plainText.first_known_index;
return this.publicKey.encrypt(JSON.stringify(plainText));
}
public async decryptSessions(sessions: Record<string, Record<string, any>>): Promise<Record<string, any>[]> {
const privKey = await this.getKey();
const decryption = new global.Olm.PkDecryption();
try {
const backupPubKey = decryption.init_with_private_key(privKey);
if (backupPubKey !== this.authData.public_key) {
// eslint-disable-next-line no-throw-literal
throw { errcode: MatrixClient.RESTORE_BACKUP_ERROR_BAD_KEY };
}
const keys = [];
for (const [sessionId, sessionData] of Object.entries(sessions)) {
try {
const decrypted = JSON.parse(decryption.decrypt(
sessionData.session_data.ephemeral,
sessionData.session_data.mac,
sessionData.session_data.ciphertext,
));
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
} finally {
decryption.free();
}
}
public async keyMatches(key: Uint8Array): Promise<boolean> {
const decryption = new global.Olm.PkDecryption();
let pubKey;
try {
pubKey = decryption.init_with_private_key(key);
} finally {
decryption.free();
}
return pubKey === this.authData.public_key;
}
public free(): void {
this.publicKey.free();
}
}
export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Curve25519.algorithmName]: Curve25519,
};
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
+16 -3
View File
@@ -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.
@@ -19,10 +19,23 @@ import { IndexedDBCryptoStore } from '../crypto/store/indexeddb-crypto-store';
import { decryptAES, encryptAES } from './aes';
import anotherjson from "another-json";
import { logger } from '../logger';
import { ISecretStorageKeyInfo } from "../matrix";
// FIXME: these types should eventually go in a different file
type Signatures = Record<string, Record<string, string>>;
export interface IDehydratedDevice {
device_id: string; // eslint-disable-line camelcase
device_data: ISecretStorageKeyInfo & { // eslint-disable-line camelcase
algorithm: string;
account: string; // pickle
};
}
export interface IDehydratedDeviceKeyInfo {
passphrase?: string;
}
interface DeviceKeys {
algorithms: Array<string>;
device_id: string; // eslint-disable-line camelcase
@@ -192,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",
@@ -255,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),
+54 -403
View File
@@ -2,7 +2,7 @@
Copyright 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2018-2019 New Vector Ltd
Copyright 2019-2020 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.
@@ -26,7 +26,6 @@ import { EventEmitter } from 'events';
import { ReEmitter } from '../ReEmitter';
import { logger } from '../logger';
import * as utils from "../utils";
import { sleep } from "../utils";
import { OlmDevice } from "./OlmDevice";
import * as olmlib from "./olmlib";
import { DeviceList } from "./DeviceList";
@@ -58,6 +57,7 @@ import { KeySignatureUploadError } from "../errors";
import { decryptAES, encryptAES } from './aes';
import { DehydrationManager } from './dehydration';
import { MatrixEvent } from "../models/event";
import { BackupManager } from "./backup";
const DeviceVerification = DeviceInfo.DeviceVerification;
@@ -85,7 +85,6 @@ export function isCryptoAvailable() {
}
const MIN_FORCE_SESSION_INTERVAL_MS = 60 * 60 * 1000;
const KEY_BACKUP_KEYS_PER_REQUEST = 200;
/**
* Cryptography bits
@@ -97,7 +96,7 @@ const KEY_BACKUP_KEYS_PER_REQUEST = 200;
*
* @internal
*
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
* @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
@@ -154,13 +153,36 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
} else {
this._verificationMethods = defaultVerificationMethods;
}
// track whether this device's megolm keys are being backed up incrementally
// to the server or not.
// XXX: this should probably have a single source of truth from OlmAccount
this.backupInfo = null; // The info dict from /room_keys/version
this.backupKey = null; // The encryption key object
this._checkedForBackup = false; // Have we checked the server for a backup we can use?
this._sendingBackups = false; // Are we currently sending backups?
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(
@@ -232,7 +254,7 @@ export function Crypto(baseApis, sessionStore, userId, deviceId,
// processing the response.
this._sendKeyRequestsImmediately = false;
const cryptoCallbacks = this._baseApis._cryptoCallbacks || {};
const cryptoCallbacks = this._baseApis.cryptoCallbacks || {};
const cacheCallbacks = createCryptoStoreCacheCallbacks(cryptoStore, this._olmDevice);
this._crossSigningInfo = new CrossSigningInfo(
@@ -331,7 +353,7 @@ Crypto.prototype.init = async function(opts) {
this._deviceList.startTrackingDeviceList(this._userId);
logger.log("Crypto: checking for key backup...");
this._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
};
/**
@@ -458,7 +480,7 @@ Crypto.prototype.isSecretStorageReady = async function() {
this._secretStorage,
);
const sessionBackupInStorage = (
!this._baseApis.getKeyBackupEnabled() ||
!this._backupManager.getKeyBackupEnabled() ||
this._baseApis.isKeyBackupKeyStored()
);
@@ -495,7 +517,7 @@ Crypto.prototype.bootstrapCrossSigning = async function({
} = {}) {
logger.log("Bootstrapping cross-signing");
const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks;
const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks;
const builder = new EncryptionSetupBuilder(
this._baseApis.store.accountData,
delegateCryptoCallbacks,
@@ -522,9 +544,11 @@ Crypto.prototype.bootstrapCrossSigning = async function({
builder.addKeySignature(this._userId, this._deviceId, deviceSignature);
// Sign message key backup with cross-signing master key
if (this.backupInfo) {
await crossSigningInfo.signObject(this.backupInfo.auth_data, "master");
builder.addSessionBackup(this.backupInfo);
if (this._backupManager.backupInfo) {
await crossSigningInfo.signObject(
this._backupManager.backupInfo.auth_data, "master",
);
builder.addSessionBackup(this._backupManager.backupInfo);
}
};
@@ -579,7 +603,7 @@ Crypto.prototype.bootstrapCrossSigning = async function({
const crossSigningPrivateKeys = builder.crossSigningCallbacks.privateKeys;
if (
crossSigningPrivateKeys.size &&
!this._baseApis._cryptoCallbacks.saveCrossSigningKeys
!this._baseApis.cryptoCallbacks.saveCrossSigningKeys
) {
const secretStorage = new SecretStorage(
builder.accountDataClientAdapter,
@@ -646,7 +670,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
getKeyBackupPassphrase,
} = {}) {
logger.log("Bootstrapping Secure Secret Storage");
const delegateCryptoCallbacks = this._baseApis._cryptoCallbacks;
const delegateCryptoCallbacks = this._baseApis.cryptoCallbacks;
const builder = new EncryptionSetupBuilder(
this._baseApis.store.accountData,
delegateCryptoCallbacks,
@@ -681,7 +705,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
const ensureCanCheckPassphrase = async (keyId, keyInfo) => {
if (!keyInfo.mac) {
const key = await this._baseApis._cryptoCallbacks.getSecretStorageKey(
const key = await this._baseApis.cryptoCallbacks.getSecretStorageKey(
{ keys: { [keyId]: keyInfo } }, "",
);
if (key) {
@@ -766,6 +790,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
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,
@@ -801,7 +826,7 @@ Crypto.prototype.bootstrapSecretStorage = async function({
// If we have cross-signing private keys cached, store them in secret
// storage if they are not there already.
if (
!this._baseApis._cryptoCallbacks.saveCrossSigningKeys &&
!this._baseApis.cryptoCallbacks.saveCrossSigningKeys &&
await this.isCrossSigningReady() &&
(newKeyId || !await this._crossSigningInfo.isStoredInSecretStorage(secretStorage))
) {
@@ -1071,7 +1096,7 @@ Crypto.prototype._afterCrossSigningLocalKeyChange = async function() {
upload({ shouldEmit: true });
const shouldUpgradeCb = (
this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications
this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications
);
if (shouldUpgradeCb) {
logger.info("Starting device verification upgrade");
@@ -1477,7 +1502,7 @@ Crypto.prototype.checkOwnCrossSigningTrust = async function({
}
// Now we may be able to trust our key backup
await this.checkKeyBackup();
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)?
};
@@ -1509,7 +1534,7 @@ Crypto.prototype._storeTrustedSelfKeys = async function(keys) {
*/
Crypto.prototype._checkDeviceVerifications = async function(userId) {
const shouldUpgradeCb = (
this._baseApis._cryptoCallbacks.shouldUpgradeDeviceVerifications
this._baseApis.cryptoCallbacks.shouldUpgradeDeviceVerifications
);
if (!shouldUpgradeCb) {
// Upgrading skipped when callback is not present.
@@ -1539,206 +1564,11 @@ Crypto.prototype._checkDeviceVerifications = async function(userId) {
logger.info(`Finished device verification upgrade for ${userId}`);
};
/**
* Check the server for an active key backup and
* if one is present and has a valid signature from
* one of the user's verified devices, start backing up
* to it.
*/
Crypto.prototype._checkAndStartKeyBackup = async function() {
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;
try {
backupInfo = await this._baseApis.getKeyBackupVersion();
} catch (e) {
logger.log("Error checking for active key backup", e);
if (e.httpStatus === 404) {
// 404 is returned when the key backup does not exist, so that
// counts as successfully checking.
this._checkedForBackup = true;
}
return null;
}
this._checkedForBackup = true;
const trustInfo = await this.isKeyBackupTrusted(backupInfo);
if (trustInfo.usable && !this.backupInfo) {
logger.log(
"Found usable key backup v" + backupInfo.version +
": enabling key backups",
);
this._baseApis.enableKeyBackup(backupInfo);
} else if (!trustInfo.usable && this.backupInfo) {
logger.log("No usable key backup: disabling key backup");
this._baseApis.disableKeyBackup();
} else if (!trustInfo.usable && !this.backupInfo) {
logger.log("No usable key backup: not enabling key backup");
} else if (trustInfo.usable && this.backupInfo) {
// may not be the same version: if not, we should switch
if (backupInfo.version !== this.backupInfo.version) {
logger.log(
"On backup version " + this.backupInfo.version + " but found " +
"version " + backupInfo.version + ": switching.",
);
this._baseApis.disableKeyBackup();
this._baseApis.enableKeyBackup(backupInfo);
// We're now using a new backup, so schedule all the keys we have to be
// uploaded to the new backup. This is a bit of a workaround to upload
// keys to a new backup in *most* cases, but it won't cover all cases
// because we don't remember what backup version we uploaded keys to:
// see https://github.com/vector-im/element-web/issues/14833
await this.scheduleAllGroupSessionsForBackup();
} else {
logger.log("Backup version " + backupInfo.version + " still current");
}
}
return { backupInfo, trustInfo };
};
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.checkKeyBackup();
};
/**
* Forces a re-check of the key backup and enables/disables it
* as appropriate.
*
* @return {Object} Object with backup info (as returned by
* getKeyBackupVersion) in backupInfo and
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*/
Crypto.prototype.checkKeyBackup = async function() {
this._checkedForBackup = false;
return this._checkAndStartKeyBackup();
};
/**
* @param {object} backupInfo key backup info dict from /room_keys/version
* @return {object} {
* usable: [bool], // is the backup trusted, true iff there is a sig that is valid & from a trusted device
* sigs: [
* valid: [bool || null], // true: valid, false: invalid, null: cannot attempt validation
* deviceId: [string],
* device: [DeviceInfo || null],
* ]
* }
*/
Crypto.prototype.isKeyBackupTrusted = async function(backupInfo) {
const ret = {
usable: false,
trusted_locally: false,
sigs: [],
};
if (
!backupInfo ||
!backupInfo.algorithm ||
!backupInfo.auth_data ||
!backupInfo.auth_data.public_key ||
!backupInfo.auth_data.signatures
) {
logger.info("Key backup is absent or missing required data");
return ret;
}
const trustedPubkey = this._sessionStore.getLocalTrustedBackupPubKey();
if (backupInfo.auth_data.public_key === trustedPubkey) {
logger.info("Backup public key " + trustedPubkey + " is trusted locally");
ret.trusted_locally = true;
}
const mySigs = backupInfo.auth_data.signatures[this._userId] || [];
for (const keyId of Object.keys(mySigs)) {
const keyIdParts = keyId.split(':');
if (keyIdParts[0] !== 'ed25519') {
logger.log("Ignoring unknown signature type: " + keyIdParts[0]);
continue;
}
// Could be a cross-signing master key, but just say this is the device
// ID for backwards compat
const sigInfo = { deviceId: keyIdParts[1] };
// first check to see if it's from our cross-signing key
const crossSigningId = this._crossSigningInfo.getId();
if (crossSigningId === sigInfo.deviceId) {
sigInfo.crossSigningId = true;
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
sigInfo.deviceId,
crossSigningId,
);
sigInfo.valid = true;
} catch (e) {
logger.warning(
"Bad signature from cross signing key " + crossSigningId, e,
);
sigInfo.valid = false;
}
ret.sigs.push(sigInfo);
continue;
}
// 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._deviceList.getStoredDevice(
this._userId, sigInfo.deviceId,
);
if (device) {
sigInfo.device = device;
sigInfo.deviceTrust = await this.checkDeviceTrust(
this._userId, sigInfo.deviceId,
);
try {
await olmlib.verifySignature(
this._olmDevice,
backupInfo.auth_data,
this._userId,
device.deviceId,
device.getFingerprint(),
);
sigInfo.valid = true;
} catch (e) {
logger.info(
"Bad signature from key ID " + keyId + " userID " + this._userId +
" device ID " + device.deviceId + " fingerprint: " +
device.getFingerprint(), backupInfo.auth_data, e,
);
sigInfo.valid = false;
}
} else {
sigInfo.valid = null; // Can't determine validity because we don't have the signing device
logger.info("Ignoring signature from unknown key " + keyId);
}
ret.sigs.push(sigInfo);
}
ret.usable = ret.sigs.some((s) => {
return (
s.valid && (
(s.device && s.deviceTrust.isVerified()) ||
(s.crossSigningId)
)
);
});
ret.usable |= ret.trusted_locally;
return ret;
await this._backupManager.checkKeyBackup();
};
/**
@@ -2785,191 +2615,12 @@ Crypto.prototype.importRoomKeys = function(keys, opts = {}) {
}));
};
/**
* Schedules sending all keys waiting to be sent to the backup, if not already
* scheduled. Retries if necessary.
*
* @param {number} maxDelay Maximum delay to wait in ms. 0 means no delay.
*/
Crypto.prototype.scheduleKeyBackupSend = async function(maxDelay = 10000) {
if (this._sendingBackups) return;
this._sendingBackups = true;
try {
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
// the same time when a new key is sent
const delay = Math.random() * maxDelay;
await sleep(delay);
let numFailures = 0; // number of consecutive failures
while (1) {
if (!this.backupKey) {
return;
}
try {
const numBackedUp =
await this._backupPendingKeys(KEY_BACKUP_KEYS_PER_REQUEST);
if (numBackedUp === 0) {
// no sessions left needing backup: we're done
return;
}
numFailures = 0;
} catch (err) {
numFailures++;
logger.log("Key backup request failed", err);
if (err.data) {
if (
err.data.errcode == 'M_NOT_FOUND' ||
err.data.errcode == 'M_WRONG_ROOM_KEYS_VERSION'
) {
// Re-check key backup status on error, so we can be
// sure to present the current situation when asked.
await this.checkKeyBackup();
// Backup version has changed or this backup version
// has been deleted
this.emit("crypto.keyBackupFailed", err.data.errcode);
throw err;
}
}
}
if (numFailures) {
// exponential backoff if we have failures
await sleep(1000 * Math.pow(2, Math.min(numFailures - 1, 4)));
}
}
} finally {
this._sendingBackups = false;
}
};
/**
* Take some e2e keys waiting to be backed up and send them
* to the backup.
*
* @param {integer} limit Maximum number of keys to back up
* @returns {integer} Number of sessions backed up
*/
Crypto.prototype._backupPendingKeys = async function(limit) {
const sessions = await this._cryptoStore.getSessionsNeedingBackup(limit);
if (!sessions.length) {
return 0;
}
let remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
const data = {};
for (const session of sessions) {
const roomId = session.sessionData.room_id;
if (data[roomId] === undefined) {
data[roomId] = { sessions: {} };
}
const sessionData = await this._olmDevice.exportInboundGroupSession(
session.senderKey, session.sessionId, session.sessionData,
);
sessionData.algorithm = olmlib.MEGOLM_ALGORITHM;
delete sessionData.session_id;
delete sessionData.room_id;
const firstKnownIndex = sessionData.first_known_index;
delete sessionData.first_known_index;
const encrypted = this.backupKey.encrypt(JSON.stringify(sessionData));
const forwardedCount =
(sessionData.forwarding_curve25519_key_chain || []).length;
const userId = this._deviceList.getUserByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const device = this._deviceList.getDeviceByIdentityKey(
olmlib.MEGOLM_ALGORITHM, session.senderKey,
);
const verified = this._checkDeviceInfoTrust(userId, device).isVerified();
data[roomId]['sessions'][session.sessionId] = {
first_message_index: firstKnownIndex,
forwarded_count: forwardedCount,
is_verified: verified,
session_data: encrypted,
};
}
await this._baseApis.sendKeyBackup(
undefined, undefined, this.backupInfo.version,
{ rooms: data },
);
await this._cryptoStore.unmarkSessionsNeedingBackup(sessions);
remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return sessions.length;
};
Crypto.prototype.backupGroupSession = async function(
roomId, senderKey, forwardingCurve25519KeyChain,
sessionId, sessionKey, keysClaimed,
exportFormat,
) {
await this._cryptoStore.markSessionsNeedingBackup([{
senderKey: senderKey,
sessionId: sessionId,
}]);
if (this.backupInfo) {
// don't wait for this to complete: it will delay so
// happens in the background
this.scheduleKeyBackupSend();
}
// if this.backupInfo is not set, then the keys will be backed up when
// client.enableKeyBackup is called
};
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*/
Crypto.prototype.scheduleAllGroupSessionsForBackup = async function() {
await this.flagAllGroupSessionsForBackup();
// Schedule keys to upload in the background as soon as possible.
this.scheduleKeyBackupSend(0 /* maxDelay */);
};
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
* @returns {Promise<int>} Resolves to the number of sessions now requiring a backup
* (which will be equal to the number of sessions in the store).
*/
Crypto.prototype.flagAllGroupSessionsForBackup = async function() {
await this._cryptoStore.doTxn(
'readwrite',
[
IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS,
IndexedDBCryptoStore.STORE_BACKUP,
],
(txn) => {
this._cryptoStore.getAllEndToEndInboundGroupSessions(txn, (session) => {
if (session !== null) {
this._cryptoStore.markSessionsNeedingBackup([session], txn);
}
});
},
);
const remaining = await this._cryptoStore.countSessionsNeedingBackup();
this.emit("crypto.keyBackupSessionsRemaining", remaining);
return remaining;
};
/**
* Counts the number of end to end session keys that are waiting to be backed up
* @returns {Promise<int>} Resolves to the number of sessions requiring backup
*/
Crypto.prototype.countSessionsNeedingBackup = function() {
return this._cryptoStore.countSessionsNeedingBackup();
return this._backupManager.countSessionsNeedingBackup();
};
/**
@@ -3345,10 +2996,10 @@ Crypto.prototype._onRoomKeyEvent = function(event) {
return;
}
if (!this._checkedForBackup) {
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._checkAndStartKeyBackup();
this._backupManager.checkAndStart();
}
const alg = this._getRoomDecryptor(content.room_id, content.algorithm);
+70
View File
@@ -0,0 +1,70 @@
/*
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 { ISignatures } from "../@types/signed";
import { DeviceInfo } from "./deviceinfo";
export interface IKeyBackupSession {
first_message_index: number; // eslint-disable-line camelcase
forwarded_count: number; // eslint-disable-line camelcase
is_verified: boolean; // eslint-disable-line camelcase
session_data: { // eslint-disable-line camelcase
ciphertext: string;
ephemeral: string;
mac: string;
};
}
export interface IKeyBackupRoomSessions {
[sessionId: string]: IKeyBackupSession;
}
export interface IKeyBackupVersion {
algorithm: string;
auth_data: { // eslint-disable-line camelcase
public_key: string; // eslint-disable-line camelcase
signatures: ISignatures;
};
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[];
}[];
}
export interface IKeyBackupPrepareOpts {
secureSecretStorage: boolean;
}
export interface IKeyBackupRestoreResult {
total: number;
imported: number;
}
export interface IKeyBackupRestoreOpts {
cacheCompleteCallback?: () => void;
progressCallback?: ({ stage: string }) => void;
}
+2 -2
View File
@@ -118,7 +118,7 @@ export async function encryptMessageForDevice(
*
* @param {module:crypto/OlmDevice} olmDevice
*
* @param {module:base-apis~MatrixBaseApis} baseApis
* @param {MatrixClient} baseApis
*
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* map from userid to list of devices to ensure sessions for
@@ -168,7 +168,7 @@ export async function getExistingOlmSessions(
*
* @param {module:crypto/OlmDevice} olmDevice
*
* @param {module:base-apis~MatrixBaseApis} baseApis
* @param {MatrixClient} baseApis
*
* @param {object<string, module:crypto/deviceinfo[]>} devicesByUser
* map from userid to list of devices to ensure sessions for
+4 -3
View File
@@ -49,9 +49,10 @@ export class VerificationBase extends EventEmitter {
*
* @class
*
* @param {module:base-apis~Channel} channel the verification channel to send verification messages over.
* @param {Object} channel the verification channel to send verification messages over.
* TODO: Channel types
*
* @param {module:base-apis~MatrixBaseApis} baseApis base matrix api interface
* @param {MatrixClient} baseApis base matrix api interface
*
* @param {string} userId the user ID that is being verified
*
@@ -291,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({
+50
View File
@@ -0,0 +1,50 @@
/*
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 { MatrixEvent } from "./models/event";
export type EventMapper = (obj: any) => MatrixEvent;
export interface MapperOpts {
preventReEmit?: boolean;
decrypt?: boolean;
}
export function eventMapperFor(client: MatrixClient, options: MapperOpts): EventMapper {
const preventReEmit = Boolean(options.preventReEmit);
const decrypt = options.decrypt !== false;
function mapper(plainOldJsObject) {
const event = new MatrixEvent(plainOldJsObject);
if (event.isEncrypted()) {
if (!preventReEmit) {
client.reEmitter.reEmit(event, [
"Event.decrypted",
]);
}
if (decrypt) {
client.decryptEventIfNeeded(event);
}
}
if (!preventReEmit) {
client.reEmitter.reEmit(event, ["Event.replaced"]);
}
return event;
}
return mapper;
}
+3 -50
View File
@@ -1,7 +1,5 @@
/*
Copyright 2015, 2016 OpenMarket Ltd
Copyright 2017 Vector Creations Ltd
Copyright 2019, 2020 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.
@@ -16,17 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type Request from "request";
import { MemoryCryptoStore } from "./crypto/store/memory-crypto-store";
import { LocalStorageCryptoStore } from "./crypto/store/localStorage-crypto-store";
import { IndexedDBCryptoStore } from "./crypto/store/indexeddb-crypto-store";
import { MemoryStore } from "./store/memory";
import { StubStore } from "./store/stub";
import { LocalIndexedDBStoreBackend } from "./store/indexeddb-local-backend";
import { RemoteIndexedDBStoreBackend } from "./store/indexeddb-remote-backend";
import { MatrixScheduler } from "./scheduler";
import { MatrixClient } from "./client";
import { ICreateClientOpts } from "./client";
export * from "./client";
export * from "./http-api";
@@ -94,11 +86,6 @@ export function wrapRequest(wrapper) {
};
}
type Store =
StubStore | MemoryStore | LocalIndexedDBStoreBackend | RemoteIndexedDBStoreBackend;
type CryptoStore = MemoryCryptoStore | LocalStorageCryptoStore | IndexedDBCryptoStore;
let cryptoStoreFactory = () => new MemoryCryptoStore;
/**
@@ -111,41 +98,6 @@ export function setCryptoStoreFactory(fac) {
cryptoStoreFactory = fac;
}
export interface ICreateClientOpts {
baseUrl: string;
idBaseUrl?: string;
store?: Store;
cryptoStore?: CryptoStore;
scheduler?: MatrixScheduler;
request?: Request;
userId?: string;
deviceId?: string;
accessToken?: string;
identityServer?: any;
localTimeoutMs?: number;
useAuthorizationHeader?: boolean;
timelineSupport?: boolean;
queryParams?: Record<string, unknown>;
deviceToImport?: {
olmDevice: {
pickledAccount: string;
sessions: Array<Record<string, any>>;
pickleKey: string;
};
userId: string;
deviceId: string;
};
pickleKey?: string;
sessionStore?: any;
unstableClientRelationAggregation?: boolean;
verificationMethods?: Array<any>;
forceTURN?: boolean;
iceCandidatePoolSize?: number,
supportsCallTransfer?: boolean,
fallbackICEServerAllowed?: boolean;
cryptoCallbacks?: ICryptoCallbacks;
}
export interface ICryptoCallbacks {
getCrossSigningKey?: (keyType: string, pubKey: Uint8Array) => Promise<Uint8Array>;
saveCrossSigningKeys?: (keys: Record<string, Uint8Array>) => void;
@@ -166,6 +118,7 @@ export interface ICryptoCallbacks {
keyInfo: ISecretStorageKeyInfo,
checkFunc: (Uint8Array) => void,
) => Promise<Uint8Array>;
getBackupKey?: () => Promise<Uint8Array>;
}
// TODO: Move this to `SecretStorage` once converted
+102
View File
@@ -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<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
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<void>} Resolves when complete.
*/
public setName(name: string): Promise<void> {
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 };
}
}
+437
View File
@@ -0,0 +1,437 @@
/*
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 } from "../utils";
import { MSC3089Branch } from "./MSC3089Branch";
/**
* 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<void>} Resolves when complete.
*/
public setName(name: string): Promise<void> {
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.
* @returns {Promise<void>} Resolves when complete.
*/
public invite(userId: string): Promise<void> {
// TODO: [@@TR] Reliable invites
// TODO: [@@TR] Share keys
return this.client.invite(this.roomId, userId);
}
/**
* 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<void>} Resolves when complete.
*/
public async setPermissions(userId: string, role: TreePermissions): Promise<void> {
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<MSC3089TreeSpace>} Resolves to the created directory.
*/
public async createDirectory(name: string): Promise<MSC3089TreeSpace> {
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<void>} Resolves when complete.
*/
public async delete(): Promise<void> {
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<void>} Resolves when complete.
* @throws Throws if this is a top level space.
*/
public async setOrder(index: number): Promise<void> {
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<IEncryptedFile>} info The encrypted file information.
* @returns {Promise<void>} Resolves when uploaded.
*/
public async createFile(
name: string,
encryptedContents: ArrayBuffer, info: Partial<IEncryptedFile>,
): Promise<void> {
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);
}
}
+1 -1
View File
@@ -332,7 +332,7 @@ 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;
}
+39 -28
View File
@@ -45,6 +45,7 @@ import * as utils from "../utils";
* @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.
*/
export function RoomMember(roomId, userId) {
this.roomId = roomId;
@@ -61,6 +62,7 @@ export function RoomMember(roomId, userId) {
};
this._isOutOfBand = false;
this._updateModifiedTime();
this.disambiguate = false;
}
utils.inherits(RoomMember, EventEmitter);
@@ -90,6 +92,8 @@ RoomMember.prototype.isOutOfBand = function() {
* @fires module:client~MatrixClient#event:"RoomMember.membership"
*/
RoomMember.prototype.setMembershipEvent = function(event, roomState) {
const displayName = event.getDirectionalContent().displayname;
if (event.getType() !== "m.room.member") {
return;
}
@@ -101,11 +105,19 @@ RoomMember.prototype.setMembershipEvent = function(event, roomState) {
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,
event.getDirectionalContent().displayname,
roomState);
displayName,
roomState,
this.disambiguate,
);
this.rawDisplayName = event.getDirectionalContent().displayname || this.userId;
if (oldMembership !== this.membership) {
@@ -292,43 +304,42 @@ RoomMember.prototype.getMxcAvatarUrl = function() {
const MXID_PATTERN = /@.+:.+/;
const LTR_RTL_PATTERN = /[\u200E\u200F\u202A-\u202F]/;
function calculateDisplayName(selfUserId, displayName, roomState) {
if (!displayName || displayName === selfUserId) {
return selfUserId;
}
function shouldDisambiguate(selfUserId, displayName, roomState) {
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 selfUserId;
}
if (!utils.removeHiddenChars(displayName)) return false;
if (!roomState) {
return displayName;
}
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
let disambiguate = MXID_PATTERN.test(displayName);
if (MXID_PATTERN.test(displayName)) return true;
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);
}
// 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;
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);
}
// 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, displayName, roomState, disambiguate) {
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;
if (disambiguate) {
return displayName + " (" + selfUserId + ")";
}
return displayName;
}
+5
View File
@@ -349,6 +349,11 @@ RoomState.prototype.setStateEvents = function(stateEvents) {
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
+7 -7
View File
@@ -192,13 +192,13 @@ export function Room(roomId, client, myUserId, opts) {
if (this._opts.pendingEventOrdering == "detached") {
this._pendingEventList = [];
const serializedPendingEventList = client._sessionStore.store.getItem(pendingEventsKey(this.roomId));
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);
await event.attemptDecryption(this._client.crypto);
}
event.setStatus(EventStatus.NOT_SENT);
this.addPendingEvent(event, event.getTxnId());
@@ -255,7 +255,7 @@ Room.prototype.decryptCriticalEvents = function() {
.slice(readReceiptTimelineIndex)
.filter(event => event.shouldAttemptDecryption())
.reverse()
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));
.map(event => event.attemptDecryption(this._client.crypto, { isRetry: true }));
return Promise.allSettled(decryptionPromises);
};
@@ -272,7 +272,7 @@ Room.prototype.decryptAllEvents = function() {
.getEvents()
.filter(event => event.shouldAttemptDecryption())
.reverse()
.map(event => event.attemptDecryption(this._client._crypto, { isRetry: true }));
.map(event => event.attemptDecryption(this._client.crypto, { isRetry: true }));
return Promise.allSettled(decryptionPromises);
};
@@ -632,7 +632,7 @@ Room.prototype._loadMembersFromServer = async function() {
});
const path = utils.encodeUri("/rooms/$roomId/members?" + queryString,
{ $roomId: this.roomId });
const http = this._client._http;
const http = this._client.http;
const response = await http.authedRequest(undefined, "GET", path);
return response.chunk;
};
@@ -674,7 +674,7 @@ Room.prototype.loadMembersIfNeeded = function() {
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);
this._client.crypto.trackRoomDevices(this.roomId);
}
return result.fromServer;
}).catch((err) => {
@@ -1387,7 +1387,7 @@ Room.prototype._savePendingEvents = function() {
return isEventEncrypted || !isRoomEncrypted;
});
const { store } = this._client._sessionStore;
const { store } = this._client.sessionStore;
if (this._pendingEventList.length > 0) {
store.setItem(
pendingEventsKey(this.roomId),
+26
View File
@@ -0,0 +1,26 @@
/*
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.
*/
// TODO: Merge this with sync.js once converted
export enum SyncState {
Error = "ERROR",
Prepared = "PREPARED",
Stopped = "STOPPED",
Syncing = "SYNCING",
Catchup = "CATCHUP",
Reconnecting = "RECONNECTING",
}
+9 -19
View File
@@ -21,7 +21,7 @@ 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
* 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.
*/
@@ -209,7 +209,7 @@ SyncApi.prototype.syncLeftRooms = function() {
getFilterName(client.credentials.userId, "LEFT_ROOMS"), filter,
).then(function(filterId) {
qps.filter = filterId;
return client._http.authedRequest(
return client.http.authedRequest(
undefined, "GET", "/sync", qps, undefined, localTimeoutMs,
);
}).then(function(data) {
@@ -349,7 +349,7 @@ SyncApi.prototype._peekPoll = function(peekRoom, token) {
const self = this;
// FIXME: gut wrenching; hard-coded timeout values
this.client._http.authedRequest(undefined, "GET", "/events", {
this.client.http.authedRequest(undefined, "GET", "/events", {
room_id: peekRoom.roomId,
timeout: 30 * 1000,
from: token,
@@ -551,7 +551,7 @@ SyncApi.prototype.sync = function() {
}
try {
debuglog("Storing client options...");
await this.client._storeClientOptions();
await this.client.storeClientOptions();
debuglog("Stored client options");
} catch (err) {
logger.error("Storing client options failed", err);
@@ -815,7 +815,7 @@ SyncApi.prototype._sync = async function(syncOptions) {
SyncApi.prototype._doSyncRequest = function(syncOptions, syncToken) {
const qps = this._getSyncParams(syncOptions, syncToken);
return this.client._http.authedRequest(
return this.client.http.authedRequest(
undefined, "GET", "/sync", qps, undefined,
qps.timeout + BUFFER_PERIOD_MS,
);
@@ -1426,7 +1426,7 @@ SyncApi.prototype._pokeKeepAlive = function(connDidFail) {
}
}
this.client._http.request(
this.client.http.request(
undefined, // callback
"GET", "/_matrix/client/versions",
undefined, // queryParams
@@ -1671,19 +1671,9 @@ SyncApi.prototype._processEventsForNotifs = function(room, timelineEventList) {
* @return {string}
*/
SyncApi.prototype._getGuestFilter = function() {
const guestRooms = this.client._guestRooms; // FIXME: horrible gut-wrenching
if (!guestRooms) {
return "{}";
}
// we just need to specify the filter inline if we're a guest because guests
// can't create filters.
return JSON.stringify({
room: {
timeline: {
limit: 20,
},
},
});
// 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 "{}";
};
/**
+161 -1
View File
@@ -398,7 +398,7 @@ export function ensureNoTrailingSlash(url: string): string {
}
// Returns a promise which resolves with a given value after the given number of ms
export function sleep<T>(ms: number, value: T): Promise<T> {
export function sleep<T>(ms: number, value?: T): Promise<T> {
return new Promise((resolve => {
setTimeout(resolve, ms, value);
}));
@@ -456,3 +456,163 @@ export function setCrypto(c: Object) {
export function getCrypto(): Object {
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);
}
+29 -52
View File
@@ -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;
@@ -441,7 +441,7 @@ export class MatrixCall extends EventEmitter {
* @returns {Array<CallFeed>} local CallFeeds
*/
public getLocalFeeds(): Array<CallFeed> {
return this.feeds.filter((feed) => {return feed.isLocal()});
return this.feeds.filter((feed) => feed.isLocal());
}
/**
@@ -449,7 +449,7 @@ export class MatrixCall extends EventEmitter {
* @returns {Array<CallFeed>} remote CallFeeds
*/
public getRemoteFeeds(): Array<CallFeed> {
return this.feeds.filter((feed) => {return !feed.isLocal()});
return this.feeds.filter((feed) => !feed.isLocal());
}
/**
@@ -511,7 +511,7 @@ export class MatrixCall extends EventEmitter {
// make sure we have valid turn creds. Unless something's gone wrong, it should
// poll and keep the credentials valid so this should be instant.
const haveTurnCreds = await this.client._checkTurnServers();
const haveTurnCreds = await this.client.checkTurnServers();
if (!haveTurnCreds) {
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
}
@@ -530,7 +530,7 @@ export class MatrixCall extends EventEmitter {
return;
}
const remoteStream = this.feeds.find((feed) => {return !feed.isLocal()})?.stream;
const remoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
// According to previous comments in this file, firefox at some point did not
// add streams until media started ariving on them. Testing latest firefox
@@ -598,7 +598,7 @@ export class MatrixCall extends EventEmitter {
this.gotUserMediaForAnswer(mediaStream);
} catch (e) {
this.getUserMediaFailed(e);
return
return;
}
} else if (this.localAVStream) {
this.gotUserMediaForAnswer(this.localAVStream);
@@ -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;
@@ -846,10 +842,10 @@ export class MatrixCall extends EventEmitter {
},
} as MCallAnswer;
if (this.client._supportsCallTransfer) {
if (this.client.supportsCallTransfer) {
answerContent.capabilities = {
'm.call.transferee': true,
}
};
}
// We have just taken the local description from the peerconnection which will
@@ -1096,31 +1092,12 @@ 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);
if (description.type === 'offer') {
// First we sent the direction of the tranciever to what we'd like it to be,
// irresepective of whether the other side has us on hold - so just whether we
// want the call to be on hold or not. This is necessary because in a few lines,
// we'll adjust the direction and unless we do this too, we'll never come off hold.
for (const tranceiver of this.peerConn.getTransceivers()) {
tranceiver.direction = this.isRemoteOnHold() ? 'inactive' : 'sendrecv';
}
const localDescription = await this.peerConn.createAnswer();
await this.peerConn.setLocalDescription(localDescription);
// Now we've got our answer, set the direction to the outcome of the negotiation.
// We need to do this otherwise Firefox will notice that the direction is not the
// currentDirection and try to negotiate itself off hold again.
for (const tranceiver of this.peerConn.getTransceivers()) {
tranceiver.direction = tranceiver.currentDirection;
}
this.sendVoipEvent(EventType.CallNegotiate, {
description: this.peerConn.localDescription,
@@ -1169,7 +1146,7 @@ export class MatrixCall extends EventEmitter {
} catch (err) {
logger.debug("Error setting local description!", err);
this.terminate(CallParty.Local, CallErrorCode.SetLocalDescription, true);
return
return;
}
if (this.peerConn.iceGatheringState === 'gathering') {
@@ -1194,10 +1171,10 @@ export class MatrixCall extends EventEmitter {
content.description = this.peerConn.localDescription;
}
if (this.client._supportsCallTransfer) {
if (this.client.supportsCallTransfer) {
content.capabilities = {
'm.call.transferee': true,
}
};
}
// Get rid of any candidates waiting to be sent: they'll be included in the local
@@ -1304,7 +1281,7 @@ export class MatrixCall extends EventEmitter {
return;
}
const oldRemoteStream = this.feeds.find((feed) => {return !feed.isLocal()})?.stream;
const oldRemoteStream = this.feeds.find((feed) => !feed.isLocal())?.stream;
// If we already have a stream, check this track is from the same one
// Note that we check by ID and always set the remote stream: Chrome appears
@@ -1325,7 +1302,7 @@ export class MatrixCall extends EventEmitter {
logger.debug(`Track id ${ev.track.id} of kind ${ev.track.kind} added`);
this.pushNewFeed(newRemoteStream, this.getOpponentMember().userId, SDPStreamMetadataPurpose.Usermedia)
this.pushNewFeed(newRemoteStream, this.getOpponentMember().userId, SDPStreamMetadataPurpose.Usermedia);
logger.info("playing remote. stream active? " + newRemoteStream.active);
};
@@ -1456,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);
}
/*
@@ -1496,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) {
@@ -1592,14 +1569,14 @@ export class MatrixCall extends EventEmitter {
private async placeCallWithConstraints(constraints: MediaStreamConstraints) {
logger.log("Getting user media with constraints", constraints);
// XXX Find a better way to do this
this.client._callEventHandler.calls.set(this.callId, this);
this.client.callEventHandler.calls.set(this.callId, this);
this.setState(CallState.WaitLocalMedia);
this.direction = CallDirection.Outbound;
this.config = constraints;
// make sure we have valid turn creds. Unless something's gone wrong, it should
// poll and keep the credentials valid so this should be instant.
const haveTurnCreds = await this.client._checkTurnServers();
const haveTurnCreds = await this.client.checkTurnServers();
if (!haveTurnCreds) {
logger.warn("Failed to get TURN credentials! Proceeding with call anyway...");
}
@@ -1621,7 +1598,7 @@ export class MatrixCall extends EventEmitter {
const pc = new window.RTCPeerConnection({
iceTransportPolicy: this.forceTURN ? 'relay' : undefined,
iceServers: this.turnServers,
iceCandidatePoolSize: this.client._iceCandidatePoolSize,
iceCandidatePoolSize: this.client.iceCandidatePoolSize,
});
// 'connectionstatechange' would be better, but firefox doesn't implement that.
@@ -1826,7 +1803,7 @@ export function createNewMatrixCall(client: any, roomId: string, options?: CallO
roomId: roomId,
turnServers: client.getTurnServers(),
// call level options
forceTURN: client._forceTURN || optionsForceTURN,
forceTURN: client.forceTURN || optionsForceTURN,
};
const call = new MatrixCall(opts);
+3 -3
View File
@@ -87,7 +87,7 @@ export class CallEventHandler {
}
this.callEventBuffer = [];
}
}
};
private onEvent = (event: MatrixEvent) => {
this.client.decryptEventIfNeeded(event);
@@ -117,7 +117,7 @@ export class CallEventHandler {
}
});
}
}
};
private eventIsACall(event: MatrixEvent): boolean {
const type = event.getType();
@@ -155,7 +155,7 @@ export class CallEventHandler {
const timeUntilTurnCresExpire = this.client.getTurnServersExpiry() - Date.now();
logger.info("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
call = createNewMatrixCall(this.client, event.getRoomId(), {
forceTURN: this.client._forceTURN,
forceTURN: this.client.forceTURN,
});
if (!call) {
logger.log(
+2 -2
View File
@@ -16,7 +16,7 @@ limitations under the License.
import EventEmitter from "events";
import { SDPStreamMetadataPurpose } from "./callEventTypes";
import MatrixClient from "../client"
import { MatrixClient } from "../client";
import { RoomMember } from "../models/room-member";
export enum CallFeedEvent {
@@ -31,7 +31,7 @@ export class CallFeed extends EventEmitter {
private client: MatrixClient,
private roomId: string,
) {
super()
super();
}
/**
+2 -2
View File
@@ -2845,8 +2845,8 @@ eslint-config-google@^0.14.0:
integrity sha512-WsbX4WbjuMvTdeVL6+J3rK1RGhCTqjsFjX7UMSMgZiyxxaNLkoJENbrGExzERFeoTpGw3F3FypTiWAP9ZXzkEw==
"eslint-plugin-matrix-org@github:matrix-org/eslint-plugin-matrix-org#main":
version "0.3.1"
resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/b55649a0f48ee27155c1968ed5050b6ddc5afdbe"
version "0.3.2"
resolved "https://codeload.github.com/matrix-org/eslint-plugin-matrix-org/tar.gz/28d392822533a7468be0dd806d0a4ba573a45d74"
eslint-rule-composer@^0.3.0:
version "0.3.0"