Compare commits

...

137 Commits

Author SHA1 Message Date
RiotRobot ab6260074d v34.0.0-rc.0 2024-06-25 12:50:01 +00:00
Andrew Ferrazzutti 25a7c9e140 Prefix the user+device state key if needed (#4262)
* Prefix the user+device state key if needed

No need to prefix it for rooms that use MSC3779.
Otherwise, prefix it to bypass the auth rule for state events with keys
starting with @.

* Use RegExp.exec() method instead

Sonar typescript:S6594

* Split nested ternary operator into method

Sonar typescript:S3358

* Add test coverage
2024-06-21 17:31:42 +00:00
Michael Telatynski 78b6b878bd Remove domexception polyfill, has been native in NodeJS since v17 (#4253)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-21 14:15:54 +00:00
Richard van der Hoff 4ccb72c0f2 Element-R: Fix resource leaks in verification logic (#4263)
* Move `RustVerificationRequest.onChange` out to a method

The only reason it was an inner function in the first place was to avoid
storing a reference in the class to `outgoingRequestProcessor`. That changed
with d1dec4cd08.

* Fix reference cycles in rust verification code
2024-06-21 12:55:43 +00:00
Richard van der Hoff 9f1aebbdcb Bump ES target version to ES2022 (#4264)
* Bump ES target version to ES2022

I want to be able to use `WeakRef`, and per
https://github.com/element-hq/element-web/issues/24913#issuecomment-2182448007,
I believe this should be safe.

* room.ts: Fix initialisation order

It seems that ES2022 causes typescript to change the initialization order of
regular properties vs parameter properties
(https://github.com/microsoft/TypeScript/issues/45995), so we need to rearrange
the initializations to avoid an error.

In practice, it might be fine because we have enabled
`babel-plugin-transform-class-properties`, which moves the initialization back
after the parameter property, but we shoudn't rely on that, and anyway it
upsets the linter.
2024-06-21 11:50:28 +00:00
Andrew Ferrazzutti 6a15e8f1a0 Use legacy call membership if anyone else is (#4260)
* Use legacy call membership if anyone else is

* Convert nullish to boolean

* Update tests

* Lint

* Use computed decision to use legacy events or not

* Check if discovered legacy sessions are ongoing

* Lint

* Lint again

* Increase test coverage
2024-06-21 11:40:27 +00:00
Richard van der Hoff 238eea0ef5 Upgrade Rust Crypto SDK to 6.1.0 (#4261)
Fixes https://github.com/element-hq/element-web/issues/27590
2024-06-20 08:28:17 +00:00
Michael Telatynski ab6f86536f Replace deprecated babel proposal plugins (#4254)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-19 15:11:38 +00:00
David Baker 819fc75202 Fetch capabilities in the background (#4246)
* Fetch capabilities in the background

& keep them up to date

* Add missed await

* Replace some more runAllTimers

and round down the wait time for sanity

* Remove double comment

* Typo

* Add a method back that will fetch capabilities if they're not already there

* Add tests

* Catch exception here too

* Add test for room version code
2024-06-19 10:24:56 +00:00
renovate[bot] c70aa33367 Update typescript-eslint monorepo to v7.13.1 (#4257)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 09:50:23 +00:00
renovate[bot] 240b43b652 Update typedoc (#4258)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 08:57:01 +00:00
renovate[bot] 697d5d31d1 Update all non-major dependencies (#4256)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-19 08:54:29 +00:00
Robin b1701ff571 Correctly transform base64 with multiple instances of + or / (#4252)
String.replace only replaces a single instance of the search pattern by default; we need a regex in g mode if we want to replace them all.
2024-06-18 14:58:16 +00:00
Michael Telatynski c55289ec65 Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation (#4233)
* Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-18 13:03:14 +00:00
RiotRobot 987ec1e62f Resetting package fields for development 2024-06-18 11:55:23 +00:00
RiotRobot 76b240cf57 Merge branch 'master' into develop 2024-06-18 11:55:21 +00:00
RiotRobot a4c4e7e275 v33.1.0 2024-06-18 11:54:47 +00:00
Johannes Marbach 3f5a994a24 Add via parameter for MSC4156 (#4247)
* Add via parameter for MSC4156

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Always include both parameters

* Fix tests

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2024-06-18 08:14:48 +00:00
Timo d754392410 Make the js-sdk compatible with MSC preferred foci and active focus. (#4195)
* Refactor to preferred and active foci.

Signed-off-by: Timo K <toger5@hotmail.de>

* make the sdk compatible with MSC4143 but still be backwards compatible

* comment fixes

* also fallback to legacy if the current member event is legacy

* use XOR types

* use EitherAnd

* make livekit Foucs types simpler

* review

* fix tests

* test work

* more review + more tests

* remove unnecassary await that is in conflict with the comment

* make joinRoomSession sync

* Update src/matrixrtc/MatrixRTCSession.ts

Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>

* review

* fix

* test

* review

* review

* comment clarification

* typo

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Andrew Ferrazzutti <af_0_af@hotmail.com>
2024-06-17 13:02:29 +00:00
Michael Telatynski 7ecaa53e34 Work around spec bug for m.room.avatar state event content type (#4245)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-17 09:30:13 +00:00
RiotRobot 222e95d33f v33.1.0-rc.1 2024-06-14 12:18:37 +00:00
ElementRobot 2ee43cade7 [Backport staging] Fix screen sharing in recent Chrome (#4243)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2024-06-13 20:54:53 +01:00
David Baker 9218f6380c Fix screen sharing in recent Chrome (#4241)
* Fix screen sharing in recent Chrome

Dreadful hack to work around a bug in recent chrome/electron's
WebRTC, as explained.

I'm not sure which is the least hideous out of this (ie. repeatedly
calling setCodecPreferences and seeing if it crashes each time) or
hardcoding the bad codec and skipping it. Opinions welcome.

* Unused import

* Remove commented line
2024-06-13 18:44:30 +00:00
Travis Ralston 661ba76763 Use stable endpoints for MSC3916 (#4239)
* Use stable endpoints for MSC3916

* appease the linter
2024-06-13 17:03:25 +00:00
Michael Telatynski 4cb851c51a Replace usages of setImmediate with setTimeout for wider compatibility (#4240)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-06-13 16:01:33 +01:00
renovate[bot] 5a3d24abc2 Update typescript-eslint monorepo to v7.12.0 (#4237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 13:15:29 +00:00
renovate[bot] 3eed74f1a6 Update dependency uuid to v10 (#4238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:26:43 +00:00
renovate[bot] 10e7a2d997 Update dependency typedoc-plugin-mdn-links to v3.1.28 (#4236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:17:02 +00:00
renovate[bot] 969ecdb6fb Update dependency @types/node to v18.19.34 (#4235)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:16:52 +00:00
renovate[bot] e8b91f2729 Update babel monorepo to v7.24.7 (#4234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:16:38 +00:00
renovate[bot] f80366ff30 Update all non-major dependencies (#4229)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-11 12:09:01 +00:00
RiotRobot 395c3cfcd6 v33.1.0-rc.0 2024-06-11 12:06:39 +00:00
Hubert Chathi f95954c233 Add support for stable name for MSC4115 (#4232)
* add support for stable name for MSC4115

* fix types issues

* prettier

* actually, it still returns `undefined`
2024-06-07 08:47:17 +00:00
Michael Telatynski fa5f2d389a Fix incorrect assumptions about required fields in /search response (#4228)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-06 21:01:16 +00:00
Michael Telatynski 9fc557fc6b Fix typo
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-06 12:11:16 +01:00
Michael Telatynski 6436fbb99f MSC4108 support OIDC QR code login (#4134)
Co-authored-by: Hugh Nimmo-Smith <hughns@users.noreply.github.com>
Co-authored-by: Hugh Nimmo-Smith <hughns@matrix.org>
2024-06-06 09:57:26 +01:00
Michael Telatynski 87c2ac3ffa Use LegacyRendezvousFailureReason over RendezvousFailureReason (#4231)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-05 15:56:37 +00:00
Richard van der Hoff 43022d5b2f RustCrypto: fix ordering of methods (#4230)
* RustCrypto: Move CryptoBackend impl to CryptoBackend impl section

Given there is a `CryptoBackend implementation` section, the methods
implementing CryptoBackend should be there.

* RustCrypto: Fix documentation on dehydration methods

* RustCrypto: reunite `resetKeyBackup` with its helper

A couple of new methods had snuck into the middle.
2024-06-05 12:02:45 +00:00
renovate[bot] a0fadeb4ec Update babel monorepo to v7.24.6 (#4221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-05 10:14:04 +00:00
Michael Telatynski a3cea8ce7d Add crypto methods for export and import of secrets bundle (#4227)
* Add crypto methods for OIDC QR code login

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Improve test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Revert test due to hang inside Rust.

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update test name

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update test name

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-06-05 09:27:20 +00:00
renovate[bot] c88487da07 Update all non-major dependencies (#4226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-06-04 17:21:20 +00:00
RiotRobot 89875b8e31 Resetting package fields for development 2024-06-04 13:18:49 +00:00
RiotRobot 9c94393d76 Merge branch 'master' into develop 2024-06-04 13:18:48 +00:00
RiotRobot 7850294a4b v33.0.0 2024-06-04 13:18:11 +00:00
renovate[bot] 131e81401a Update typescript-eslint monorepo to v7.10.0 (#4223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-30 16:21:12 +00:00
David Baker 5c27e30302 Fix the queueToDevice tests for the new fakeindexeddb (#4225)
https://github.com/dumbmatter/fakeIndexedDB/pull/93 causes a bunch
of tests to start failing because the fake timers need running in
order for fake indexeddb to work. It also seems to cause failures
to bleed between tests somehow if fake timers are enabled/disabled.
This keeps all the fake timer tests in one suite and all the others
in another, which appears to work.

This should allow https://github.com/matrix-org/matrix-js-sdk/pull/4224
to be merged.
2024-05-30 15:16:42 +00:00
renovate[bot] 8dfb6de3cc Update all non-major dependencies (#4220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-30 14:10:12 +00:00
renovate[bot] 042610310f Update dependency typedoc-plugin-mdn-links to v3.1.27 (#4222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-30 13:42:20 +00:00
renovate[bot] 8535604200 Update actions/checkout digest to a5ac7e5 (#4219)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-30 11:45:14 +00:00
Hugh Nimmo-Smith 3ee64722c5 Add note about MSC3886, MSC3903 and MSC3906 being closed (#4189)
* Add note about MSC3886, MSC3903 and MSC3906 being closed

* Move comments in to jsdoc

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-30 11:23:24 +00:00
RiotRobot 9d6210b3f9 v33.0.0-rc.0 2024-05-29 12:54:25 +00:00
Andy Balaam 909caab74e Don't run migration for Rust crypto if the legacy store is empty (#4218)
* Don't run migration for Rust crypto if the legacy store is empty

Fixes https://github.com/element-hq/element-web/issues/27447

* Add copyright for the TypeScript files in legacy DB dumps

* Provide a type for the accountPickle we check for before migration

* Remove redundant backup response

This is unused

* Simplify keys response

* Downgrade log message.

---------

Co-authored-by: Richard van der Hoff <richard@matrix.org>
2024-05-28 17:43:25 +00:00
Michael Telatynski 7c87625157 Remove more deprecated methods, fields, and exports (#4217) 2024-05-28 09:12:55 +01:00
Richard van der Hoff b19817bb73 Bump matrix-sdk-crypto-wasm to 5.0.0 (#4216)
Slightly more involved than normal because it requires us to pass a backup version into OlmMachine.importBackedUpRoomKeys.

On the other hand we can now re-enable the test that was disabled in #4214 due to matrix-org/matrix-rust-sdk#3447

Fixes: element-hq/element-web#27165
2024-05-24 12:10:52 +01:00
Richard van der Hoff 36196ea422 initRustCrypto: allow app to pass in the store key directly (#4210)
* `initRustCrypto`: allow app to pass in the store key directly

... instead of using the pickleKey. This allows us to avoid a slow PBKDF
operation.

* Fix link in doc-comment
2024-05-24 09:52:34 +00:00
renovate[bot] a81adf542e Update dependency @matrix-org/matrix-sdk-crypto-wasm to v4.10.0 (#4214)
* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v4.10.0

* Disable affected test

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-23 11:19:41 +00:00
RiotRobot a49bc3ddf4 Merge branch 'master' into develop 2024-05-22 12:04:33 +00:00
RiotRobot a86d4ceb49 v32.4.0 2024-05-22 12:04:07 +00:00
R Midhun Suresh 8c3be2a56a Update organization (#4212) 2024-05-21 15:55:49 +00:00
RiotRobot 38898a60c7 v32.3.0 2024-05-21 12:24:52 +00:00
Bayyr Oorjak fd3a4d4403 Preserve ESM for async imports to work correctly (#4187)
* fix: fix lazy rust crypto import

* test: use "commonjs" for tests because of circular deps

* chore: revert commonjs for "module"

* refactor: remove unnecessary example

* refactor: add comments

Signed-off-by: Bayyr Oorjak <the.bayyr.oorjak@gmail.com>

* refactor: improve comment

Signed-off-by: Bayyr Oorjak <the.bayyr.oorjak@gmail.com>

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update babel.config.js

---------

Signed-off-by: Bayyr Oorjak <the.bayyr.oorjak@gmail.com>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-05-21 11:48:00 +00:00
RiotRobot 93d96281fd Resetting package fields for development 2024-05-21 12:25:26 +00:00
RiotRobot 944dc51c58 Merge branch 'master' into develop 2024-05-21 12:25:25 +00:00
renovate[bot] c6b43dd176 Update dependency eslint-plugin-unicorn to v53 (#4209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 16:56:22 +00:00
Michael Telatynski f03dd7b7bc Remove deprecated methods and fields (#4201)
* Remove legacy `threepidCreds` field

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove deprecated shouldUpgradeToVersion

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove `added` legacy login request field

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove deprecated re-exports

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Remove `home_server` field

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update imports in tests

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-15 15:23:04 +00:00
Michael Telatynski 51fa1866a9 Wire up verification cancel & mismatch for rust crypto (#4202)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-15 14:58:24 +00:00
renovate[bot] d76fb2baa0 Update all non-major dependencies (#4207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 11:51:51 +00:00
renovate[bot] 3feafc9c17 Update dependency typedoc-plugin-mdn-links to v3.1.25 (#4206)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 11:28:29 +00:00
Michael Telatynski c9075b3dba Only pass id_server if we had one to begin with (#4200)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-15 10:59:42 +00:00
renovate[bot] 69c474dda7 Update dependency @types/node to v18.19.33 (#4205)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 10:54:21 +00:00
renovate[bot] 73ce51065f Update mheap/github-action-required-labels digest to 5847eef (#4204)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 10:26:32 +00:00
renovate[bot] 5d0407d0a6 Update actions/checkout digest to 0ad4b8f (#4203)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-15 10:23:58 +00:00
RiotRobot 3a4b02d8e6 v32.3.0-rc.0 2024-05-15 09:04:25 +00:00
Kegan Dougal d421e7f829 Run complement-crypto in CI (#4197)
* Maybe run complement-crypto

* Use existing checkout

* Test that things fail if crypto breaks

* Fix test; run only on merge queue

* Prettier

* Maybe get it working in a merge queue
2024-05-13 10:26:27 +00:00
Michael Telatynski 9fd051af33 Update downstream-end-to-end-tests.yml 2024-05-13 11:20:47 +01:00
Michael Telatynski b78a1ad889 Hotfix types export point
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-10 13:26:07 +01:00
Michael Telatynski a25cdcecaa Fix state_events.ts types (#4196)
* Fix state_events.ts types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Iterate

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-10 10:55:44 +00:00
RiotRobot 2a716bd076 Resetting package fields for development 2024-05-07 12:16:24 +00:00
RiotRobot ef1db8d664 Merge branch 'master' into develop 2024-05-07 12:16:23 +00:00
RiotRobot 22865fd834 v32.2.0 2024-05-07 12:15:48 +00:00
Michael Telatynski c4fe564855 Simplify OIDC types & export decodeIdToken (#4193)
* Fix types

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Export `decodeIdToken`

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-07 10:21:57 +00:00
Michael Telatynski 9ecb1a0381 Fix sendEventHttpRequest for m.room.redaction events without redacts (#4192)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-05-07 08:50:37 +00:00
Travis Ralston ef9490c7b1 Add missing (and common-ish) HTTP method verbs to types (#4188) 2024-05-02 23:09:15 +00:00
Travis Ralston 402adfbe8a Add helpers for authenticated media, and associated documentation (#4185)
* Add helpers for authenticated media, and associated documentation

* Appease the linter
2024-05-02 21:11:09 +00:00
renovate[bot] 41e8c2af34 Update dependency typedoc-plugin-coverage to v3.1.1 (#4186)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-05-01 10:23:17 +00:00
renovate[bot] 4843b40296 Update dependency typedoc-plugin-mdn-links to v3.1.22 (#4182)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:18:45 +00:00
renovate[bot] bc2c870152 Update babel monorepo to v7.24.5 (#4181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:17:37 +00:00
renovate[bot] 7c7b2817d3 Update all non-major dependencies (#4184)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-30 13:16:31 +00:00
renovate[bot] 9f78202ecd Update typescript-eslint monorepo to v7.7.1 (#4183)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-30 14:40:57 +01:00
RiotRobot abc9911e95 v32.2.0-rc.0 2024-04-30 12:02:20 +00:00
David Baker efdae0d66f Remove spammy RTCSession log line (#4180)
This can get quite spammy in rooms with legacy calls and isn't really
neccesary: remove it to cut down on the log spam.
2024-04-30 10:43:20 +00:00
David Baker 2bf554761c Add some comments to the release drafter workflows (#4140)
* Add some comments to the release drafter workflows

* Rename component workflow so they have different names

* Fix comment

---------

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-30 09:38:07 +00:00
Michael Telatynski c2687643b5 Update downstream-end-to-end-tests.yml 2024-04-30 10:30:19 +01:00
Michael Telatynski a33758eda6 Update downstream-end-to-end-tests.yml 2024-04-30 10:28:44 +01:00
Michael Telatynski 8faed02cc5 Update sonarcloud.yml 2024-04-29 17:22:32 +01:00
Michael Telatynski 3ae0dab47a Update sonarcloud.yml 2024-04-29 16:59:55 +01:00
Michael Telatynski 95394e4cbe Update sonarcloud.yml 2024-04-29 16:52:54 +01:00
Michael Telatynski 8c9bbc01fc Update downstream-end-to-end-tests.yml 2024-04-29 16:20:24 +01:00
Michael Telatynski eb888791a3 Pass all args to Sonar via sonar-project.properties file (#4179)
* Pass all args to Sonar via sonar-project.properties file

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Update Sonarcloud action to v3.1

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Apply suggestions from code review

Co-authored-by: davidegirardi <16451191+davidegirardi@users.noreply.github.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: davidegirardi <16451191+davidegirardi@users.noreply.github.com>
2024-04-29 15:12:00 +00:00
Michael Telatynski 6c0b2f55e1 Delete .github/workflows/downstream-artifacts.yml 2024-04-29 10:53:12 +01:00
Michael Telatynski c9a5eaece3 Simplify Playwright CI (#4178) 2024-04-29 08:49:00 +01:00
Hubert Chathi 64505de36b Use a different error code for UTDs when user was not in the room (#4172)
* use a different error code for UTDs when user was not in the room

* if user is invited, treat it as unexpected UTD
2024-04-26 13:38:10 +00:00
Valere 65d858f9a3 Fix rust migration when ssss secret not encrypted (#4168) 2024-04-26 12:59:17 +00:00
RiotRobot 1da5e8f56a Resetting package fields for development 2024-04-23 12:34:26 +00:00
RiotRobot 5efd4c2915 Merge branch 'master' into develop 2024-04-23 12:34:25 +00:00
RiotRobot bc03950f8a v32.1.0 2024-04-23 12:33:56 +00:00
Travis Ralston c09da9a23f Modernize window.crypto access constants (#4169)
* Force service worker-safe crypto when operating under a service worker

* Fix tests/unsafe execution

* Further fix tests?

* Docs would probably be good

* Define a type guard function

https://www.typescriptlang.org/docs/handbook/advanced-types.html#user-defined-type-guards

* Use `@types` repo

* Maybe don't modify tsconfig, I guess

* Revert "Use `@types` repo"

This reverts commit db46bcf1db4b94fbc7e0c97a20d5d800fcb2768b.

* Use a different type for Window

* Simplify the crypto accessors
2024-04-22 19:44:01 +00:00
Andrew Ferrazzutti e874468ba3 Improve compliance with MSC3266 (#4155)
* Fix fields of MSC 3266 summary object

Also remove redundant room_type field which is inherited from elsewhere

* Export the MSC 3266 summary type

* Use proper endpoint for MSC 3266 summary lookup

Use the endpoint recommended by the MSC

* Rename newly-exported symbol to not start with I

* Use "export type"

* Lint

* Fix type of "encryption" field

* Add TSDoc documentation

* Add basic integration test for getRoomSummary

* Lint

* Use fallback endpoint for MSC3266

* Improve test coverage

* Lint

* Refactor async catch to satisfy linter

* Increase test coverage
2024-04-22 17:39:10 +00:00
Johannes Marbach 6fedda91f9 Use encoded URI components when storing sessions in memory crypto store (#4170)
* Use encoded URI components when storing sessions in memory crypto store

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>

* Add URI en-/decoding to missing methods

* Extract convenience functions

---------

Signed-off-by: Johannes Marbach <n0-0ne+github@mailbox.org>
2024-04-22 08:12:54 +00:00
Valere d22a39f5d7 Element R: fix isCrossSigningReady not checking identity trust (#4156)
* Fix inconsistency between rust and legacy

* Add tests

* Review: better comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: Better doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Simplify test data and some comments

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-04-17 13:36:52 +00:00
Timo 4fc6ba884e add comment to make clear that RoomStateEvent.Events does not update related objects in the js-sdk (#4152)
Signed-off-by: Timo K <toger5@hotmail.de>
2024-04-17 10:28:40 +00:00
Richard van der Hoff c30e498013 Crypto: use a new error code for UTDs from device-relative historical events (#4139)
* Add `PerSessionKeyBackupDownloader.isKeyBackupDownloadConfigured()`

* Add new `RustBackupManager.getServerBackupInfo`

... and a convenience method in PerSessionKeyBackupDownloader to access it.

* Crypto.spec: move `useRealTimers` to global `afterEach`

... so that we don't need to remember to do it everywhere.

* Use fake timers for UTD error code tests

This doesn't have any effect on the tests, but *does* stop jest from hanging
when you run the tests in in-band mode. It shouldn't *really* be needed, but
using fake timers gives more reproducible tests, and I don't have the
time/patience to debug why it is needed.

* Use new error codes for UTDs from historical events
2024-04-17 10:26:41 +00:00
renovate[bot] 8240bf0ae7 Update shogo82148/actions-upload-release-asset digest to 8f032ef (#4157)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 15:59:04 +00:00
renovate[bot] 2321c44687 Update typedoc (#4162)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 15:22:17 +00:00
renovate[bot] 0137e9d5a8 Update dependency eslint-plugin-unicorn to v52 (#4166)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 14:29:48 +00:00
renovate[bot] 28bbc51752 Update dependency @types/node to v18.19.31 (#4160)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 14:06:49 +00:00
renovate[bot] 0db3ac9b43 Update dependency eslint-plugin-jest to v28 (#4165)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:56:50 +00:00
renovate[bot] 53039b78ee Update all non-major dependencies (#4158)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:25:06 +00:00
renovate[bot] 2a06d19431 Update babel monorepo to v7.24.4 (#4159)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:24:46 +00:00
renovate[bot] a747eef04c Update dependency typescript to v5.4.5 (#4161)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:24:27 +00:00
renovate[bot] 583823c2ef Update dependency knip to v5 (#4167)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:21:14 +00:00
renovate[bot] 26d13c15c3 Update typescript-eslint monorepo to v7.6.0 (#4163)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-16 13:20:45 +00:00
RiotRobot c850ca3179 v32.1.0-rc.0 2024-04-16 12:16:13 +00:00
Valere 8438533532 Validate backup private key before migrating it (#4114)
* Migrate own identity trust to rust crypto

* Fix gendoc not happy if msk of IDownloadKeyResult has a signature

* add missing mock

* code review

* Code review

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review move function down in file

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review gh suggestion

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review: Cleaning tests, renaming

* Review: better comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Comment paragraphs

* retry until initial  key query is successfull

* Validate backup private key before migrating it

* post merge fix

* Fix test, missing mock

* Use crypto wasm instead of lib olm to check backup key

* typo

* code review

* quick lint

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-04-15 07:17:14 +00:00
David Baker 475f82c5ce Update git-get-release action (#4148)
which appears to be breaking renovate somehow, and could probably use
and update anyway.
2024-04-11 09:36:07 +00:00
Hubert Chathi 936e7c3072 Add support for device dehydration v2 (Element R) (#4062)
* initial implementation of device dehydration

* add dehydrated flag for devices

* add missing dehydration.ts file, add test, add function to schedule dehydration

* add more dehydration utility functions

* stop scheduled dehydration when crypto stops

* bump matrix-crypto-sdk-wasm version, and fix tests

* adding dehydratedDevices member to mock OlmDevice isn't necessary any more

* fix yarn lock file

* more tests

* fix test

* more tests

* fix typo

* fix logic for checking if dehydration supported

* make changes from review

* add missing file

* move setup into another function

* apply changes from review

* implement simpler API

* fix type and move the code to the right spot

* apply suggestions from review

* make sure that cross-signing and secret storage are set up
2024-04-11 04:01:47 +00:00
RiotRobot 82ed7bd86a Resetting package fields for development 2024-04-09 10:07:09 +00:00
RiotRobot cb67eae858 Merge branch 'master' into develop 2024-04-09 10:07:07 +00:00
Michael Telatynski 5cdd524da7 OIDC improvements in prep of OIDC-QR reciprocation (#4149)
* Add `device_authorization_endpoint` field to OIDC issuer well-known metadata

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Allow `validateIdToken` to skip handling nonce when none is present

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

* Tweak registerOidcClient to check OIDC grant_types_supported before registration

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-04-08 14:07:00 +00:00
Michael Telatynski 0ff0093380 Add knip unused code & dependency analyser (#4013) 2024-04-08 11:04:40 +01:00
Valere b352405c89 ElementR| Retry query backup until it works during migration to avoid spurious correption error popup (#4113)
* retry query backup until it works during migration

* Add log line when fails to get backup during migration
2024-04-08 07:11:06 +00:00
renovate[bot] 0d73d0c6c7 Update typescript-eslint monorepo to v7.4.0 (#4146)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 09:49:35 +00:00
renovate[bot] d2f76d4956 Update dependency typescript to v5.4.3 (#4145)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 09:23:09 +00:00
renovate[bot] c680dd7eb2 Update dependency @types/node to v18.19.28 (#4144)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 08:37:32 +00:00
renovate[bot] e24bb0f50c Update babel monorepo to v7.24.3 (#4143)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-03 08:36:51 +00:00
renovate[bot] 1ed3b13f0d Update all non-major dependencies (#4142)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-02 17:58:13 +00:00
renovate[bot] 4f628bf64c Update mheap/github-action-required-labels digest to 132879b (#4141)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-04-02 17:08:08 +00:00
134 changed files with 7564 additions and 2660 deletions
-22
View File
@@ -1,22 +0,0 @@
{
"sourceMaps": true,
"presets": [
[
"@babel/preset-env",
{
"targets": {
"node": 10
},
"modules": "commonjs"
}
],
"@babel/preset-typescript"
],
"plugins": [
"@babel/plugin-proposal-numeric-separator",
"@babel/plugin-proposal-class-properties",
"@babel/plugin-proposal-object-rest-spread",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime"
]
}
+16
View File
@@ -49,6 +49,22 @@ module.exports = {
},
],
"no-restricted-properties": [
"error",
{
object: "window",
property: "setImmediate",
message: "Use setTimeout instead.",
},
],
"no-restricted-globals": [
"error",
{
name: "setImmediate",
message: "Use setTimeout instead.",
},
],
"import/no-restricted-paths": [
"error",
{
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@5bd52f05dd8076794da5975d4c0a4f3bce7dd8f5 # v1
uses: shogo82148/actions-upload-release-asset@8f032eff0255912cc9c8455797fd6d72f25c7ab7 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
@@ -1,22 +0,0 @@
name: Build downstream artifacts
on:
merge_group:
types: [checks_requested]
pull_request: {}
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
# to develop or master.
#
#push:
# branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.95.0
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
@@ -3,10 +3,16 @@
name: matrix-react-sdk End to End Tests
on:
workflow_run:
workflows: ["Build downstream artifacts"]
types:
- completed
merge_group:
types: [checks_requested]
pull_request: {}
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
# to develop or master.
#
#push:
# branches: [develop, master]
concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch || github.run_id }}
@@ -15,44 +21,14 @@ concurrency:
jobs:
playwright:
name: Playwright
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
if: github.event.workflow_run.event == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
deployments: write
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
# We want to make the Playwright tests a required check for the merge queue.
#
# Unfortunately, GitHub doesn't distinguish between "checks needed for branch
# protection" (ie, the things that must pass before the PR will even be added
# to the merge queue) and "checks needed in the merge queue". We just have to add
# the check to the branch protection list.
#
# Ergo, if we know we're not going to run the Playwright tests, we need to add a
# passing status check manually.
mark_skipped:
if: github.event.workflow_run.event != 'merge_group'
permissions:
statuses: write
runs-on: ubuntu-latest
steps:
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: "${{ secrets.GITHUB_TOKEN }}"
state: success
description: Playwright skipped
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
context: "${{ github.workflow }} / end-to-end-tests"
sha: "${{ github.event.workflow_run.head_sha }}"
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
skip: ${{ github.event_name != 'merge_group' }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
fail-fast: false
matrix:
include:
- repo: vector-im/element-web
- repo: element-hq/element-web
event: element-web-notify
- repo: matrix-org/matrix-react-sdk
event: upstream-sdk-notify
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-latest
steps:
- uses: mheap/github-action-required-labels@80a96a4863886addcbc9f681b5b295ba7f5424e1 # v5
- uses: mheap/github-action-required-labels@5847eef68201219cf0a4643ea7be61e77837bbce # v5
if: github.event_name != 'merge_group'
with:
labels: |
@@ -1,4 +1,5 @@
name: Release Drafter
# Workflow used by other workflows to generate draft releases.
name: Release Drafter Reusable
on:
workflow_call:
inputs:
+4
View File
@@ -1,3 +1,7 @@
# Generates the draft release for the js-sdk
# Normally triggered whenever anything is merged to the staging branch, but
# also has a workflow dispatch trigger in case it needs running manually due
# to failures / workflow updates etc.
name: Release Drafter
on:
push:
+1 -1
View File
@@ -59,7 +59,7 @@ jobs:
- name: Get draft release
id: draft-release
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
env:
GITHUB_TOKEN: ${{ github.token }}
with:
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
- name: 🚀 Publish to npm
id: npm-publish
uses: JS-DevTools/npm-publish@4b07b26a2f6e0a51846e1870223e545bae91c552 # v3.0.1
uses: JS-DevTools/npm-publish@19c28f1ef146469e409470805ea4279d47c3d35c # v3.1.1
with:
token: ${{ secrets.NPM_TOKEN }}
access: public
+6 -14
View File
@@ -12,10 +12,6 @@ on:
type: boolean
required: false
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
extra_args:
type: string
required: false
description: "Extra args to pass to SonarCloud"
jobs:
sonarqube:
runs-on: ubuntu-latest
@@ -34,7 +30,7 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
uses: actions/checkout@a5ac7e51b41094c92402da3b24376905380afc29 # v4
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
@@ -60,14 +56,14 @@ jobs:
- id: extra_args
run: |
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
coverage=$(find coverage -type f -name '*lcov.info' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
echo "sonar.javascript.lcov.reportPaths=$coverage" >> sonar-project.properties
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' -printf '%h/%f,' | tr -d '\r\n' | sed 's/,$//g')
echo "sonar.testExecutionReportPaths=$reports" >> sonar-project.properties
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.7
uses: matrix-org/sonarcloud-workflow-action@v3.2
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -78,10 +74,6 @@ jobs:
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
extra_args: |
${{ inputs.extra_args }}
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
-Dsonar.testExecutionReportPaths=${{ steps.extra_args.outputs.testExecutionReportPaths }}
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
if: always()
+16
View File
@@ -90,3 +90,19 @@ jobs:
path: _docs
# We'll only use this in a workflow_run, then we're done with it
retention-days: 1
analyse_dead_code:
name: "Analyse Dead Code"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"
+18
View File
@@ -71,6 +71,24 @@ jobs:
disable_coverage: true
matrix-js-sdk-sha: ${{ github.sha }}
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
with:
use_js_sdk: "."
# we need this so the job is reported properly when run in a merge queue
downstream-complement-crypto:
name: Downstream Complement Crypto tests
runs-on: ubuntu-latest
if: always()
needs:
- complement-crypto
steps:
- if: needs.complement-crypto.result != 'skipped' && needs.complement-crypto.result != 'success'
run: exit 1
# Hook for branch protection to skip downstream testing outside of merge queues
# and skip sonarcloud coverage within merge queues
downstream:
+1 -1
View File
@@ -6,6 +6,6 @@ on:
jobs:
call-triage-labelled:
uses: vector-im/element-web/.github/workflows/triage-labelled.yml@develop
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@develop
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+85
View File
@@ -1,3 +1,88 @@
Changes in [33.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.1.0) (2024-06-18)
==================================================================================================
## ✨ Features
* MSC4108 support OIDC QR code login ([#4134](https://github.com/matrix-org/matrix-js-sdk/pull/4134)). Contributed by @t3chguy.
* Add crypto methods for export and import of secrets bundle ([#4227](https://github.com/matrix-org/matrix-js-sdk/pull/4227)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Fix screen sharing in recent Chrome ([#4243](https://github.com/matrix-org/matrix-js-sdk/pull/4243)). Contributed by @RiotRobot.
* Fix incorrect assumptions about required fields in /search response ([#4228](https://github.com/matrix-org/matrix-js-sdk/pull/4228)). Contributed by @t3chguy.
* Fix the queueToDevice tests for the new fakeindexeddb ([#4225](https://github.com/matrix-org/matrix-js-sdk/pull/4225)). Contributed by @dbkr.
Changes in [33.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v33.0.0) (2024-06-04)
==================================================================================================
## 🚨 BREAKING CHANGES
* Remove more deprecated methods, fields, and exports ([#4217](https://github.com/matrix-org/matrix-js-sdk/pull/4217)). Contributed by @t3chguy.
* Remove deprecated methods and fields ([#4201](https://github.com/matrix-org/matrix-js-sdk/pull/4201)). Contributed by @t3chguy.
## 🦖 Deprecations
* Remove more deprecated methods, fields, and exports ([#4217](https://github.com/matrix-org/matrix-js-sdk/pull/4217)). Contributed by @t3chguy.
* Remove deprecated methods and fields ([#4201](https://github.com/matrix-org/matrix-js-sdk/pull/4201)). Contributed by @t3chguy.
## ✨ Features
* `initRustCrypto`: allow app to pass in the store key directly ([#4210](https://github.com/matrix-org/matrix-js-sdk/pull/4210)). Contributed by @richvdh.
* Preserve ESM for async imports to work correctly ([#4187](https://github.com/matrix-org/matrix-js-sdk/pull/4187)). Contributed by @ms-dosx86.
## 🐛 Bug Fixes
* Don't run migration for Rust crypto if the legacy store is empty ([#4218](https://github.com/matrix-org/matrix-js-sdk/pull/4218)). Contributed by @andybalaam.
* Bump matrix-sdk-crypto-wasm to 5.0.0 ([#4216](https://github.com/matrix-org/matrix-js-sdk/pull/4216)). Contributed by @richvdh.
* Wire up verification cancel \& mismatch for rust crypto ([#4202](https://github.com/matrix-org/matrix-js-sdk/pull/4202)). Contributed by @t3chguy.
* Only pass id\_server if we had one to begin with ([#4200](https://github.com/matrix-org/matrix-js-sdk/pull/4200)). Contributed by @t3chguy.
Changes in [32.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.4.0) (2024-05-22)
==================================================================================================
* No changes
Changes in [32.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.3.0) (2024-05-21)
==================================================================================================
## ✨ Features
* Simplify OIDC types \& export `decodeIdToken` ([#4193](https://github.com/matrix-org/matrix-js-sdk/pull/4193)). Contributed by @t3chguy.
* Add helpers for authenticated media, and associated documentation ([#4185](https://github.com/matrix-org/matrix-js-sdk/pull/4185)). Contributed by @turt2live.
## 🐛 Bug Fixes
* Fix state\_events.ts types ([#4196](https://github.com/matrix-org/matrix-js-sdk/pull/4196)). Contributed by @t3chguy.
* Fix sendEventHttpRequest for `m.room.redaction` events without `redacts` ([#4192](https://github.com/matrix-org/matrix-js-sdk/pull/4192)). Contributed by @t3chguy.
Changes in [32.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.2.0) (2024-05-07)
==================================================================================================
## ✨ Features
* Use a different error code for UTDs when user was not in the room ([#4172](https://github.com/matrix-org/matrix-js-sdk/pull/4172)). Contributed by @uhoreg.
* Modernize window.crypto access constants ([#4169](https://github.com/matrix-org/matrix-js-sdk/pull/4169)). Contributed by @turt2live.
* Improve compliance with MSC3266 ([#4155](https://github.com/matrix-org/matrix-js-sdk/pull/4155)). Contributed by @AndrewFerr.
* Add comment to make clear that RoomStateEvent.Events does not update related objects in the js-sdk ([#4152](https://github.com/matrix-org/matrix-js-sdk/pull/4152)). Contributed by @toger5.
* Crypto: use a new error code for UTDs from device-relative historical events ([#4139](https://github.com/matrix-org/matrix-js-sdk/pull/4139)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Element-R: Fix rust migration when ssss secret are stored not encryted in cache (old legacy behavior) ([#4168](https://github.com/matrix-org/matrix-js-sdk/pull/4168)). Contributed by @BillCarsonFr.
Changes in [32.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.1.0) (2024-04-23)
==================================================================================================
## ✨ Features
* Add support for device dehydration v2 (Element R) ([#4062](https://github.com/matrix-org/matrix-js-sdk/pull/4062)). Contributed by @uhoreg.
* OIDC improvements in prep of OIDC-QR reciprocation ([#4149](https://github.com/matrix-org/matrix-js-sdk/pull/4149)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Validate backup private key before migrating it ([#4114](https://github.com/matrix-org/matrix-js-sdk/pull/4114)). Contributed by @BillCarsonFr.
* ElementR| Retry query backup until it works during migration to avoid spurious correption error popup ([#4113](https://github.com/matrix-org/matrix-js-sdk/pull/4113)). Contributed by @BillCarsonFr.
Changes in [32.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v32.0.0) (2024-04-09)
==================================================================================================
## 🚨 BREAKING CHANGES
+32
View File
@@ -21,6 +21,10 @@ endpoints from before Matrix 1.1, for example.
# Quickstart
> [!IMPORTANT]
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
@@ -89,6 +93,34 @@ Object.keys(client.store.rooms).forEach((roomId) => {
});
```
## Authenticated media
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) (Matrix 1.11) will require clients, like
yours, to include an `Authorization` header when `/download`ing or `/thumbnail`ing media. For NodeJS environments this
may be as easy as the following code snippet, though web browsers may need to use [Service Workers](https://developer.mozilla.org/en-US/docs/Web/API/Service_Worker_API)
to append the header when using the endpoints in `<img />` elements and similar.
```javascript
const downloadUrl = client.mxcUrlToHttp(
/*mxcUrl=*/ "mxc://example.org/abc123", // the MXC URI to download/thumbnail, typically from an event or profile
/*width=*/ undefined, // part of the thumbnail API. Use as required.
/*height=*/ undefined, // part of the thumbnail API. Use as required.
/*resizeMethod=*/ undefined, // part of the thumbnail API. Use as required.
/*allowDirectLinks=*/ false, // should generally be left `false`.
/*allowRedirects=*/ true, // implied supported with authentication
/*useAuthentication=*/ true, // the flag we're after in this example
);
const img = await fetch(downloadUrl, {
headers: {
Authorization: `Bearer ${client.getAccessToken()}`,
},
});
// Do something with `img`.
```
> [!WARNING]
> In future the js-sdk will _only_ return authentication-required URLs, mandating population of the `Authorization` header.
## What does this SDK do?
This SDK provides a full object model around the Matrix Client-Server API and emits
+26
View File
@@ -0,0 +1,26 @@
module.exports = {
sourceMaps: true,
presets: [
[
"@babel/preset-env",
{
targets: {
esmodules: true,
},
// We want to output ES modules for the final build (mostly to ensure that
// async imports work correctly). However, jest doesn't support ES modules very
// well yet (see https://github.com/jestjs/jest/issues/9430), so we use commonjs
// when testing.
modules: process.env.NODE_ENV === "test" ? "commonjs" : false,
},
],
"@babel/preset-typescript",
],
plugins: [
"@babel/plugin-transform-numeric-separator",
"@babel/plugin-transform-class-properties",
"@babel/plugin-transform-object-rest-spread",
"@babel/plugin-syntax-dynamic-import",
"@babel/plugin-transform-runtime",
],
};
+36
View File
@@ -0,0 +1,36 @@
import { KnipConfig } from "knip";
export default {
entry: [
"src/index.ts",
"src/types.ts",
"src/browser-index.ts",
"src/indexeddb-worker.ts",
"scripts/**",
"spec/**",
"release.sh",
// For now, we include all source files as entrypoints as we have been bad about gutwrenched imports
"src/**",
],
project: ["**/*.{js,ts}"],
ignore: ["examples/**"],
ignoreDependencies: [
// Required for `action-validator`
"@action-validator/*",
// Used for git pre-commit hooks
"husky",
// Used in script which only runs in environment with `@octokit/rest` installed
"@octokit/rest",
// Used by jest
"jest-environment-jsdom",
"babel-jest",
"ts-node",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
"dist",
],
ignoreExportsUsedInFile: true,
} satisfies KnipConfig;
+13 -14
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "32.0.0",
"version": "34.0.0-rc.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -19,6 +19,7 @@
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"lint:knip": "knip",
"test": "jest",
"test:watch": "jest --watch",
"coverage": "yarn test --coverage"
@@ -52,7 +53,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.9.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^6.0.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
@@ -64,7 +65,7 @@
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
"uuid": "9"
"uuid": "10"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -73,21 +74,19 @@
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
"@babel/eslint-plugin": "^7.12.10",
"@babel/plugin-proposal-class-properties": "^7.12.1",
"@babel/plugin-proposal-numeric-separator": "^7.12.7",
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-class-properties": "^7.12.1",
"@babel/plugin-transform-numeric-separator": "^7.12.7",
"@babel/plugin-transform-object-rest-spread": "^7.12.1",
"@babel/plugin-transform-runtime": "^7.12.10",
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/domexception": "^4.0.0",
"@types/jest": "^29.0.0",
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
@@ -96,17 +95,16 @@
"@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.57.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jest": "^28.0.0",
"eslint-plugin-jsdoc": "^48.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^51.0.0",
"eslint-plugin-tsdoc": "^0.3.0",
"eslint-plugin-unicorn": "^53.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
@@ -115,12 +113,13 @@
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"knip": "^5.0.0",
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"node-fetch": "^2.7.0",
"prettier": "3.2.5",
"prettier": "3.3.2",
"rimraf": "^5.0.0",
"ts-node": "^10.9.1",
"ts-node": "^10.9.2",
"typedoc": "^0.25.10",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc-plugin-mdn-links": "^3.0.3",
+61
View File
@@ -347,6 +347,67 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
expect(isCrossSigningReady).toBeTruthy();
});
it("should return false if identity is not trusted, even if the secrets are in 4S", async () => {
e2eKeyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
// Complete initial sync, to get the 4S account_data events stored
mockInitialApiRequests(aliceClient.getHomeserverUrl());
// For this test we need to have a well-formed 4S setup.
const mockSecretInfo = {
encrypted: {
// Don't care about the actual values here, just need to be present for validation
KeyId: {
iv: "IVIVIVIVIVIVIV",
ciphertext: "CIPHERTEXTB64",
mac: "MACMACMAC",
},
},
};
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: [
{
type: "m.secret_storage.key.KeyId",
content: {
algorithm: "m.secret_storage.v1.aes-hmac-sha2",
// iv and mac not relevant for this test
},
},
{
type: "m.secret_storage.default_key",
content: {
key: "KeyId",
},
},
{
type: "m.cross_signing.master",
content: mockSecretInfo,
},
{
type: "m.cross_signing.user_signing",
content: mockSecretInfo,
},
{
type: "m.cross_signing.self_signing",
content: mockSecretInfo,
},
],
},
});
await aliceClient.startClient();
await syncPromise(aliceClient);
// Sanity: ensure that the secrets are in 4S
const status = await aliceClient.getCrypto()!.getCrossSigningStatus();
expect(status.privateKeysInSecretStorage).toBeTruthy();
const isCrossSigningReady = await aliceClient.getCrypto()!.isCrossSigningReady();
expect(isCrossSigningReady).toBeFalsy();
});
});
describe("getCrossSigningKeyId", () => {
+234 -19
View File
@@ -101,6 +101,7 @@ import {
} from "./olm-utils";
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event";
import { KnownMembership } from "../../../src/@types/membership";
afterEach(() => {
@@ -108,6 +109,8 @@ afterEach(() => {
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
jest.useRealTimers();
});
/**
@@ -467,6 +470,10 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("Unable to decrypt error codes", function () {
beforeEach(() => {
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
it("Decryption fails with UISI error", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -474,11 +481,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
// Ensure that the timestamp post-dates the creation of our device
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now(),
};
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
@@ -498,12 +511,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await aliceClient.getCrypto()!.importRoomKeys([testData.RATCHTED_MEGOLM_SESSION_DATA]);
// Alice gets both the events in a single sync
// Ensure that the timestamp post-dates the creation of our device
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now(),
};
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } },
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
@@ -515,6 +533,219 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.OLM_UNKNOWN_MESSAGE_INDEX);
});
describe("Historical events", () => {
async function sendEventAndAwaitDecryption(props: Partial<IEvent> = {}): Promise<MatrixEvent> {
// A promise which resolves, with the MatrixEvent which wraps the event, once the decryption fails.
const awaitDecryption = emitPromise(aliceClient, MatrixEventEvent.Decrypted);
// Ensure that the timestamp pre-dates the creation of our device: set it to 24 hours ago
const encryptedEvent = {
...testData.ENCRYPTED_EVENT,
origin_server_ts: Date.now() - 24 * 3600 * 1000,
...props,
};
const syncResponse = {
next_batch: 1,
rooms: {
join: {
[testData.TEST_ROOM_ID]: { timeline: { events: [encryptedEvent] } },
},
},
};
syncResponder.sendOrQueueSyncResponse(syncResponse);
return await awaitDecryption;
}
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_NO_BACKUP when there is no backup", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
});
newBackendOnly("fails with HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED when the backup is broken", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(
DecryptionFailureCode.HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED,
);
});
newBackendOnly("fails with HISTORICAL_MESSAGE_WORKING_BACKUP when backup is working", async () => {
// The test backup data is signed by a dummy device. We'll need to tell Alice about the device, and
// later, tell her to trust it, so that she trusts the backup.
const e2eResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
e2eResponder.addDeviceKeys(testData.SIGNED_TEST_DEVICE_DATA);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
await startClientAndAwaitFirstSync();
await aliceClient
.getCrypto()!
.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
// Tell Alice to trust the dummy device that signed the backup
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([TEST_USER_ID]);
expect(devices.get(TEST_USER_ID)!.keys()).toContain(testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
// Tell Alice to check and enable backup
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
// Sanity: Alice should now have working backup.
expect(await aliceClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual(
testData.SIGNED_BACKUP_DATA.version,
);
// Finally! we can check what happens when we get an event.
const ev = await sendEventAndAwaitDecryption();
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP);
});
newBackendOnly("fails with NOT_JOINED if user is not member of room", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "leave",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED);
});
newBackendOnly(
"fails with NOT_JOINED if user is not member of room (MSC4115 unstable prefix)",
async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "leave",
},
});
expect(ev.decryptionFailureReason).toEqual(
DecryptionFailureCode.HISTORICAL_MESSAGE_USER_NOT_JOINED,
);
},
);
newBackendOnly(
"fails with another error when the server reports user was a member of the room",
async () => {
// This tests that when the server reports that the user
// was invited at the time the event was sent, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
// and instead get some other error, since the user should
// have gotten the key for the event.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "invite",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
newBackendOnly(
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
async () => {
// This tests that when the server reports that the user
// was invited at the time the event was sent, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error,
// and instead get some other error, since the user should
// have gotten the key for the event.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "invite",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
newBackendOnly(
"fails with another error when the server reports user was a member of the room",
async () => {
// This tests that when the server reports the user's
// membership, and reports that the user was joined, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
// instead get some other error.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.name]: "join",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
newBackendOnly(
"fails with another error when the server reports user was a member of the room (MSC4115 unstable prefix)",
async () => {
// This tests that when the server reports the user's
// membership, and reports that the user was joined, then we
// don't get a HISTORICAL_MESSAGE_USER_NOT_JOINED error, and
// instead get some other error.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
const ev = await sendEventAndAwaitDecryption({
unsigned: {
[UNSIGNED_MEMBERSHIP_FIELD.altName!]: "join",
},
});
expect(ev.decryptionFailureReason).toEqual(DecryptionFailureCode.HISTORICAL_MESSAGE_NO_KEY_BACKUP);
},
);
});
it("Decryption fails with Unable to decrypt for other errors", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -997,10 +1228,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
return encryptedMessage;
}
afterEach(() => {
jest.useRealTimers();
});
newBackendOnly("should rotate the session after 2 messages", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -2184,10 +2411,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
jest.useRealTimers();
});
function awaitKeyUploadRequest(): Promise<{ keysCount: number; fallbackKeysCount: number }> {
return new Promise((resolve) => {
const listener = (url: string, options: RequestInit) => {
@@ -2250,10 +2473,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("getUserDeviceInfo", () => {
afterEach(() => {
jest.useRealTimers();
});
// From https://spec.matrix.org/v1.6/client-server-api/#post_matrixclientv3keysquery
// Using extracted response from matrix.org, it needs to have real keys etc to pass old crypto verification
const queryResponseBody = {
@@ -2742,10 +2961,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
jest.useRealTimers();
});
it("Should be able to restore from 4S after bootstrap", async () => {
const backupVersion = "1";
await bootstrapSecurity(backupVersion);
@@ -0,0 +1,181 @@
/*
Copyright 2024 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 "fake-indexeddb/auto";
import fetchMock from "fetch-mock-jest";
import { createClient, ClientEvent, MatrixClient, MatrixEvent } from "../../../src";
import { RustCrypto } from "../../../src/rust-crypto/rust-crypto";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
describe("Device dehydration", () => {
it("should rehydrate and dehydrate a device", async () => {
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
cryptoCallbacks: {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
},
});
await initializeSecretStorage(matrixClient, "@alice:localhost", "http://test.server");
// count the number of times the dehydration key gets set
let setDehydrationCount = 0;
matrixClient.on(ClientEvent.AccountData, (event: MatrixEvent) => {
if (event.getType() === "org.matrix.msc3814") {
setDehydrationCount++;
}
});
const crypto = matrixClient.getCrypto()!;
fetchMock.config.overwriteRoutes = true;
// start dehydration -- we start with no dehydrated device, and we
// store the dehydrated device that we create
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
let dehydratedDeviceBody: any;
let dehydrationCount = 0;
let resolveDehydrationPromise: () => void;
fetchMock.put("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", (_, opts) => {
dehydratedDeviceBody = JSON.parse(opts.body as string);
dehydrationCount++;
if (resolveDehydrationPromise) {
resolveDehydrationPromise();
}
return {};
});
await crypto.startDehydration();
expect(dehydrationCount).toEqual(1);
// a week later, we should have created another dehydrated device
const dehydrationPromise = new Promise<void>((resolve, reject) => {
resolveDehydrationPromise = resolve;
});
jest.advanceTimersByTime(7 * 24 * 60 * 60 * 1000);
await dehydrationPromise;
expect(dehydrationCount).toEqual(2);
// restart dehydration -- rehydrate the device that we created above,
// and create a new dehydrated device. We also set `createNewKey`, so
// a new dehydration key will be set
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
device_id: dehydratedDeviceBody.device_id,
device_data: dehydratedDeviceBody.device_data,
});
const eventsResponse = jest.fn((url, opts) => {
// rehydrating should make two calls to the /events endpoint.
// The first time will return a single event, and the second
// time will return no events (which will signal to the
// rehydration function that it can stop)
const body = JSON.parse(opts.body as string);
const nextBatch = body.next_batch ?? "0";
const events = nextBatch === "0" ? [{ sender: "@alice:localhost", type: "m.dummy", content: {} }] : [];
return {
events,
next_batch: nextBatch + "1",
};
});
fetchMock.post(
`path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device/${encodeURIComponent(dehydratedDeviceBody.device_id)}/events`,
eventsResponse,
);
await crypto.startDehydration(true);
expect(dehydrationCount).toEqual(3);
expect(setDehydrationCount).toEqual(2);
expect(eventsResponse.mock.calls).toHaveLength(2);
matrixClient.stopClient();
});
});
/** create a new secret storage and cross-signing keys */
async function initializeSecretStorage(
matrixClient: MatrixClient,
userId: string,
homeserverUrl: string,
): Promise<void> {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
const accountData: Map<string, object> = new Map();
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
const name = url.split("/").pop()!;
const value = accountData.get(name);
if (value) {
return value;
} else {
return {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
};
}
});
fetchMock.put("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
const name = url.split("/").pop()!;
const value = JSON.parse(opts.body as string);
accountData.set(name, value);
matrixClient.emit(ClientEvent.AccountData, new MatrixEvent({ type: name, content: value }));
return {};
});
await matrixClient.initRustCrypto();
const crypto = matrixClient.getCrypto()! as RustCrypto;
// we need to process a sync so that the OlmMachine will upload keys
await crypto.preprocessToDeviceMessages([]);
await crypto.onSyncCompleted({});
// create initial secret storage
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
await matrixClient.bootstrapCrossSigning({ setupNewCrossSigning: true });
await matrixClient.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: false,
});
}
+15 -5
View File
@@ -21,7 +21,7 @@ import { Mocked } from "jest-mock";
import {
createClient,
CryptoApi,
Crypto,
CryptoEvent,
ICreateClientOpts,
IEvent,
@@ -45,6 +45,7 @@ import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keyback
import { IKeyBackup } from "../../../src/crypto/backup";
import { flushPromises } from "../../test-utils/flushPromises";
import { defer, IDeferred } from "../../../src/utils";
import { DecryptionFailureCode } from "../../../src/crypto-api";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -242,8 +243,17 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
// On the first decryption attempt, decryption fails.
await awaitDecryption(event);
expect(event.decryptionFailureReason).toEqual(
backend === "libolm"
? DecryptionFailureCode.MEGOLM_UNKNOWN_INBOUND_SESSION_ID
: DecryptionFailureCode.HISTORICAL_MESSAGE_WORKING_BACKUP,
);
// Eventually, decryption succeeds.
await awaitDecryption(event, { waitOnDecryptionFailure: true });
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
});
@@ -300,7 +310,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
describe("recover from backup", () => {
let aliceCrypto: CryptoApi;
let aliceCrypto: Crypto.CryptoApi;
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
@@ -786,7 +796,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
const result = await aliceCrypto.checkKeyBackupAndEnable();
expect(result).toBeTruthy();
jest.runAllTimers();
jest.advanceTimersByTime(10 * 60 * 1000);
await failurePromise;
// Fix the endpoint to do successful uploads
@@ -819,7 +829,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
// run the timers, which will make the backup loop redo the request
await jest.runAllTimersAsync();
await jest.advanceTimersByTimeAsync(10 * 60 * 1000);
await successPromise;
await allKeysUploadedPromise;
});
+175
View File
@@ -23,6 +23,7 @@ import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump"
import { MSK_NOT_CACHED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump";
import { IDENTITY_NOT_TRUSTED_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/unverified";
import { FULL_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/full_account";
import { EMPTY_ACCOUNT_DATASET } from "../../test-utils/test_indexeddb_cryptostore_dump/empty_account";
jest.setTimeout(15000);
@@ -85,6 +86,44 @@ describe("MatrixClient.initRustCrypto", () => {
);
});
it("should create the meta db if given a storageKey", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ storageKey: new Uint8Array(32) });
// should have two indexed dbs now
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
expect(databaseNames).toEqual(
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
);
});
it("should create the meta db if given a storagePassword", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ storagePassword: "the cow is on the moon" });
// should have two indexed dbs now
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
expect(databaseNames).toEqual(
expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto", "matrix-js-sdk::matrix-sdk-crypto-meta"]),
);
});
it("should ignore a second call", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
@@ -162,6 +201,142 @@ describe("MatrixClient.initRustCrypto", () => {
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
}, 60000);
describe("Private key backup migration", () => {
it("should not migrate the backup private key if backup has changed", async () => {
// Here we have a new backup server side, and the migrated account has the previous backup key.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.newBackupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should not migrate the backup private key if backup has unknown algorithm", async () => {
// Here we have a new backup server side, and the migrated account has the previous backup key.
const backupResponse = {
...MSK_NOT_CACHED_DATASET.backupResponse,
algorithm: "m.megolm_backup.v8",
};
fetchMock.get("path:/_matrix/client/v3/room_keys/version", backupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should not migrate the backup private key if the backup has been deleted", async () => {
// The old backup has been deleted server side.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "No backup found",
},
});
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeNull();
});
it("should migrate the backup private key if the backup matches", async () => {
// The old backup has been deleted server side.
fetchMock.get("path:/_matrix/client/v3/room_keys/version", MSK_NOT_CACHED_DATASET.backupResponse);
fetchMock.post("path:/_matrix/client/v3/keys/query", MSK_NOT_CACHED_DATASET.keyQueryResponse);
await populateStore("test-store", MSK_NOT_CACHED_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, "test-store");
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: MSK_NOT_CACHED_DATASET.userId,
deviceId: MSK_NOT_CACHED_DATASET.deviceId,
cryptoStore,
pickleKey: MSK_NOT_CACHED_DATASET.pickleKey,
});
await matrixClient.initRustCrypto();
const privateBackupKey = await matrixClient.getCrypto()?.getSessionBackupPrivateKey();
expect(privateBackupKey).toBeDefined();
});
});
it("should not migrate if account data is missing", async () => {
// See https://github.com/element-hq/element-web/issues/27447
// Given we have an almost-empty legacy account in the database
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No backup found" },
});
fetchMock.post("path:/_matrix/client/v3/keys/query", EMPTY_ACCOUNT_DATASET.keyQueryResponse);
const testStoreName = "test-store";
await populateStore(testStoreName, EMPTY_ACCOUNT_DATASET.dumpPath);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, testStoreName);
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: EMPTY_ACCOUNT_DATASET.userId,
deviceId: EMPTY_ACCOUNT_DATASET.deviceId,
cryptoStore,
pickleKey: EMPTY_ACCOUNT_DATASET.pickleKey,
});
// When we start Rust crypto, potentially triggering an upgrade
const progressListener = jest.fn();
matrixClient.addListener(CryptoEvent.LegacyCryptoStoreMigrationProgress, progressListener);
await matrixClient.initRustCrypto();
// Then no error occurs, and no upgrade happens
expect(progressListener.mock.calls.length).toBe(0);
}, 60000);
describe("Legacy trust migration", () => {
async function populateAndStartLegacyCryptoStore(dumpPath: string): Promise<IndexedDBCryptoStore> {
const testStoreName = "test-store";
+206 -34
View File
@@ -19,7 +19,16 @@ import { Mocked } from "jest-mock";
import * as utils from "../test-utils/test-utils";
import { CRYPTO_ENABLED, IStoredClientOpts, MatrixClient } from "../../src/client";
import { MatrixEvent } from "../../src/models/event";
import { Filter, KnockRoomOpts, MemoryStore, Method, Room, SERVICE_TYPES } from "../../src/matrix";
import {
Filter,
JoinRule,
KnockRoomOpts,
MemoryStore,
Method,
Room,
RoomSummary,
SERVICE_TYPES,
} from "../../src/matrix";
import { TestClient } from "../TestClient";
import { THREAD_RELATION_TYPE } from "../../src/models/thread";
import { IFilterDefinition } from "../../src/filter";
@@ -248,7 +257,7 @@ describe("MatrixClient", function () {
.when("POST", "/knock/" + encodeURIComponent(roomId))
.check((request) => {
expect(request.data).toEqual({ reason: opts.reason });
expect(request.queryParams).toEqual({ server_name: opts.viaServers });
expect(request.queryParams).toEqual({ server_name: opts.viaServers, via: opts.viaServers });
})
.respond(200, { room_id: roomId });
@@ -1284,18 +1293,109 @@ describe("MatrixClient", function () {
});
describe("getCapabilities", () => {
it("should cache by default", async () => {
it("should return cached capabilities if present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: {
"m.change_password": false,
},
capabilities: capsObject,
});
const prom = httpBackend.flushAllExpected();
const capabilities1 = await client.getCapabilities();
const capabilities2 = await client.getCapabilities();
client.startClient();
await httpBackend!.flushAllExpected();
expect(await client.getCapabilities()).toEqual(capsObject);
});
it("should fetch capabilities if cache not present", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
const capsPromise = client.getCapabilities();
await httpBackend!.flushAllExpected();
expect(await capsPromise).toEqual(capsObject);
});
});
describe("getCachedCapabilities", () => {
it("should return cached capabilities or undefined", async () => {
const capsObject = {
"m.change_password": false,
};
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
expect(client.getCachedCapabilities()).toBeUndefined();
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
});
});
describe("fetchCapabilities", () => {
const capsObject = {
"m.change_password": false,
};
beforeEach(() => {
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: capsObject,
});
});
afterEach(() => {
jest.useRealTimers();
});
it("should always fetch capabilities and then cache", async () => {
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
const caps = await prom;
expect(caps).toEqual(capsObject);
});
it("should write-through the cache", async () => {
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
client.startClient();
await httpBackend!.flushAllExpected();
expect(client.getCachedCapabilities()).toEqual(capsObject);
const newCapsObject = {
"m.change_password": true,
};
httpBackend.when("GET", "/capabilities").respond(200, {
capabilities: newCapsObject,
});
const prom = client.fetchCapabilities();
await httpBackend.flushAllExpected();
await prom;
expect(capabilities1).toStrictEqual(capabilities2);
expect(client.getCachedCapabilities()).toEqual(newCapsObject);
});
});
@@ -1710,6 +1810,102 @@ describe("MatrixClient", function () {
await Promise.all([client.unbindThreePid("email", "alice@server.com"), httpBackend.flushAllExpected()]);
});
});
describe("getRoomSummary", () => {
const roomId = "!foo:bar";
const encodedRoomId = encodeURIComponent(roomId);
const roomSummary: RoomSummary = {
"room_id": roomId,
"name": "My Room",
"avatar_url": "",
"topic": "My room topic",
"world_readable": false,
"guest_can_join": false,
"num_joined_members": 1,
"room_type": "",
"join_rule": JoinRule.Public,
"membership": "leave",
"im.nheko.summary.room_version": "6",
"im.nheko.summary.encryption": "algo",
};
const prefix = "/_matrix/client/unstable/im.nheko.summary/";
const suffix = `summary/${encodedRoomId}`;
const deprecatedSuffix = `rooms/${encodedRoomId}/summary`;
const errorUnrecogStatus = 404;
const errorUnrecogBody = {
errcode: "M_UNRECOGNIZED",
error: "Unsupported endpoint",
};
const errorBadreqStatus = 400;
const errorBadreqBody = {
errcode: "M_UNKNOWN",
error: "Invalid request",
};
it("should respond with a valid room summary object", () => {
httpBackend.when("GET", prefix + suffix).respond(200, roomSummary);
const prom = client.getRoomSummary(roomId).then((response) => {
expect(response).toEqual(roomSummary);
});
httpBackend.flush("");
return prom;
});
it("should allow fallback to the deprecated endpoint", () => {
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
httpBackend.when("GET", prefix + deprecatedSuffix).respond(200, roomSummary);
const prom = client.getRoomSummary(roomId).then((response) => {
expect(response).toEqual(roomSummary);
});
httpBackend.flush("");
return prom;
});
it("should respond to unsupported path with error", () => {
httpBackend.when("GET", prefix + suffix).respond(errorUnrecogStatus, errorUnrecogBody);
httpBackend.when("GET", prefix + deprecatedSuffix).respond(errorUnrecogStatus, errorUnrecogBody);
const prom = client.getRoomSummary(roomId).then(
function (response) {
throw Error("request not failed");
},
function (error) {
expect(error.httpStatus).toEqual(errorUnrecogStatus);
expect(error.errcode).toEqual(errorUnrecogBody.errcode);
expect(error.message).toEqual(`MatrixError: [${errorUnrecogStatus}] ${errorUnrecogBody.error}`);
},
);
httpBackend.flush("");
return prom;
});
it("should respond to invalid path arguments with error", () => {
httpBackend.when("GET", prefix).respond(errorBadreqStatus, errorBadreqBody);
const prom = client.getRoomSummary("notAroom").then(
function (response) {
throw Error("request not failed");
},
function (error) {
expect(error.httpStatus).toEqual(errorBadreqStatus);
expect(error.errcode).toEqual(errorBadreqBody.errcode);
expect(error.message).toEqual(`MatrixError: [${errorBadreqStatus}] ${errorBadreqBody.error}`);
},
);
httpBackend.flush("");
return prom;
});
});
});
function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
@@ -1720,7 +1916,6 @@ function withThreadId(event: MatrixEvent, newThreadId: string): MatrixEvent {
const buildEventMessageInThread = (root: MatrixEvent) =>
new MatrixEvent({
age: 80098509,
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
@@ -1741,12 +1936,10 @@ const buildEventMessageInThread = (root: MatrixEvent) =>
sender: "@andybalaam-test1:matrix.org",
type: "m.room.encrypted",
unsigned: { age: 80098509 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventPollResponseReference = () =>
new MatrixEvent({
age: 80098509,
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
@@ -1764,7 +1957,6 @@ const buildEventPollResponseReference = () =>
sender: "@andybalaam-test1:matrix.org",
type: "m.room.encrypted",
unsigned: { age: 80106237 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventReaction = (event: MatrixEvent) =>
@@ -1804,7 +1996,6 @@ const buildEventRedaction = (event: MatrixEvent) =>
const buildEventPollStartThreadRoot = () =>
new MatrixEvent({
age: 80108647,
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "ENCRYPTEDSTUFF",
@@ -1818,12 +2009,10 @@ const buildEventPollStartThreadRoot = () =>
sender: "@andybalaam-test1:matrix.org",
type: "m.room.encrypted",
unsigned: { age: 80108647 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventReply = (target: MatrixEvent) =>
new MatrixEvent({
age: 80098509,
content: {
"algorithm": "m.megolm.v1.aes-sha2",
"ciphertext": "ENCRYPTEDSTUFF",
@@ -1842,12 +2031,10 @@ const buildEventReply = (target: MatrixEvent) =>
sender: "@andybalaam-test1:matrix.org",
type: "m.room.encrypted",
unsigned: { age: 80098509 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventRoomName = () =>
new MatrixEvent({
age: 80123249,
content: {
name: "1 poll, 1 vote, 1 thread",
},
@@ -1858,12 +2045,10 @@ const buildEventRoomName = () =>
state_key: "",
type: "m.room.name",
unsigned: { age: 80123249 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventEncryption = () =>
new MatrixEvent({
age: 80123383,
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
@@ -1874,12 +2059,10 @@ const buildEventEncryption = () =>
state_key: "",
type: "m.room.encryption",
unsigned: { age: 80123383 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventGuestAccess = () =>
new MatrixEvent({
age: 80123473,
content: {
guest_access: "can_join",
},
@@ -1890,12 +2073,10 @@ const buildEventGuestAccess = () =>
state_key: "",
type: "m.room.guest_access",
unsigned: { age: 80123473 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventHistoryVisibility = () =>
new MatrixEvent({
age: 80123556,
content: {
history_visibility: "shared",
},
@@ -1906,12 +2087,10 @@ const buildEventHistoryVisibility = () =>
state_key: "",
type: "m.room.history_visibility",
unsigned: { age: 80123556 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventJoinRules = () =>
new MatrixEvent({
age: 80123696,
content: {
join_rule: KnownMembership.Invite,
},
@@ -1922,12 +2101,10 @@ const buildEventJoinRules = () =>
state_key: "",
type: "m.room.join_rules",
unsigned: { age: 80123696 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventPowerLevels = () =>
new MatrixEvent({
age: 80124105,
content: {
ban: 50,
events: {
@@ -1958,12 +2135,10 @@ const buildEventPowerLevels = () =>
state_key: "",
type: "m.room.power_levels",
unsigned: { age: 80124105 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventMember = () =>
new MatrixEvent({
age: 80125279,
content: {
avatar_url: "mxc://matrix.org/aNtbVcFfwotudypZcHsIcPOc",
displayname: "andybalaam-test1",
@@ -1976,12 +2151,10 @@ const buildEventMember = () =>
state_key: "@andybalaam-test1:matrix.org",
type: "m.room.member",
unsigned: { age: 80125279 },
user_id: "@andybalaam-test1:matrix.org",
});
const buildEventCreate = () =>
new MatrixEvent({
age: 80126105,
content: {
room_version: "6",
},
@@ -1992,7 +2165,6 @@ const buildEventCreate = () =>
state_key: "",
type: "m.room.create",
unsigned: { age: 80126105 },
user_id: "@andybalaam-test1:matrix.org",
});
function assertObjectContains(obj: Record<string, any>, expected: any): void {
@@ -333,7 +333,7 @@ describe("MatrixClient room timelines", function () {
name: userName,
url: "mxc://some/url",
});
oldMshipEvent.prev_content = {
oldMshipEvent.unsigned!.prev_content = {
displayname: "Old Alice",
avatar_url: undefined,
membership: KnownMembership.Join,
@@ -105,13 +105,13 @@ describe("MatrixClient syncing errors", () => {
await client!.startClient();
expect(await syncEvents[0].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[1].promise).toBe(SyncState.Error);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[2].promise).toBe(SyncState.Prepared);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[3].promise).toBe(SyncState.Syncing);
jest.runAllTimers(); // this will skip forward to trigger the keepAlive/sync
jest.advanceTimersByTime(60 * 1000); // this will skip forward to trigger the keepAlive/sync
expect(await syncEvents[4].promise).toBe(SyncState.Syncing);
});
@@ -119,6 +119,7 @@ describe("MatrixClient syncing errors", () => {
jest.useFakeTimers();
fetchMock.config.overwriteRoutes = false;
fetchMock
.get("end:capabilities", {})
.getOnce("end:versions", {}) // first version check without credentials needs to succeed
.get("end:versions", unknownTokenErrorData) // further version checks fails with 401
.get("end:pushrules/", 401) // fails with 401 without an error. This does happen in practice e.g. with Synapse
@@ -0,0 +1,356 @@
/*
Copyright 2024 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 { QrCodeData, QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import {
MSC4108FailureReason,
MSC4108RendezvousSession,
MSC4108SecureChannel,
MSC4108SignInWithQR,
PayloadType,
RendezvousError,
} from "../../../src/rendezvous";
import { defer } from "../../../src/utils";
import {
ClientPrefix,
DEVICE_CODE_SCOPE,
IHttpOpts,
IMyDevice,
MatrixClient,
MatrixError,
MatrixHttpApi,
} from "../../../src";
import { mockOpenIdConfiguration } from "../../test-utils/oidc";
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const baseUrl = "https://example.com";
const crypto = {
exportSecretsForQrLogin: jest.fn(),
};
const client = {
doesServerSupportUnstableFeature(feature: string) {
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl,
getDomain: () => "example.com",
getDevice: jest.fn(),
getCrypto: jest.fn(() => crypto),
getAuthIssuer: jest.fn().mockResolvedValue({ issuer: "https://issuer/" }),
} as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl,
prefix: ClientPrefix.Unstable,
onlyData: true,
});
return client;
}
describe("MSC4108SignInWithQR", () => {
beforeEach(() => {
fetchMock.get(
"https://issuer/.well-known/openid-configuration",
mockOpenIdConfiguration("https://issuer/", [DEVICE_CODE_SCOPE]),
);
fetchMock.get("https://issuer/jwks", {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
});
afterEach(() => {
fetchMock.reset();
});
const url = "https://fallbackserver/rz/123";
const deviceId = "DEADB33F";
const verificationUri = "https://example.com/verify";
const verificationUriComplete = "https://example.com/verify/complete";
it("should generate qr code data as expected", async () => {
const session = new MSC4108RendezvousSession({
url,
});
const channel = new MSC4108SecureChannel(session);
const login = new MSC4108SignInWithQR(channel, false);
await login.generateCode();
const code = login.code;
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
expect(text.endsWith(url)).toBeTruthy();
// Assert that the code is stable
await login.generateCode();
expect(login.code).toEqual(code);
});
describe("should be able to connect as a reciprocating device", () => {
let client: MatrixClient;
let ourLogin: MSC4108SignInWithQR;
let opponentLogin: MSC4108SignInWithQR;
beforeEach(async () => {
let ourData = defer<string>();
let opponentData = defer<string>();
const ourMockSession = {
send: jest.fn(async (newData) => {
ourData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = opponentData.promise;
prom.then(() => {
opponentData = defer();
});
return prom;
}),
url,
cancelled: false,
cancel: () => {
// @ts-ignore
ourMockSession.cancelled = true;
ourData.resolve("");
},
} as unknown as MSC4108RendezvousSession;
const opponentMockSession = {
send: jest.fn(async (newData) => {
opponentData.resolve(newData);
}),
receive: jest.fn(() => {
const prom = ourData.promise;
prom.then(() => {
ourData = defer();
});
return prom;
}),
url,
} as unknown as MSC4108RendezvousSession;
client = makeMockClient({ userId: "@alice:example.com", deviceId: "alice", msc4108Enabled: true });
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.fromBytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
ourLogin = new MSC4108SignInWithQR(ourChannel, true, client);
opponentLogin = new MSC4108SignInWithQR(opponentChannel, false);
});
it("should be able to connect with opponent and share server name & check code", async () => {
await Promise.all([
expect(ourLogin.negotiateProtocols()).resolves.toEqual({}),
expect(opponentLogin.negotiateProtocols()).resolves.toEqual({ serverName: client.getDomain() }),
]);
expect(ourLogin.checkCode).toBe(opponentLogin.checkCode);
});
it("should be able to connect with opponent and share verificationUri", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).resolves.toEqual({
verificationUri: verificationUriComplete,
}),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
verification_uri_complete: verificationUriComplete,
},
device_id: deviceId,
}),
]);
});
it("should abort if device already exists", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow("Specified device ID already exists"),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});
it("should abort on unsupported protocol", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
await Promise.all([
expect(ourLogin.deviceAuthorizationGrant()).rejects.toThrow(
"Received a request for an unsupported protocol",
),
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
opponentLogin.send({
type: PayloadType.Protocol,
protocol: "device_authorization_grant_v2",
device_authorization_grant: {
verification_uri: verificationUri,
},
device_id: deviceId,
}),
]);
});
it("should be able to connect with opponent and share secrets", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
const ourProm = ourLogin.shareSecrets();
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();
mocked(client.getDevice).mockResolvedValue({} as IMyDevice);
const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
const payload = {
secrets: expect.objectContaining(secrets),
};
await Promise.all([
expect(ourProm).resolves.toEqual(payload),
expect(opponentLogin.shareSecrets()).resolves.toEqual(payload),
]);
});
it("should abort if device doesn't come up by timeout", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});
jest.spyOn(Date, "now").mockImplementation(() => {
return 12345678 + mocked(setTimeout).mock.calls.length * 1000;
});
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(new MatrixError({ errcode: "M_NOT_FOUND" }, 404));
const ourProm = ourLogin.shareSecrets();
await expect(ourProm).rejects.toThrow("New device not found");
});
it("should abort on unexpected errors", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
// @ts-ignore
await opponentLogin.send({
type: PayloadType.Success,
});
mocked(client.getDevice).mockRejectedValue(
new MatrixError({ errcode: "M_UNKNOWN", error: "The message" }, 500),
);
await expect(ourLogin.shareSecrets()).rejects.toThrow("The message");
});
it("should abort on declined login", async () => {
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
await ourLogin.declineLoginOnExistingDevice();
await expect(opponentLogin.shareSecrets()).rejects.toThrow(
new RendezvousError("Failed", MSC4108FailureReason.UserCancelled),
);
});
it("should not send secrets if user cancels", async () => {
jest.spyOn(global, "setTimeout").mockImplementation((fn) => {
(<Function>fn)();
return -1;
});
await Promise.all([ourLogin.negotiateProtocols(), opponentLogin.negotiateProtocols()]);
// We don't have the new device side of this flow implemented at this time so mock it
// @ts-ignore
ourLogin.expectingNewDeviceId = "DEADB33F";
const ourProm = ourLogin.shareSecrets();
const opponentProm = opponentLogin.shareSecrets();
// Consume the ProtocolAccepted message which would normally be handled by step 4 which we do not have here
// @ts-ignore
await opponentLogin.receive();
const deferred = defer<IMyDevice>();
mocked(client.getDevice).mockReturnValue(deferred.promise);
ourLogin.cancel(MSC4108FailureReason.UserCancelled).catch(() => {});
deferred.resolve({} as IMyDevice);
const secrets = {
cross_signing: { master_key: "mk", user_signing_key: "usk", self_signing_key: "ssk" },
};
client.getCrypto()!.exportSecretsBundle = jest.fn().mockResolvedValue(secrets);
await Promise.all([
expect(ourProm).rejects.toThrow("User cancelled"),
expect(opponentProm).rejects.toThrow("Unexpected message received"),
]);
});
});
});
-4
View File
@@ -14,10 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import DOMException from "domexception";
global.DOMException = DOMException as typeof global.DOMException;
jest.mock("../src/http-api/utils", () => ({
...jest.requireActual("../src/http-api/utils"),
// We mock timeoutSignal otherwise it causes tests to leave timers running
+1 -1
View File
@@ -88,6 +88,6 @@ export const mockClientMethodsEvents = () => ({
export const mockClientMethodsServer = (): Partial<Record<MethodLikeKeys<MatrixClient>, unknown>> => ({
getIdentityServerUrl: jest.fn(),
getHomeserverUrl: jest.fn(),
getCapabilities: jest.fn().mockReturnValue({}),
getCachedCapabilities: jest.fn().mockReturnValue({}),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(false),
});
+6 -2
View File
@@ -38,14 +38,18 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
* @param issuer used as the base for all other urls
* @returns ValidatedIssuerMetadata
*/
export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): ValidatedIssuerMetadata => ({
export const mockOpenIdConfiguration = (
issuer = "https://auth.org/",
additionalGrantTypes: string[] = [],
): ValidatedIssuerMetadata => ({
issuer,
revocation_endpoint: issuer + "revoke",
token_endpoint: issuer + "token",
authorization_endpoint: issuer + "auth",
registration_endpoint: issuer + "registration",
device_authorization_endpoint: issuer + "device",
jwks_uri: issuer + "jwks",
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
grant_types_supported: ["authorization_code", "refresh_token", ...additionalGrantTypes],
code_challenge_methods_supported: ["S256"],
});
+4 -2
View File
@@ -173,8 +173,10 @@ export function mkEvent(opts: IEventOpts & { event?: boolean }, client?: MatrixC
room_id: opts.room,
sender: opts.sender || opts.user, // opts.user for backwards-compat
content: opts.content,
prev_content: opts.prev_content,
unsigned: opts.unsigned || {},
unsigned: {
...opts.unsigned,
prev_content: opts.prev_content,
},
event_id: "$" + testEventIndex++ + "-" + Math.random() + "-" + Math.random(),
txn_id: "~" + Math.random(),
redacts: opts.redacts,
@@ -0,0 +1,10 @@
## Dump of an empty libolm indexeddb cryptostore to test skipping migration
A dump of an account which is almost completely empty, and totally unsuitable
for use as a real account.
This dump was manually created by copying and editing full_account.
Created to test
["Unable to restore session" error due due to half-initialised legacy indexeddb crypto store #27447](https://github.com/element-hq/element-web/issues/27447).
We should not launch the Rust migration code when we find a DB in this state.
@@ -0,0 +1,14 @@
{
"account": [],
"device_data": [],
"inbound_group_sessions": [],
"inbound_group_sessions_withheld": [],
"notified_error_devices": [],
"outgoingRoomKeyRequests": [],
"parked_shared_history": [],
"rooms": [],
"session_problems": [],
"sessions": [],
"sessions_needing_backup": [],
"shared_history_inbound_group_sessions": []
}
@@ -0,0 +1,35 @@
/*
Copyright 2024 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 { DumpDataSetInfo } from "../index";
/**
* A key query response containing the current keys of the tested user.
* To be used during tests with fetchmock.
*/
const KEYS_QUERY_RESPONSE = { device_keys: { "@emptyuser:example.com": {} } };
/**
* A dataset containing the information for the tested user.
* To be used during tests.
*/
export const EMPTY_ACCOUNT_DATASET: DumpDataSetInfo = {
userId: "@emptyuser:example.com",
deviceId: "EMPTYDEVIC",
pickleKey: "+/bcdefghijklmnopqrstu1/zyxvutsrqponmlkjih2",
keyQueryResponse: KEYS_QUERY_RESPONSE,
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/empty_account/dump.json",
};
@@ -1,3 +1,19 @@
/*
Copyright 2024 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 { DumpDataSetInfo } from "../index";
/**
@@ -1,3 +1,19 @@
/*
Copyright 2024 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 { KeyBackupInfo } from "../../../../src/crypto-api/keybackup";
import { DumpDataSetInfo } from "../index";
@@ -245,6 +261,28 @@ const BACKUP_RESPONSE: KeyBackupInfo = {
count: 0,
};
/**
* This was generated by doing a backup reset on the account.
* This is a new valid backup for this account.
*/
const NEW_BACKUP_RESPONSE: KeyBackupInfo = {
auth_data: {
public_key: "CkDxWALi3lcChgjEZFEM6clYq5x768XBwsL++eaOzTI",
signatures: {
"@migration:localhost": {
"ed25519:YVEGEYPYWX":
"ZSYuQDdwgB9WKXQ+z5aWWfqSolBCGRw53kur1Vy956gFefgzCBkMbw5M0I2UgfU2Cukri7jZ4ig201zmLNmaAA",
"ed25519:rXCrBin/+xyh+yW//vWte+2UV0et1ZHTWfalp/Ekack":
"+UQ8EA507LoIqgK9rPsqPoGrj+iRBJeY2Oz0mMtXmVf8c1y8G0KWJNUWqvOysnOhsoJf1bt8ey48CxjjtSQ2AA",
},
},
},
version: "3",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
etag: "0",
count: 0,
};
/**
* A dataset containing the information for the tested user.
* To be used during tests.
@@ -256,5 +294,6 @@ export const MSK_NOT_CACHED_DATASET: DumpDataSetInfo = {
keyQueryResponse: KEY_QUERY_RESPONSE,
rotatedKeyQueryResponse: ROTATED_KEY_QUERY_RESPONSE,
backupResponse: BACKUP_RESPONSE,
newBackupResponse: NEW_BACKUP_RESPONSE,
dumpPath: "spec/test-utils/test_indexeddb_cryptostore_dump/no_cached_msk_dump/dump.json",
};
@@ -1,3 +1,19 @@
/*
Copyright 2024 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 { DumpDataSetInfo } from "../index";
/**
+1 -1
View File
@@ -857,7 +857,7 @@ describe("AutoDiscovery", function () {
const expected = {
"m.homeserver": {
state: AutoDiscoveryAction.FAIL_ERROR,
error: AutoDiscovery.ERROR_HOMESERVER_TOO_OLD,
error: AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION,
base_url: "https://example.org",
},
"m.identity_server": {
+5 -4
View File
@@ -50,17 +50,18 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => {
});
it("Should encode unpadded URL-safe base64", () => {
const toEncode = "?????";
// Chosen to have padding and multiple instances of / and + in the base64
const toEncode = "???????⊕⊗⊗";
const data = new TextEncoder().encode(toEncode);
const encoded = encodeUnpaddedBase64Url(data);
expect(encoded).toEqual("Pz8_Pz8");
expect(encoded).toEqual("Pz8_Pz8_P-KKleKKl-KKlw");
});
it("Should decode URL-safe base64", () => {
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8="));
const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8_P-KKleKKl-KKlw=="));
expect(decoded).toStrictEqual("?????");
expect(decoded).toStrictEqual("???????⊕⊗⊗");
});
it("Encode unpadded should not have padding", () => {
+22
View File
@@ -76,5 +76,27 @@ describe("ContentRepo", function () {
baseUrl + "/_matrix/media/v3/download/server.name/resourceid#automade",
);
});
it("should return an authenticated URL when requested", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, true, true)).toEqual(
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, true, true)).toEqual(
baseUrl +
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
it("should force-enable allow_redirects when useAuthentication is set true", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, undefined, false, true)).toEqual(
baseUrl + "/_matrix/client/v1/media/download/server.name/resourceid?allow_redirect=true",
);
expect(getHttpUriForMxc(baseUrl, mxcUri, 64, 64, "scale", undefined, false, true)).toEqual(
baseUrl +
"/_matrix/client/v1/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
);
});
});
});
+10 -10
View File
@@ -23,12 +23,12 @@ import HttpBackend from "matrix-mock-request";
import * as olmlib from "../../../src/crypto/olmlib";
import { MatrixError } from "../../../src/http-api";
import { logger } from "../../../src/logger";
import { ICrossSigningKey, ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
import { ICreateClientOpts, ISignedKey, MatrixClient } from "../../../src/client";
import { CryptoEvent } from "../../../src/crypto";
import { IDevice } from "../../../src/crypto/deviceinfo";
import { TestClient } from "../../TestClient";
import { resetCrossSigningKeys } from "./crypto-utils";
import { BootstrapCrossSigningOpts } from "../../../src/crypto-api";
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo } from "../../../src/crypto-api";
const PUSH_RULES_RESPONSE: Response = {
method: "GET",
@@ -377,7 +377,7 @@ describe("Cross Signing", function () {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK: ICrossSigningKey = {
const bobSSK: CrossSigningKeyInfo = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -515,7 +515,7 @@ describe("Cross Signing", function () {
};
olmlib.pkSign(bobDevice, selfSigningKey as unknown as PkSigning, "@bob:example.com", "");
const bobMaster: ICrossSigningKey = {
const bobMaster: CrossSigningKeyInfo = {
user_id: "@bob:example.com",
usage: ["master"],
keys: {
@@ -630,7 +630,7 @@ describe("Cross Signing", function () {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK: ICrossSigningKey = {
const bobSSK: CrossSigningKeyInfo = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -696,7 +696,7 @@ describe("Cross Signing", function () {
const bobSigning = new global.Olm.PkSigning();
const bobPrivkey = bobSigning.generate_seed();
const bobPubkey = bobSigning.init_with_seed(bobPrivkey);
const bobSSK: ICrossSigningKey = {
const bobSSK: CrossSigningKeyInfo = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -763,7 +763,7 @@ describe("Cross Signing", function () {
const bobSigning2 = new global.Olm.PkSigning();
const bobPrivkey2 = bobSigning2.generate_seed();
const bobPubkey2 = bobSigning2.init_with_seed(bobPrivkey2);
const bobSSK2: ICrossSigningKey = {
const bobSSK2: CrossSigningKeyInfo = {
user_id: "@bob:example.com",
usage: ["self_signing"],
keys: {
@@ -913,7 +913,7 @@ describe("Cross Signing", function () {
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK: ICrossSigningKey = {
const aliceSSK: CrossSigningKeyInfo = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
@@ -988,7 +988,7 @@ describe("Cross Signing", function () {
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK: ICrossSigningKey = {
const aliceSSK: CrossSigningKeyInfo = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
@@ -1048,7 +1048,7 @@ describe("Cross Signing", function () {
const aliceSigning = new global.Olm.PkSigning();
const alicePrivkey = aliceSigning.generate_seed();
const alicePubkey = aliceSigning.init_with_seed(alicePrivkey);
const aliceSSK: ICrossSigningKey = {
const aliceSSK: CrossSigningKeyInfo = {
user_id: "@alice:example.com",
usage: ["self_signing"],
keys: {
+6 -5
View File
@@ -23,12 +23,13 @@ import { makeTestClients } from "./verification/util";
import { encryptAES } from "../../../src/crypto/aes";
import { createSecretStorageKey, resetCrossSigningKeys } from "./crypto-utils";
import { logger } from "../../../src/logger";
import { ClientEvent, ICreateClientOpts, ICrossSigningKey, MatrixClient } from "../../../src/client";
import { ClientEvent, ICreateClientOpts, MatrixClient } from "../../../src/client";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { ISignatures } from "../../../src/@types/signed";
import { ICurve25519AuthData } from "../../../src/crypto/keybackup";
import { SecretStorageKeyDescription, SECRET_STORAGE_ALGORITHM_V1_AES } from "../../../src/secret-storage";
import { decodeBase64 } from "../../../src/base64";
import { CrossSigningKeyInfo } from "../../../src/crypto-api";
async function makeTestClient(
userInfo: { userId: string; deviceId: string },
@@ -475,7 +476,7 @@ describe("Secrets", function () {
[`ed25519:${XSPubKey}`]: XSPubKey,
},
},
self_signing: sign<ICrossSigningKey>(
self_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["self_signing"],
@@ -486,7 +487,7 @@ describe("Secrets", function () {
XSK,
"@alice:example.com",
),
user_signing: sign<ICrossSigningKey>(
user_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["user_signing"],
@@ -631,7 +632,7 @@ describe("Secrets", function () {
[`ed25519:${XSPubKey}`]: XSPubKey,
},
},
self_signing: sign<ICrossSigningKey>(
self_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["self_signing"],
@@ -642,7 +643,7 @@ describe("Secrets", function () {
XSK,
"@alice:example.com",
),
user_signing: sign<ICrossSigningKey>(
user_signing: sign<CrossSigningKeyInfo>(
{
user_id: "@alice:example.com",
usage: ["user_signing"],
-5
View File
@@ -136,11 +136,6 @@ describe("EventTimelineSet", () => {
expect(eventsInLiveTimeline.length).toStrictEqual(1);
expect(eventsInLiveTimeline[0]).toStrictEqual(duplicateMessageEvent);
});
it("Make sure legacy overload passing options directly as parameters still works", () => {
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Replace, false)).not.toThrow();
expect(() => eventTimelineSet.addLiveEvent(messageEvent, DuplicateStrategy.Ignore, true)).not.toThrow();
});
});
describe("addEventToTimeline", () => {
+3 -1
View File
@@ -386,6 +386,9 @@ describe("MatrixClient", function () {
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
);
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true, true),
);
});
});
@@ -1557,7 +1560,6 @@ describe("MatrixClient", function () {
},
},
event_id: "$ev1",
user_id: "@alice:matrix.org",
});
expect(rootEvent.isThreadRoot).toBe(true);
+155 -85
View File
@@ -15,16 +15,7 @@ limitations under the License.
*/
import { MatrixEvent } from "../../../src";
import { CallMembership, CallMembershipData } from "../../../src/matrixrtc/CallMembership";
const membershipTemplate: CallMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
};
import { CallMembership, CallMembershipDataLegacy, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
function makeMockEvent(originTs = 0): MatrixEvent {
return {
@@ -34,96 +25,175 @@ function makeMockEvent(originTs = 0): MatrixEvent {
}
describe("CallMembership", () => {
it("rejects membership with no expiry and no expires_ts", () => {
expect(() => {
new CallMembership(
makeMockEvent(),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
describe("CallMembershipDataLegacy", () => {
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
it("rejects membership with no expiry and no expires_ts", () => {
expect(() => {
new CallMembership(
makeMockEvent(),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
);
}).toThrow();
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("rejects with malformatted expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
}).toThrow();
});
it("rejects with malformatted expires", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
}).toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
}).toThrow();
expect(membership.createdTs()).toEqual(67890);
});
it("computes absolute expiry time based on expires", () => {
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership(
makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires_ts: 6000 }),
);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships expired when local age large", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.localTimestamp = Date.now() - 6000;
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(true);
});
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
describe("SessionMembershipData", () => {
const membershipTemplate: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
focus_active: { type: "livekit" },
foci_preferred: [{ type: "livekit" }],
};
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("rejects membership with no device_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { device_id: undefined }));
}).toThrow();
});
it("rejects membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).toThrow();
});
it("rejects with malformatted expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
}).toThrow();
});
it("rejects with malformatted expires", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
}).toThrow();
});
it("rejects membership with no call_id", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { call_id: undefined }));
}).toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("allow membership with no scope", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).not.toThrow();
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
expect(membership.createdTs()).toEqual(12345);
});
it("computes absolute expiry time based on expires", () => {
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("uses created_ts if present", () => {
const membership = new CallMembership(
makeMockEvent(12345),
Object.assign({}, membershipTemplate, { created_ts: 67890 }),
);
expect(membership.createdTs()).toEqual(67890);
});
it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership(
makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(false);
});
it("considers memberships expired when local age large", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.localTimestamp = Date.now() - 6000;
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(true);
});
it("returns active foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_active: [mockFocus] }),
);
expect(membership.getActiveFoci()).toEqual([mockFocus]);
it("returns preferred foci", () => {
const fakeEvent = makeMockEvent();
const mockFocus = { type: "this_is_a_mock_focus" };
const membership = new CallMembership(
fakeEvent,
Object.assign({}, membershipTemplate, { foci_preferred: [mockFocus] }),
);
expect(membership.getPreferredFoci()).toEqual([mockFocus]);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
const membershipTemplate: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA",
expires: 5000,
membershipID: "bloop",
foci_active: [{ type: "livekit" }],
};
beforeEach(() => {
// server origin timestamp for this event is 1000
+60
View File
@@ -0,0 +1,60 @@
/*
Copyright 2023 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 { isLivekitFocus, isLivekitFocusActive, isLivekitFocusConfig } from "../../../src/matrixrtc/LivekitFocus";
describe("LivekitFocus", () => {
it("isLivekitFocus", () => {
expect(
isLivekitFocus({
type: "livekit",
livekit_service_url: "http://test.com",
livekit_alias: "test",
}),
).toBeTruthy();
expect(isLivekitFocus({ type: "livekit" })).toBeFalsy();
expect(
isLivekitFocus({ type: "not-livekit", livekit_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", other_service_url: "http://test.com", livekit_alias: "test" }),
).toBeFalsy();
expect(
isLivekitFocus({ type: "livekit", livekit_service_url: "http://test.com", other_alias: "test" }),
).toBeFalsy();
});
it("isLivekitFocusActive", () => {
expect(
isLivekitFocusActive({
type: "livekit",
focus_selection: "oldest_membership",
}),
).toBeTruthy();
expect(isLivekitFocusActive({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusActive({ type: "not-livekit", focus_selection: "oldest_membership" })).toBeFalsy();
});
it("isLivekitFocusConfig", () => {
expect(
isLivekitFocusConfig({
type: "livekit",
livekit_service_url: "http://test.com",
}),
).toBeTruthy();
expect(isLivekitFocusConfig({ type: "livekit" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "not-livekit", livekit_service_url: "http://test.com" })).toBeFalsy();
expect(isLivekitFocusConfig({ type: "livekit", other_service_url: "oldest_membership" })).toBeFalsy();
});
});
+237 -42
View File
@@ -16,7 +16,11 @@ limitations under the License.
import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import {
CallMembershipData,
CallMembershipDataLegacy,
SessionMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { randomString } from "../../../src/randomstring";
@@ -29,6 +33,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
};
const mockFocus = { type: "mock" };
@@ -98,22 +103,33 @@ describe("MatrixRTCSession", () => {
});
it("safely ignores events with no memberships section", () => {
const roomId = randomString(8);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
},
],
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
@@ -122,22 +138,33 @@ describe("MatrixRTCSession", () => {
});
it("safely ignores events with junk memberships section", () => {
const roomId = randomString(8);
const event = {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: ["i am a fish"] }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
};
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
roomId,
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({ memberships: "i am a fish" }),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: jest.fn().mockReturnValue(0),
},
],
getStateEvents: (_type: string, _stateKey: string) => [event],
events: new Map([
[
EventType.GroupCallMemberPrefix,
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
}),
}),
};
@@ -185,6 +212,67 @@ describe("MatrixRTCSession", () => {
expect(sess.memberships).toHaveLength(0);
});
describe("updateCallMembershipEvent", () => {
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
const joinSessionConfig = { useLegacyMemberEvents: false };
const legacyMembershipData: CallMembershipDataLegacy = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_legacy",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [mockFocus],
};
const expiredLegacyMembershipData: CallMembershipDataLegacy = {
...legacyMembershipData,
device_id: "AAAAAAA_legacy_expired",
expires: 0,
};
const sessionMembershipData: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_session",
focus_active: mockFocus,
foci_preferred: [mockFocus],
};
function testSession(
membershipData: CallMembershipData[] | SessionMembershipData,
shouldUseLegacy: boolean,
): void {
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
const makeNewLegacyMembershipsMock = jest.spyOn(sess as any, "makeNewLegacyMemberships");
const makeNewMembershipMock = jest.spyOn(sess as any, "makeNewMembership");
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
expect(makeNewLegacyMembershipsMock).toHaveBeenCalledTimes(shouldUseLegacy ? 1 : 0);
expect(makeNewMembershipMock).toHaveBeenCalledTimes(shouldUseLegacy ? 0 : 1);
}
it("uses legacy events if there are any active legacy calls", () => {
testSession([expiredLegacyMembershipData, legacyMembershipData, sessionMembershipData], true);
});
it('uses legacy events if a non-legacy call is in a "memberships" array', () => {
testSession([sessionMembershipData], true);
});
it("uses non-legacy events if all legacy calls are expired", () => {
testSession([expiredLegacyMembershipData], false);
});
it("uses non-legacy events if there are only non-legacy calls", () => {
testSession(sessionMembershipData, false);
});
});
describe("getOldestMembership", () => {
it("returns the oldest membership event", () => {
const mockRoom = makeMockRoom([
@@ -198,6 +286,64 @@ describe("MatrixRTCSession", () => {
});
});
describe("getsActiveFocus", () => {
const activeFociConfig = { type: "livekit", livekit_service_url: "https://active.url" };
it("gets the correct active focus with oldest_membership", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "oldest_membership",
});
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
it("does not provide focus if the selction method is unknown", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }], {
type: "livekit",
focus_selection: "unknown",
});
expect(sess.getActiveFocus()).toBe(undefined);
});
it("gets the correct active focus legacy", () => {
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "foo",
created_ts: 500,
foci_active: [activeFociConfig],
}),
Object.assign({}, membershipTemplate, { device_id: "old", created_ts: 1000 }),
Object.assign({}, membershipTemplate, { device_id: "bar", created_ts: 2000 }),
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([{ type: "livekit", livekit_service_url: "htts://test.org" }]);
expect(sess.getActiveFocus()).toBe(activeFociConfig);
});
});
describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
@@ -223,13 +369,13 @@ describe("MatrixRTCSession", () => {
});
it("shows joined once join is called", () => {
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true);
});
it("sends a membership event when joining a call", () => {
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
@@ -242,7 +388,8 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [{ type: "mock" }],
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
],
@@ -252,12 +399,48 @@ describe("MatrixRTCSession", () => {
jest.useRealTimers();
});
describe("non-legacy calls", () => {
const activeFocusConfig = { type: "livekit", livekit_service_url: "https://active.url" };
const activeFocus = { type: "livekit", focus_selection: "oldest_membership" };
function testJoin(useOwnedStateEvents: boolean): void {
if (useOwnedStateEvents) {
mockRoom.getVersion = jest.fn().mockReturnValue("org.matrix.msc3779.default");
}
jest.useFakeTimers();
sess!.joinRoomSession([activeFocusConfig], activeFocus, { useLegacyMemberEvents: false });
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
foci_preferred: [activeFocusConfig],
focus_active: activeFocus,
} satisfies SessionMembershipData,
`${!useOwnedStateEvents ? "_" : ""}@alice:example.org_AAAAAAA`,
);
jest.useRealTimers();
}
it("sends a membership event with session payload when joining a non-legacy call", () => {
testJoin(false);
});
it("does not prefix the state key with _ for rooms that support user-owned state events", () => {
testJoin(true);
});
});
it("does nothing if join called when already joined", () => {
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
@@ -274,15 +457,26 @@ describe("MatrixRTCSession", () => {
const sendStateEventMock = jest.fn().mockImplementation(resolveFn);
client.sendStateEvent = sendStateEventMock;
sess!.joinRoomSession([mockFocus]);
sess!.joinRoomSession([mockFocus], mockFocus);
const eventContent = await eventSentPromise;
// definitely should have renewed by 1 second before the expiry!
const timeElapsed = 60 * 60 * 1000 - 1000;
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
.fn()
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed));
const event = mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed);
const getState = mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
getState.getStateEvents = jest.fn().mockReturnValue(event);
getState.events = new Map([
[
event.getType(),
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
} as unknown as Map<string, MatrixEvent>,
],
]);
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
@@ -308,7 +502,7 @@ describe("MatrixRTCSession", () => {
device_id: "AAAAAAA",
expires: 3600000 * 2,
expires_ts: 1000 + 3600000 * 2,
foci_active: [{ type: "mock" }],
foci_active: [mockFocus],
created_ts: 1000,
membershipID: expect.stringMatching(".*"),
},
@@ -322,7 +516,7 @@ describe("MatrixRTCSession", () => {
});
it("creates a key when joining", () => {
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA");
expect(keys).toHaveLength(1);
@@ -336,7 +530,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await eventSentPromise;
@@ -372,7 +566,7 @@ describe("MatrixRTCSession", () => {
});
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
jest.advanceTimersByTime(10000);
await eventSentPromise;
@@ -394,7 +588,7 @@ describe("MatrixRTCSession", () => {
throw e;
});
sess!.joinRoomSession([mockFocus], true);
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel);
});
@@ -409,7 +603,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
sendEventMock.mockClear();
@@ -462,7 +656,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
const firstKeysPayload = await keysSentPromise1;
expect(firstKeysPayload.keys).toHaveLength(1);
@@ -489,7 +683,7 @@ describe("MatrixRTCSession", () => {
});
it("Doesn't re-send key immediately", async () => {
const realSetImmediate = setImmediate;
const realSetTimeout = setTimeout;
jest.useFakeTimers();
try {
const mockRoom = makeMockRoom([membershipTemplate]);
@@ -499,7 +693,7 @@ describe("MatrixRTCSession", () => {
sendEventMock.mockImplementation(resolve);
});
sess.joinRoomSession([mockFocus], true);
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await keysSentPromise1;
sendEventMock.mockClear();
@@ -517,7 +711,7 @@ describe("MatrixRTCSession", () => {
sess.onMembershipUpdate();
await new Promise((resolve) => {
realSetImmediate(resolve);
realSetTimeout(resolve);
});
expect(sendEventMock).not.toHaveBeenCalled();
@@ -595,7 +789,7 @@ describe("MatrixRTCSession", () => {
jest.advanceTimersByTime(10000);
sess.joinRoomSession([mockFocus]);
sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoomNoExpired!.roomId,
@@ -631,7 +825,7 @@ describe("MatrixRTCSession", () => {
]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
sess.joinRoomSession([mockFocus]);
sess.joinRoomSession([mockFocus], mockFocus);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
@@ -645,6 +839,7 @@ describe("MatrixRTCSession", () => {
device_id: "OTHERDEVICE",
expires: 3600000,
created_ts: 1000,
foci_active: [{ type: "livekit", livekit_service_url: "https://lk.url" }],
membershipID: expect.stringMatching(".*"),
},
{
@@ -35,6 +35,7 @@ const membershipTemplate: CallMembershipData = {
device_id: "AAAAAAA",
expires: 60 * 60 * 1000,
membershipID: "bloop",
foci_active: [{ type: "test" }],
};
describe("MatrixRTCSessionManager", () => {
+27 -9
View File
@@ -15,24 +15,27 @@ limitations under the License.
*/
import { EventType, MatrixEvent, Room } from "../../../src";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { CallMembershipData, SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { randomString } from "../../../src/randomstring";
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
type MembershipData = CallMembershipData[] | SessionMembershipData;
export function makeMockRoom(membershipData: MembershipData, localAge: number | null = null): Room {
const roomId = randomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(memberships, roomId, localAge);
const roomState = makeMockRoomState(membershipData, roomId, localAge);
return {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(roomState),
}),
getVersion: jest.fn().mockReturnValue("default"),
} as unknown as Room;
}
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
const event = mockRTCEvent(memberships, roomId, localAge);
export function makeMockRoomState(membershipData: MembershipData, roomId: string, localAge: number | null = null) {
const event = mockRTCEvent(membershipData, roomId, localAge);
return {
on: jest.fn(),
off: jest.fn(),
@@ -40,15 +43,30 @@ export function makeMockRoomState(memberships: CallMembershipData[], roomId: str
if (stateKey !== undefined) return event;
return [event];
},
events: new Map([
[
event.getType(),
{
size: () => true,
has: (_stateKey: string) => true,
get: (_stateKey: string) => event,
values: () => [event],
},
],
]),
};
}
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
export function mockRTCEvent(membershipData: MembershipData, roomId: string, localAge: number | null): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({
memberships: memberships,
}),
getContent: jest.fn().mockReturnValue(
!Array.isArray(membershipData)
? membershipData
: {
memberships: membershipData,
},
),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
localTimestamp: Date.now() - (localAge ?? 10),
+17 -18
View File
@@ -16,11 +16,12 @@ limitations under the License.
import { IContent, MatrixClient, MatrixEvent } from "../../../src";
import { Room } from "../../../src/models/room";
import { IEncryptedFile, RelationType, UNSTABLE_MSC3089_BRANCH } from "../../../src/@types/event";
import { RelationType, 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";
import { MSC3089TreeSpace } from "../../../src/models/MSC3089TreeSpace";
import { EncryptedFile } from "../../../src/@types/media";
describe("MSC3089Branch", () => {
let client: MatrixClient;
@@ -254,7 +255,7 @@ describe("MSC3089Branch", () => {
it("should create new versions of itself", async () => {
const canaryName = "canary";
const canaryContents = "contents go here";
const canaryFile = {} as IEncryptedFile;
const canaryFile = {} as EncryptedFile;
const canaryAddl = { canary: true };
indexEvent.getContent = () => ({ active: true, retained: true });
const stateKeyOrder = [fileEventId2, fileEventId];
@@ -287,23 +288,21 @@ describe("MSC3089Branch", () => {
const createFn = jest
.fn()
.mockImplementation(
(name: string, contents: ArrayBuffer, info: Partial<IEncryptedFile>, addl: IContent) => {
expect(name).toEqual(canaryName);
expect(contents).toBe(canaryContents);
expect(info).toBe(canaryFile);
expect(addl).toMatchObject({
...canaryAddl,
"m.new_content": true,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: fileEventId,
},
});
.mockImplementation((name: string, contents: ArrayBuffer, info: Partial<EncryptedFile>, addl: IContent) => {
expect(name).toEqual(canaryName);
expect(contents).toBe(canaryContents);
expect(info).toBe(canaryFile);
expect(addl).toMatchObject({
...canaryAddl,
"m.new_content": true,
"m.relates_to": {
rel_type: RelationType.Replace,
event_id: fileEventId,
},
});
return Promise.resolve({ event_id: fileEventId2 });
},
);
return Promise.resolve({ event_id: fileEventId2 });
});
directory.createFile = createFn;
await branch.createNewVersion(canaryName, canaryContents, canaryFile, canaryAddl);
+27
View File
@@ -90,4 +90,31 @@ describe("registerOidcClient()", () => {
OidcError.DynamicRegistrationInvalid,
);
});
it("should throw when required endpoints are unavailable", async () => {
await expect(() =>
registerOidcClient(
{
...delegatedAuthConfig,
registrationEndpoint: undefined,
},
metadata,
),
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
});
it("should throw when required scopes are unavailable", async () => {
await expect(() =>
registerOidcClient(
{
...delegatedAuthConfig,
metadata: {
...delegatedAuthConfig.metadata,
grant_types_supported: [delegatedAuthConfig.metadata.grant_types_supported[0]],
},
},
metadata,
),
).rejects.toThrow(OidcError.DynamicRegistrationNotSupported);
});
});
+282 -240
View File
@@ -61,251 +61,293 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
let httpBackend: MockHttpBackend;
let client: MatrixClient;
beforeEach(async function () {
jest.runOnlyPendingTimers();
jest.useRealTimers();
httpBackend = new MockHttpBackend();
/**
* We need to split the tests into regular ones (these) and ones that use fake timers,
* because the fake indexeddb uses timers too and appears make tests cause other tests
* to fail if we keep enabling/disabling fake timers within the same test suite.
*/
describe("non-timed tests", () => {
beforeEach(async function () {
httpBackend = new MockHttpBackend();
let store: IStore;
if (storeType === StoreType.IndexedDB) {
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
await idbStore.startup();
store = idbStore;
} else {
store = new MemoryStore();
}
let store: IStore;
if (storeType === StoreType.IndexedDB) {
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
await idbStore.startup();
store = idbStore;
} else {
store = new MemoryStore();
}
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
store,
});
});
afterEach(function () {
jest.useRealTimers();
client.stopClient();
});
it("sends a to-device message", async function () {
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
})
.respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
await httpBackend.flushAllExpected();
// let the code handle the response to the request so we don't get
// log output after the test has finished (apparently stopping the
// client in aftereach is not sufficient.)
await flushPromises();
});
it("retries on error", async function () {
jest.useFakeTimers();
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
})
.respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
// flush, as per comment in first test
await flushPromises();
});
it("stops retrying on 4xx errors", async function () {
jest.useFakeTimers();
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
// Asserting that another request is never made is obviously
// a bit tricky - we just flush the queue what should hopefully
// be plenty of times and assert that nothing comes through.
let tries = 0;
await flushAndRunTimersUntil(() => ++tries === 10);
expect(httpBackend.requests.length).toEqual(0);
});
it("honours ratelimiting", async function () {
jest.useFakeTimers();
// pick something obscure enough it's unlikley to clash with a
// retry delay the algorithm uses anyway
const retryDelay = 279 * 1000;
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
errcode: "M_LIMIT_EXCEEDED",
retry_after_ms: retryDelay,
});
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
await flushPromises();
logger.info("Advancing clock to just before expected retry time...");
jest.advanceTimersByTime(retryDelay - 1000);
await flushPromises();
expect(httpBackend.requests.length).toEqual(0);
logger.info("Advancing clock past expected retry time...");
jest.advanceTimersByTime(2000);
await flushPromises();
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
});
it("retries on retryImmediately()", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
await flushPromises();
client.retryImmediately();
// longer timeout here to try & avoid flakiness
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
});
it("retries on when client is started", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
await flushPromises();
client.stopClient();
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
});
it("retries when a message is retried", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
await flushPromises();
const dummyEvent = new MatrixEvent({
event_id: "!fake:example.org",
});
const mockRoom = {
updatePendingEvent: jest.fn(),
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
} as unknown as Room;
client.resendEvent(dummyEvent, mockRoom);
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
});
it("splits many messages into multiple HTTP requests", async function () {
const batch: ToDeviceBatch = {
eventType: "org.example.foo",
batch: [],
};
for (let i = 0; i <= 20; ++i) {
batch.batch.push({
userId: `@user${i}:example.org`,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
store,
});
}
});
const expectedCounts = [20, 1];
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
).toBeTruthy();
})
.respond(200, {});
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(1);
})
.respond(200, {});
afterEach(function () {
client.stopClient();
});
await client.queueToDevice(batch);
await httpBackend.flushAllExpected();
it("sends a to-device message", async function () {
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
})
.respond(200, {});
// flush, as per comment in first test
await flushPromises();
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
await httpBackend.flushAllExpected();
// let the code handle the response to the request so we don't get
// log output after the test has finished (apparently stopping the
// client in aftereach is not sufficient.)
await flushPromises();
});
it("retries on retryImmediately()", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
await flushPromises();
client.retryImmediately();
// longer timeout here to try & avoid flakiness
expect(await httpBackend.flush(undefined, 1, 3000)).toEqual(1);
});
it("retries on when client is started", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 1)).toEqual(1);
await flushPromises();
client.stopClient();
await Promise.all([client.startClient(), httpBackend.flush("/_matrix/client/versions", 1, 20)]);
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
});
it("retries when a message is retried", async function () {
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
versions: ["v1.1"],
});
await Promise.all([client.startClient(), httpBackend.flush(undefined, 1, 20)]);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
await client.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
});
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
await flushPromises();
const dummyEvent = new MatrixEvent({
event_id: "!fake:example.org",
});
const mockRoom = {
updatePendingEvent: jest.fn(),
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
} as unknown as Room;
client.resendEvent(dummyEvent, mockRoom);
expect(await httpBackend.flush(undefined, 1, 20)).toEqual(1);
});
it("splits many messages into multiple HTTP requests", async function () {
const batch: ToDeviceBatch = {
eventType: "org.example.foo",
batch: [],
};
for (let i = 0; i <= 20; ++i) {
batch.batch.push({
userId: `@user${i}:example.org`,
deviceId: FAKE_DEVICE_ID,
payload: FAKE_PAYLOAD,
});
}
const expectedCounts = [20, 1];
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(
removeElement(expectedCounts, (c) => c === Object.keys(request.data.messages).length),
).toBeTruthy();
})
.respond(200, {});
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(Object.keys(request.data.messages).length).toEqual(1);
})
.respond(200, {});
await client.queueToDevice(batch);
await httpBackend.flushAllExpected();
// flush, as per comment in first test
await flushPromises();
});
});
describe("async tests", () => {
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
});
beforeEach(async function () {
httpBackend = new MockHttpBackend();
let store: IStore;
if (storeType === StoreType.IndexedDB) {
const idbStore = new IndexedDBStore({ indexedDB: fakeIndexedDB });
let storeStarted = false;
idbStore.startup().then(() => {
storeStarted = true;
});
await flushAndRunTimersUntil(() => storeStarted);
store = idbStore;
} else {
store = new MemoryStore();
}
client = new MatrixClient({
baseUrl: "https://my.home.server",
accessToken: "my.access.token",
fetchFn: httpBackend.fetchFn as typeof global.fetch,
store,
});
});
afterEach(function () {
client.stopClient();
});
it("retries on error", async function () {
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(500);
httpBackend
.when("PUT", "/sendToDevice/org.example.foo/")
.check((request) => {
expect(request.data).toEqual(EXPECTED_BODY);
})
.respond(200, {});
client
.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
})
.then();
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
// flush, as per comment in first test
await flushPromises();
});
it("stops retrying on 4xx errors", async function () {
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(400);
client
.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
})
.then();
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
// Asserting that another request is never made is obviously
// a bit tricky - we just flush the queue what should hopefully
// be plenty of times and assert that nothing comes through.
let tries = 0;
await flushAndRunTimersUntil(() => ++tries === 10);
expect(httpBackend.requests.length).toEqual(0);
});
it("honours ratelimiting", async function () {
// pick something obscure enough it's unlikley to clash with a
// retry delay the algorithm uses anyway
const retryDelay = 279 * 1000;
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(429, {
errcode: "M_LIMIT_EXCEEDED",
retry_after_ms: retryDelay,
});
httpBackend.when("PUT", "/sendToDevice/org.example.foo/").respond(200, {});
client
.queueToDevice({
eventType: "org.example.foo",
batch: [FAKE_MSG],
})
.then();
await flushAndRunTimersUntil(() => httpBackend.requests.length > 0);
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
await flushPromises();
logger.info("Advancing clock to just before expected retry time...");
jest.advanceTimersByTime(retryDelay - 1000);
await flushPromises();
expect(httpBackend.requests.length).toEqual(0);
logger.info("Advancing clock past expected retry time...");
jest.advanceTimersByTime(2000);
await flushPromises();
expect(httpBackend.flushSync(undefined, 1)).toEqual(1);
});
});
});
@@ -0,0 +1,341 @@
/*
Copyright 2024 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 fetchMock from "fetch-mock-jest";
import { ClientPrefix, IHttpOpts, MatrixClient, MatrixHttpApi } from "../../../src";
import { ClientRendezvousFailureReason, MSC4108RendezvousSession } from "../../../src/rendezvous";
function makeMockClient(opts: { userId: string; deviceId: string; msc4108Enabled: boolean }): MatrixClient {
const client = {
doesServerSupportUnstableFeature(feature: string) {
return Promise.resolve(opts.msc4108Enabled && feature === "org.matrix.msc4108");
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl: "https://example.com",
} as unknown as MatrixClient;
client.http = new MatrixHttpApi<IHttpOpts & { onlyData: true }>(client, {
baseUrl: client.baseUrl,
prefix: ClientPrefix.Unstable,
onlyData: true,
});
return client;
}
fetchMock.config.overwriteRoutes = true;
describe("MSC4108RendezvousSession", () => {
beforeEach(() => {
fetchMock.reset();
});
async function postAndCheckLocation(msc4108Enabled: boolean, fallbackRzServer: string, locationResponse: string) {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled });
const transport = new MSC4108RendezvousSession({ client, fallbackRzServer });
{
// initial POST
const expectedPostLocation = msc4108Enabled
? `${client.baseUrl}/_matrix/client/unstable/org.matrix.msc4108/rendezvous`
: fallbackRzServer;
fetchMock.postOnce(expectedPostLocation, {
status: 201,
body: { url: locationResponse },
});
await transport.send("data");
}
{
fetchMock.get(locationResponse, {
status: 200,
body: "data",
headers: {
"content-type": "text/plain",
"etag": "aaa",
},
});
await expect(transport.receive()).resolves.toEqual("data");
}
}
it("should use custom fetchFn if provided", async () => {
const sandbox = fetchMock.sandbox();
const fetchFn = jest.fn().mockImplementation(sandbox);
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fetchFn,
fallbackRzServer: "https://fallbackserver/rz",
});
sandbox.postOnce("https://fallbackserver/rz", {
status: 201,
body: {
url: "https://fallbackserver/rz/123",
},
});
await transport.send("data");
await sandbox.flush(true);
expect(fetchFn).toHaveBeenCalledWith("https://fallbackserver/rz", expect.anything());
});
it("should throw an error when no server available", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({ client });
await expect(transport.send("data")).rejects.toThrow("Invalid rendezvous URI");
});
it("POST to fallback server", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await fetchMock.flush(true);
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
});
it("POST with no location", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
});
await Promise.all([expect(transport.send("data")).rejects.toThrow(), fetchMock.flush(true)]);
});
it("POST with absolute path response", async function () {
await postAndCheckLocation(false, "https://fallbackserver/rz", "https://fallbackserver/123");
});
it("POST to built-in MSC3886 implementation", async function () {
await postAndCheckLocation(
true,
"https://fallbackserver/rz",
"https://example.com/_matrix/client/unstable/org.matrix.msc4108/rendezvous/123",
);
});
it("POST with relative path response including parent", async function () {
await postAndCheckLocation(false, "https://fallbackserver/rz/abc", "https://fallbackserver/rz/xyz/123");
});
// fetch-mock doesn't handle redirects properly, so we can't test this
it.skip("POST to follow 307 to other server", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
fetchMock.postOnce("https://fallbackserver/rz", {
status: 307,
redirectUrl: "https://redirected.fallbackserver/rz",
redirected: true,
});
fetchMock.postOnce("https://redirected.fallbackserver/rz", {
status: 201,
body: { url: "https://redirected.fallbackserver/rz/123" },
headers: { etag: "aaa" },
});
await fetchMock.flush(true);
await expect(transport.send("data")).resolves.toStrictEqual(undefined);
});
it("POST and GET", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// first GET without etag
fetchMock.getOnce("https://fallbackserver/rz/123", {
status: 200,
body: "foo=baa",
headers: { "content-type": "text/plain", "etag": "aaa" },
});
await expect(transport.receive()).resolves.toEqual("foo=baa");
await fetchMock.flush(true);
}
{
// subsequent GET which should have etag from previous request
fetchMock.getOnce("https://fallbackserver/rz/123", {
status: 200,
body: "foo=baa",
headers: { "content-type": "text/plain", "etag": "bbb" },
});
await expect(transport.receive()).resolves.toEqual("foo=baa");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
method: "GET",
headers: { "if-none-match": "aaa" },
});
}
});
it("POST and PUTs", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
headers: { etag: "aaa" },
});
await transport.send("foo=baa");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// subsequent PUT which should have etag from previous request
fetchMock.putOnce("https://fallbackserver/rz/123", { status: 202, headers: { etag: "bbb" } });
await transport.send("c=d");
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz/123", {
method: "PUT",
headers: { "if-match": "aaa" },
});
}
});
it("POST and DELETE", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
{
// Create
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
});
await expect(transport.send("foo=baa")).resolves.toStrictEqual(undefined);
await fetchMock.flush(true);
expect(fetchMock).toHaveFetched("https://fallbackserver/rz", {
method: "POST",
headers: { "content-type": "text/plain" },
functionMatcher: (_, opts): boolean => {
return opts.body === "foo=baa";
},
});
}
{
// Cancel
fetchMock.deleteOnce("https://fallbackserver/rz/123", { status: 204 });
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
await fetchMock.flush(true);
}
});
it("send after cancelled", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
await transport.cancel(ClientRendezvousFailureReason.UserDeclined);
await expect(transport.send("data")).resolves.toBeUndefined();
});
it("receive before ready", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
});
await expect(transport.receive()).rejects.toThrow();
});
it("404 failure callback", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const onFailure = jest.fn();
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
onFailure,
});
fetchMock.postOnce("https://fallbackserver/rz", { status: 404 });
await Promise.all([expect(transport.send("foo=baa")).resolves.toBeUndefined(), fetchMock.flush(true)]);
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Unknown);
});
it("404 failure callback mapped to expired", async function () {
const client = makeMockClient({ userId: "@alice:example.com", deviceId: "DEVICEID", msc4108Enabled: false });
const onFailure = jest.fn();
const transport = new MSC4108RendezvousSession({
client,
fallbackRzServer: "https://fallbackserver/rz",
onFailure,
});
{
// initial POST
fetchMock.postOnce("https://fallbackserver/rz", {
status: 201,
body: { url: "https://fallbackserver/rz/123" },
headers: { expires: "Thu, 01 Jan 1970 00:00:00 GMT" },
});
await transport.send("foo=baa");
await fetchMock.flush(true);
}
{
// GET with 404 to simulate expiry
fetchMock.getOnce("https://fallbackserver/rz/123", { status: 404, body: "foo=baa" });
await Promise.all([expect(transport.receive()).resolves.toBeUndefined(), fetchMock.flush(true)]);
expect(onFailure).toHaveBeenCalledWith(ClientRendezvousFailureReason.Expired);
}
});
});
@@ -0,0 +1,126 @@
/*
Copyright 2024 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 { EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked } from "jest-mock";
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
describe("MSC4108SecureChannel", () => {
const baseUrl = "https://example.com";
const url = "https://fallbackserver/rz/123";
it("should generate qr code data as expected", async () => {
const session = new MSC4108RendezvousSession({
url,
});
const channel = new MSC4108SecureChannel(session);
const code = await channel.generateCode(QrCodeMode.Login);
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
expect(text.endsWith(url)).toBeTruthy();
});
it("should throw error if attempt to connect multiple times", async () => {
const mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await channel.connect();
await expect(channel.connect()).rejects.toThrow("Channel already connected");
});
it("should throw error on invalid initiate response", async () => {
const mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
mocked(mockSession.receive).mockResolvedValue("");
await expect(channel.connect()).rejects.toThrow("No response from other device");
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
);
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await expect(channel.connect()).rejects.toThrow("Invalid response from other device");
});
describe("should be able to connect as a reciprocating device", () => {
let mockSession: MSC4108RendezvousSession;
let channel: MSC4108SecureChannel;
let opponentChannel: EstablishedEcies;
beforeEach(async () => {
mockSession = {
send: jest.fn(),
receive: jest.fn(),
url,
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
opponentChannel = _opponentChannel;
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await channel.connect();
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe("MATRIX_QR_CODE_LOGIN_OK");
mocked(mockSession.send).mockReset();
});
it("should be able to securely send encrypted payloads", async () => {
const payload = {
type: PayloadType.Secrets,
protocols: ["a", "b", "c"],
homeserver: "https://example.org",
};
await channel.secureSend(payload);
expect(mockSession.send).toHaveBeenCalled();
expect(opponentChannel.decrypt(mocked(mockSession.send).mock.calls[0][0])).toBe(JSON.stringify(payload));
});
it("should be able to securely receive encrypted payloads", async () => {
const payload = {
type: PayloadType.Secrets,
protocols: ["a", "b", "c"],
homeserver: "https://example.org",
};
const ciphertext = opponentChannel.encrypt(JSON.stringify(payload));
mocked(mockSession.receive).mockResolvedValue(ciphertext);
await expect(channel.secureReceive()).resolves.toEqual(payload);
});
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import "../../olm-loader";
import { RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import { LegacyRendezvousFailureReason as RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import { MSC3903ECDHPayload, MSC3903ECDHv2RendezvousChannel } from "../../../src/rendezvous/channels";
import { decodeBase64 } from "../../../src/base64";
import { DummyTransport } from "./DummyTransport";
+7 -2
View File
@@ -17,7 +17,12 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import "../../olm-loader";
import { MSC3906Rendezvous, RendezvousCode, RendezvousFailureReason, RendezvousIntent } from "../../../src/rendezvous";
import {
MSC3906Rendezvous,
RendezvousCode,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from "../../../src/rendezvous";
import {
ECDHv2RendezvousCode as ECDHRendezvousCode,
MSC3903ECDHPayload,
@@ -111,7 +116,7 @@ function makeMockClient(opts: {
},
};
},
getCapabilities() {
getCachedCapabilities() {
return opts.msc3882r0Only
? {}
: {
@@ -17,7 +17,7 @@ limitations under the License.
import MockHttpBackend from "matrix-mock-request";
import type { MatrixClient } from "../../../src";
import { RendezvousFailureReason } from "../../../src/rendezvous";
import { LegacyRendezvousFailureReason as RendezvousFailureReason } from "../../../src/rendezvous";
import { MSC3886SimpleHttpRendezvousTransport } from "../../../src/rendezvous/transports";
function makeMockClient(opts: { userId: string; deviceId: string; msc3886Enabled: boolean }): MatrixClient {
+45 -21
View File
@@ -22,7 +22,7 @@ import { mocked } from "jest-mock";
import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START, Optional, PollStartEvent } from "matrix-events-sdk";
import * as utils from "../test-utils/test-utils";
import { emitPromise } from "../test-utils/test-utils";
import { emitPromise, IMessageOpts } from "../test-utils/test-utils";
import {
Direction,
DuplicateStrategy,
@@ -54,7 +54,6 @@ import { Crypto } from "../../src/crypto";
import * as threadUtils from "../test-utils/thread";
import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger";
import { IMessageOpts } from "../test-utils/test-utils";
import { flushPromises } from "../test-utils/flushPromises";
import { KnownMembership } from "../../src/@types/membership";
@@ -339,24 +338,6 @@ describe("Room", function () {
}),
];
it("Make sure legacy overload passing options directly as parameters still works", async () => {
await expect(room.addLiveEvents(events, DuplicateStrategy.Replace, false)).resolves.not.toThrow();
await expect(room.addLiveEvents(events, DuplicateStrategy.Ignore, true)).resolves.not.toThrow();
await expect(
// @ts-ignore
room.addLiveEvents(events, "shouldfailbecauseinvalidduplicatestrategy", false),
).rejects.toThrow();
});
it("should throw if duplicateStrategy isn't 'replace' or 'ignore'", async function () {
return expect(
// @ts-ignore
room.addLiveEvents(events, {
duplicateStrategy: "foo",
}),
).rejects.toThrow();
});
it("should replace a timeline event if dupe strategy is 'replace'", async function () {
// make a duplicate
const dupe = utils.mkMessage({
@@ -387,7 +368,7 @@ describe("Room", function () {
expect(room.timeline[0]).toEqual(events[0]);
// @ts-ignore
await room.addLiveEvents([dupe], {
duplicateStrategy: "ignore",
duplicateStrategy: DuplicateStrategy.Ignore,
});
expect(room.timeline[0]).toEqual(events[0]);
});
@@ -4053,4 +4034,47 @@ describe("Room", function () {
expect(room.getLastThread()).toBe(thread2);
});
});
describe("getRecommendedVersion", () => {
it("returns the server's recommended version from capabilities", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "1",
available: ["1", "2"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
});
it("force-refreshes versions to make sure an upgrade is necessary", async () => {
const client = new TestClient(userA).client;
client.getCapabilities = jest.fn().mockReturnValue({
["m.room_versions"]: {
default: "5",
available: ["5"],
},
});
client.fetchCapabilities = jest.fn().mockResolvedValue({
["m.room_versions"]: {
default: "1",
available: ["1"],
},
});
const room = new Room(roomId, client, userA);
expect(await room.getRecommendedVersion()).toEqual({
version: "1",
needsUpgrade: false,
urgent: false,
});
expect(client.fetchCapabilities).toHaveBeenCalled();
});
});
});
@@ -22,6 +22,7 @@ import {
KeysClaimRequest,
KeysQueryRequest,
KeysUploadRequest,
PutDehydratedDeviceRequest,
RoomMessageRequest,
SignatureUploadRequest,
UploadSigningKeysRequest,
@@ -233,6 +234,35 @@ describe("OutgoingRequestProcessor", () => {
httpBackend.verifyNoOutstandingRequests();
});
it("should handle PutDehydratedDeviceRequest", async () => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testReq = { foo: "bar" };
const outgoingRequest = new PutDehydratedDeviceRequest(JSON.stringify(testReq));
// ... then poke the request into the OutgoingRequestProcessor under test
const reqProm = processor.makeOutgoingRequest(outgoingRequest);
// Now: check that it makes a matching HTTP request.
const testResponse = '{"result":1}';
httpBackend
.when("PUT", "/_matrix")
.check((req) => {
expect(req.path).toEqual(
"https://example.com/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device",
);
expect(JSON.parse(req.rawData)).toEqual(testReq);
expect(req.headers["Accept"]).toEqual("application/json");
expect(req.headers["Content-Type"]).toEqual("application/json");
})
.respond(200, testResponse, true);
// PutDehydratedDeviceRequest does not need to be marked as sent, so no call to OlmMachine.markAsSent is expected.
await httpBackend.flushAllExpected();
await reqProm;
httpBackend.verifyNoOutstandingRequests();
});
it("does not explode with unknown requests", async () => {
const outgoingRequest = { id: "5678", type: 987 };
const markSentCallPromise = awaitCallToMarkAsSent();
@@ -90,7 +90,7 @@ describe("PerSessionKeyBackupDownloader", () => {
mockRustBackupManager = {
getActiveBackupVersion: jest.fn(),
requestKeyBackupVersion: jest.fn(),
getServerBackupInfo: jest.fn(),
importBackedUpRoomKeys: jest.fn(),
createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor),
on: jest.fn().mockImplementation((event, listener) => {
@@ -135,7 +135,7 @@ describe("PerSessionKeyBackupDownloader", () => {
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
});
it("Should download and import a missing key from backup", async () => {
@@ -155,8 +155,11 @@ describe("PerSessionKeyBackupDownloader", () => {
downloader.onDecryptionKeyMissingError(roomId, sessionId);
// `isKeyBackupDownloadConfigured` is false until the config is proven.
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
await expectAPICall;
await awaitKeyImported.promise;
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1);
});
@@ -313,6 +316,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should not query server if backup not active", async () => {
@@ -328,6 +334,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key is not cached", async () => {
@@ -344,6 +353,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key cached as wrong version", async () => {
@@ -363,6 +375,9 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
it("Should stop if backup key version does not match the active one", async () => {
@@ -382,13 +397,16 @@ describe("PerSessionKeyBackupDownloader", () => {
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
// isKeyBackupDownloadConfigured remains false
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
});
});
describe("Given Backup state update", () => {
it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// but at this point it's not trusted and we don't have the key
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
@@ -410,6 +428,7 @@ describe("PerSessionKeyBackupDownloader", () => {
// @ts-ignore access to private property
expect(downloader.hasConfigurationProblem).toEqual(true);
expect(downloader.isKeyBackupDownloadConfigured()).toBe(false);
// Now the backup becomes trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
@@ -423,6 +442,7 @@ describe("PerSessionKeyBackupDownloader", () => {
mockEmitter.emit(CryptoEvent.KeyBackupStatus, true);
await jest.runAllTimersAsync();
expect(downloader.isKeyBackupDownloadConfigured()).toBe(true);
await a0Imported;
await a1Imported;
@@ -434,7 +454,7 @@ describe("PerSessionKeyBackupDownloader", () => {
describe("Error cases", () => {
beforeEach(async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
mockRustBackupManager.getServerBackupInfo.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// It's trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// And we have the key in cache
+168 -10
View File
@@ -104,7 +104,7 @@ describe("initRustCrypto", () => {
} as unknown as Mocked<OlmMachine>;
}
it("passes through the store params", async () => {
it("passes through the store params (passphrase)", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
@@ -126,7 +126,30 @@ describe("initRustCrypto", () => {
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("suppresses the storePassphrase if storePrefix is unset", async () => {
it("passes through the store params (key)", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "openWithKey").mockResolvedValue(mockStore);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
const storeKey = new Uint8Array(32);
await initRustCrypto({
logger,
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storeKey: storeKey,
});
expect(StoreHandle.openWithKey).toHaveBeenCalledWith("storePrefix", storeKey);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("suppresses the storePassphrase and storeKey if storePrefix is unset", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
@@ -141,10 +164,11 @@ describe("initRustCrypto", () => {
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: null,
storeKey: new Uint8Array(),
storePassphrase: "storePassphrase",
});
expect(StoreHandle.open).toHaveBeenCalledWith(undefined, undefined);
expect(StoreHandle.open).toHaveBeenCalledWith();
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
@@ -205,7 +229,20 @@ describe("initRustCrypto", () => {
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
auth_data: {
public_key: "backup_key_public",
},
version: "45",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
});
// The cached key should be valid for the backup
const mockBackupDecryptionKey: any = {
megolmV1PublicKey: {
publicKeyBase64: "backup_key_public",
},
};
jest.spyOn(RustSdkCryptoJs.BackupDecryptionKey, "fromBase64").mockReturnValue(mockBackupDecryptionKey);
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
@@ -291,6 +328,56 @@ describe("initRustCrypto", () => {
}
}, 10000);
it("migrates data from a legacy crypto store when secret are not encrypted", async () => {
const PICKLE_KEY = "pickle1234";
const legacyStore = new MemoryCryptoStore();
// It's possible for old sessions to directly store the secrets as raw UInt8Array,
// so we need to support that in the migration code.
// See https://github.com/matrix-org/matrix-js-sdk/commit/c81f11df0afd4d0da3b088892745ae2f8ba1c4a7
async function storeSecretKeyInClear(type: string, key: Uint8Array, store: CryptoStore) {
// @ts-ignore The API to store raw UInt8Array does not exist anymore, so we need that for this test.
store.privateKeys[type as keyof SecretStorePrivateKeys] = key;
}
// Populate the legacy store with some test data
const storeSecretKey = (type: string, key: string) =>
storeSecretKeyInClear(type, new TextEncoder().encode(key), legacyStore);
await legacyStore.storeAccount({}, "not a real account");
await storeSecretKey("master", "master key");
await storeSecretKey("self_signing", "ssk");
await storeSecretKey("user_signing", "usk");
fetchMock.get("path:/_matrix/client/v3/room_keys/version", 404);
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
}
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
legacyCryptoStore: legacyStore,
legacyPickleKey: PICKLE_KEY,
legacyMigrationProgressListener,
});
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
expect(data.pickledAccount).toEqual("not a real account");
expect(data.userId!.toString()).toEqual(TEST_USER);
expect(data.deviceId!.toString()).toEqual(TEST_DEVICE_ID);
expect(atob(data.privateCrossSigningMasterKey!)).toEqual("master key");
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
});
it("handles megolm sessions with no `keysClaimed`", async () => {
const legacyStore = new MemoryCryptoStore();
legacyStore.storeAccount({}, "not a real account");
@@ -762,8 +849,11 @@ describe("RustCrypto", () => {
},
},
};
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
} else if (
request instanceof RustSdkCryptoJs.UploadSigningKeysRequest ||
request instanceof RustSdkCryptoJs.PutDehydratedDeviceRequest
) {
// These request types do not implement OutgoingRequest and do not need to be marked as sent.
return;
}
if (request.id) {
@@ -1329,14 +1419,15 @@ describe("RustCrypto", () => {
const rustCrypto = await makeTestRustCrypto();
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const backupVersion = testData.SIGNED_BACKUP_DATA.version!;
await olmMachine.enableBackupV1(
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
testData.SIGNED_BACKUP_DATA.version!,
backupVersion,
);
// we import two keys: one "from backup", and one "from export"
const [backedUpRoomKey, exportedRoomKey] = testData.MEGOLM_SESSION_DATA_ARRAY;
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey]);
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey], backupVersion);
await rustCrypto.importRoomKeys([exportedRoomKey]);
// we ask for the keys that should be backed up
@@ -1371,16 +1462,17 @@ describe("RustCrypto", () => {
const rustCrypto = await makeTestRustCrypto();
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const backupVersion = testData.SIGNED_BACKUP_DATA.version!;
await olmMachine.enableBackupV1(
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
testData.SIGNED_BACKUP_DATA.version!,
backupVersion,
);
const backup = Array.from(testData.MEGOLM_SESSION_DATA_ARRAY);
// in addition to correct keys, we restore an invalid key
backup.push({ room_id: "!roomid", session_id: "sessionid" } as IMegolmSessionData);
const progressCallback = jest.fn();
await rustCrypto.importBackedUpRoomKeys(backup, { progressCallback });
await rustCrypto.importBackedUpRoomKeys(backup, backupVersion, { progressCallback });
expect(progressCallback).toHaveBeenCalledWith({
total: 3,
successes: 0,
@@ -1395,6 +1487,72 @@ describe("RustCrypto", () => {
});
});
});
describe("device dehydration", () => {
it("should detect if dehydration is supported", async () => {
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi());
fetchMock.config.overwriteRoutes = true;
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_UNRECOGNIZED",
error: "Unknown endpoint",
},
});
expect(await rustCrypto.isDehydrationSupported()).toBe(false);
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "Not found",
},
});
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
fetchMock.get("path:/_matrix/client/unstable/org.matrix.msc3814.v1/dehydrated_device", {
device_id: "DEVICE_ID",
device_data: "data",
});
expect(await rustCrypto.isDehydrationSupported()).toBe(true);
});
});
describe("import & export secrets bundle", () => {
let rustCrypto: RustCrypto;
beforeEach(async () => {
rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
}),
testData.TEST_USER_ID,
);
});
it("should throw an error if there is nothing to export", async () => {
await expect(rustCrypto.exportSecretsBundle()).rejects.toThrow(
"The store doesn't contain any cross-signing keys",
);
});
it("should correctly import & export a secrets bundle", async () => {
const bundle = {
cross_signing: {
master_key: "bMnVpkHI4S2wXRxy+IpaKM5PIAUUkl6DE+n0YLIW/qs",
user_signing_key: "8tlgLjUrrb/zGJo4YKGhDTIDCEjtJTAS/Sh2AGNLuIo",
self_signing_key: "pfDknmP5a0fVVRE54zhkUgJfzbNmvKcNfIWEW796bQs",
},
backup: {
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
key: "bYYv3aFLQ49jMNcOjuTtBY9EKDby2x1m3gfX81nIKRQ",
backup_version: "9",
},
};
await rustCrypto.importSecretsBundle(bundle);
await expect(rustCrypto.exportSecretsBundle()).resolves.toEqual(expect.objectContaining(bundle));
});
});
});
/** Build a MatrixHttpApi instance */
-5
View File
@@ -147,11 +147,6 @@ export interface LoginRequest {
* The login type being used.
*/
type: "m.login.password" | "m.login.token" | string;
/**
* Third-party identifier for the user.
* @deprecated in favour of `identifier`.
*/
address?: string;
/**
* ID of the client device.
* If this does not correspond to a known client device, a new device will be created.
+10 -5
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { UnstableValue } from "../NamespacedValue";
import { NamespacedValue, UnstableValue } from "../NamespacedValue";
import {
PolicyRuleEventContent,
RoomAvatarEventContent,
@@ -56,8 +56,8 @@ import {
SDPStreamMetadataKey,
} from "../webrtc/callEventTypes";
import { EncryptionKeysEventContent, ICallNotifyContent } from "../matrixrtc/types";
import { EncryptedFile } from "./media";
import { M_POLL_END, M_POLL_START, PollEndEventContent, PollStartEventContent } from "./polls";
import { SessionMembershipData } from "../matrixrtc/CallMembership";
export enum EventType {
// Room state events
@@ -299,9 +299,11 @@ export const LOCAL_NOTIFICATION_SETTINGS_PREFIX = new UnstableValue(
export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matrix.msc4023.thread_id");
/**
* @deprecated in favour of {@link EncryptedFile}
* https://github.com/matrix-org/matrix-spec-proposals/pull/4115
*
* @experimental
*/
export type IEncryptedFile = EncryptedFile;
export const UNSIGNED_MEMBERSHIP_FIELD = new NamespacedValue("membership", "io.element.msc4115.membership");
/**
* Mapped type from event type to content type for all specified non-state room events.
@@ -355,7 +357,10 @@ export interface StateEvents {
// MSC3401
[EventType.GroupCallPrefix]: IGroupCallRoomState;
[EventType.GroupCallMemberPrefix]: XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>;
[EventType.GroupCallMemberPrefix]: XOR<
XOR<IGroupCallRoomMemberState, ExperimentalGroupCallRoomMemberState>,
XOR<SessionMembershipData, {}>
>;
// MSC3089
[UNSTABLE_MSC3089_BRANCH.name]: MSC3089EventContent;
+44
View File
@@ -0,0 +1,44 @@
/*
Copyright 2024 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 type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
declare module "@matrix-org/matrix-sdk-crypto-wasm" {
interface OlmMachine {
importSecretsBundle(bundle: RustSdkCryptoJs.SecretsBundle): Promise<void>;
exportSecretsBundle(): Promise<RustSdkCryptoJs.SecretsBundle>;
}
interface SecretsBundle {
// eslint-disable-next-line @typescript-eslint/naming-convention
to_json(): Promise<{
cross_signing: {
master_key: string;
self_signing_key: string;
user_signing_key: string;
};
backup?: {
algorithm: string;
key: string;
backup_version: string;
};
}>;
}
interface Device {
requestVerification(methods?: any[]): [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest];
}
}
+24
View File
@@ -0,0 +1,24 @@
/*
Copyright 2024 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 "oidc-client-ts";
declare module "oidc-client-ts" {
interface OidcMetadata {
// Add the missing device_authorization_endpoint field to the OidcMetadata interface
device_authorization_endpoint?: string;
}
}
-8
View File
@@ -45,14 +45,6 @@ export enum RestrictedAllowType {
RoomMembership = "m.room_membership",
}
export interface IJoinRuleEventContent {
join_rule: JoinRule; // eslint-disable-line camelcase
allow?: {
type: RestrictedAllowType;
room_id: string; // eslint-disable-line camelcase
}[];
}
export enum GuestAccess {
CanJoin = "can_join",
Forbidden = "forbidden",
+5 -19
View File
@@ -52,21 +52,13 @@ export interface RegisterRequest {
*/
initial_device_display_name?: string;
/**
* @deprecated missing in the spec
* Guest users can also upgrade their account by going through the ordinary register flow,
* but specifying the additional POST parameter guest_access_token containing the guests access token.
* They are also required to specify the username parameter to the value of the local part of their username,
* which is otherwise optional.
* @see https://spec.matrix.org/v1.10/client-server-api/#guest-access
*/
guest_access_token?: string;
/**
* @deprecated missing in the spec
*/
x_show_msisdn?: boolean;
/**
* @deprecated missing in the spec
*/
bind_msisdn?: boolean;
/**
* @deprecated missing in the spec
*/
bind_email?: boolean;
}
/**
@@ -107,10 +99,4 @@ export interface RegisterResponse {
* Omitted if the inhibit_login option is true.
*/
refresh_token?: string;
/**
* The server_name of the homeserver on which the account has been registered.
*
* @deprecated Clients should extract the server_name from user_id (by splitting at the first colon) if they require it.
*/
home_server?: string;
}
+3 -3
View File
@@ -54,9 +54,9 @@ enum GroupKey {
}
export interface IResultRoomEvents {
count: number;
highlights: string[];
results: ISearchResult[];
count?: number;
highlights?: string[];
results?: ISearchResult[];
state?: { [roomId: string]: IStateEventWithRoomId[] };
groups?: {
[groupKey in GroupKey]: {
+5 -2
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { RoomType } from "./event";
import { GuestAccess, HistoryVisibility, RestrictedAllowType } from "./partials";
import { GuestAccess, HistoryVisibility, JoinRule, RestrictedAllowType } from "./partials";
import { ImageInfo } from "./media";
import { PolicyRecommendation } from "../models/invites-ignorer";
@@ -36,6 +36,7 @@ export interface RoomCreateEventContent {
}
export interface RoomJoinRulesEventContent {
join_rule: JoinRule;
allow?: {
room_id: string;
type: RestrictedAllowType;
@@ -94,7 +95,9 @@ export interface RoomTopicEventContent {
export interface RoomAvatarEventContent {
url?: string;
info?: ImageInfo;
// The spec says that an encrypted file can be used for the thumbnail but this isn't true
// https://github.com/matrix-org/matrix-spec/issues/562 so omit those fields
info?: Omit<ImageInfo, "thumbnail_file">;
}
export interface RoomPinnedEventsEventContent {
-5
View File
@@ -43,8 +43,6 @@ export enum AutoDiscoveryError {
InvalidJson = "Invalid JSON",
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
/** @deprecated Replaced by `UnsupportedHomeserverSpecVersion` */
HomeserverTooOld = UnsupportedHomeserverSpecVersion,
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
}
@@ -91,9 +89,6 @@ export class AutoDiscovery {
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
/** @deprecated Replaced by ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION */
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
/**
+2 -2
View File
@@ -59,7 +59,7 @@ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): stri
* @returns The unpadded base64.
*/
export function encodeUnpaddedBase64Url(uint8Array: ArrayBuffer | Uint8Array): string {
return encodeUnpaddedBase64(uint8Array).replace("+", "-").replace("/", "_");
return encodeUnpaddedBase64(uint8Array).replace(/\+/g, "-").replace(/\//g, "_");
}
/**
@@ -75,7 +75,7 @@ export function decodeBase64(base64: string): Uint8Array {
const itFunc = function* (): Generator<number> {
const decoded = atob(
// built-in atob doesn't support base64url: convert so we support either
base64.replace("-", "+").replace("_", "/"),
base64.replace(/-/g, "+").replace(/_/g, "/"),
);
for (let i = 0; i < decoded.length; ++i) {
yield decoded.charCodeAt(i);
+138 -151
View File
@@ -226,6 +226,7 @@ import { getRelationsThreadFilter } from "./thread-utils";
import { KnownMembership, Membership } from "./@types/membership";
import { RoomMessageEventContent, StickerEventContent } from "./@types/events";
import { ImageInfo } from "./@types/media";
import { Capabilities, ServerCapabilities } from "./serverCapabilities";
export type Store = IStore;
@@ -233,7 +234,6 @@ export type ResetTimelineCallback = (roomId: string) => boolean;
const SCROLLBACK_DELAY_MS = 3000;
export const CRYPTO_ENABLED: boolean = isCryptoAvailable();
const CAPABILITIES_CACHE_MS = 21600000; // 6 hours - an arbitrary value
const TURN_CHECK_INTERVAL = 10 * 60 * 1000; // poll for turn credentials every 10 minutes
export const UNSTABLE_MSC3852_LAST_SEEN_UA = new UnstableValue(
@@ -357,14 +357,14 @@ export interface ICreateClientOpts {
deviceToImport?: IExportedDevice;
/**
* Encryption key used for encrypting sensitive data (such as e2ee keys) in storage.
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
*
* This must be set to the same value every time the client is initialised for the same device.
*
* If unset, either a hardcoded key or no encryption at all is used, depending on the Crypto implementation.
*
* No particular requirement is placed on the key data (it is fed into an HKDF to generate the actual encryption
* keys).
* This is only used for the legacy crypto implementation (as used by {@link MatrixClient#initCrypto}),
* but if you use the rust crypto implementation ({@link MatrixClient#initRustCrypto}) and the device
* previously used legacy crypto (so must be migrated), then this must still be provided, so that the
* data can be migrated from the legacy store.
*/
pickleKey?: string;
@@ -518,26 +518,6 @@ export interface IStartClientOpts {
export interface IStoredClientOpts extends IStartClientOpts {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export const GET_LOGIN_TOKEN_CAPABILITY = new NamespacedValue(
"m.get_login_token",
"org.matrix.msc3882.get_login_token",
@@ -547,29 +527,13 @@ export const UNSTABLE_MSC2666_SHARED_ROOMS = "uk.half-shot.msc2666";
export const UNSTABLE_MSC2666_MUTUAL_ROOMS = "uk.half-shot.msc2666.mutual_rooms";
export const UNSTABLE_MSC2666_QUERY_MUTUAL_ROOMS = "uk.half-shot.msc2666.query_mutual_rooms";
/**
* A representation of the capabilities advertised by a homeserver as defined by
* [Capabilities negotiation](https://spec.matrix.org/v1.6/client-server-api/#get_matrixclientv3capabilities).
*/
export interface Capabilities {
[key: string]: any;
"m.change_password"?: IChangePasswordCapability;
"m.room_versions"?: IRoomVersionsCapability;
"io.element.thread"?: IThreadsCapability;
"m.get_login_token"?: IGetLoginTokenCapability;
"org.matrix.msc3882.get_login_token"?: IGetLoginTokenCapability;
}
/** @deprecated prefer {@link CrossSigningKeyInfo}. */
export type ICrossSigningKey = CrossSigningKeyInfo;
enum CrossSigningKeyType {
MasterKey = "master_key",
SelfSigningKey = "self_signing_key",
UserSigningKey = "user_signing_key",
}
export type CrossSigningKeys = Record<CrossSigningKeyType, ICrossSigningKey>;
export type CrossSigningKeys = Record<CrossSigningKeyType, CrossSigningKeyInfo>;
export type SendToDeviceContentMap = Map<string, Map<string, Record<string, any>>>;
@@ -581,7 +545,7 @@ export interface ISignedKey {
device_id: string;
}
export type KeySignatures = Record<string, Record<string, ICrossSigningKey | ISignedKey>>;
export type KeySignatures = Record<string, Record<string, CrossSigningKeyInfo | ISignedKey>>;
export interface IUploadKeySignaturesResponse {
failures: Record<
string,
@@ -865,10 +829,24 @@ interface IThirdPartyUser {
fields: object;
}
interface IRoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
room_type?: RoomType;
membership?: Membership;
is_encrypted: boolean;
/**
* The summary of a room as defined by an initial version of MSC3266 and implemented in Synapse
* Proposed at https://github.com/matrix-org/matrix-doc/pull/3266
*/
export interface RoomSummary extends Omit<IPublicRoomsChunkRoom, "canonical_alias" | "aliases"> {
/**
* The current membership of this user in the room.
* Usually "leave" if the room is fetched over federation.
*/
"membership"?: Membership;
/**
* Version of the room.
*/
"im.nheko.summary.room_version"?: string;
/**
* The encryption algorithm used for this room, if the room is encrypted.
*/
"im.nheko.summary.encryption"?: string;
}
interface IRoomKeysResponse {
@@ -1282,10 +1260,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// TODO: This should expire: https://github.com/matrix-org/matrix-js-sdk/issues/1020
protected serverVersionsPromise?: Promise<IServerVersions>;
public cachedCapabilities?: {
capabilities: Capabilities;
expiration: number;
};
protected clientWellKnown?: IClientWellKnown;
protected clientWellKnownPromise?: Promise<IClientWellKnown>;
protected turnServers: ITurnServer[] = [];
@@ -1314,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public readonly matrixRTC: MatrixRTCSessionManager;
private serverCapabilitiesService: ServerCapabilities;
public constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1407,6 +1383,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this);
this.serverCapabilitiesService = new ServerCapabilities(this.http);
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
this.timelineSupport = Boolean(opts.timelineSupport);
@@ -1469,13 +1447,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.on(ClientEvent.Sync, this.startMatrixRTC);
// backwards compat for when 'opts' was 'historyLen'.
if (typeof opts === "number") {
opts = {
initialSyncLimit: opts,
};
}
// Create our own user object artificially (instead of waiting for sync)
// so it's always available, even if the user is not in any rooms etc.
const userId = this.getUserId();
@@ -1536,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
this.toDeviceMessageQueue.start();
this.serverCapabilitiesService.start();
}
/**
@@ -1589,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.stop();
this.matrixRTC.stop();
this.serverCapabilitiesService.stop();
}
/**
@@ -2091,47 +2065,35 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* Gets the capabilities of the homeserver. Always returns an object of
* capability keys and their options, which may be empty.
* @param fresh - True to ignore any cached values.
* @returns Promise which resolves to the capabilities of the homeserver
* @returns Rejects: with an error response.
* Gets the cached capabilities of the homeserver, returning cached ones if available.
* If there are no cached capabilities and none can be fetched, throw an exception.
*
* @returns Promise resolving with The capabilities of the homeserver
*/
public getCapabilities(fresh = false): Promise<Capabilities> {
const now = new Date().getTime();
public async getCapabilities(): Promise<Capabilities> {
const caps = this.serverCapabilitiesService.getCachedCapabilities();
if (caps) return caps;
return this.serverCapabilitiesService.fetchCapabilities();
}
if (this.cachedCapabilities && !fresh) {
if (now < this.cachedCapabilities.expiration) {
this.logger.debug("Returning cached capabilities");
return Promise.resolve(this.cachedCapabilities.capabilities);
}
}
/**
* Gets the cached capabilities of the homeserver. If none have been fetched yet,
* return undefined.
*
* @returns The capabilities of the homeserver
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.serverCapabilitiesService.getCachedCapabilities();
}
type Response = {
capabilities?: Capabilities;
};
return this.http
.authedRequest<Response>(Method.Get, "/capabilities")
.catch((e: Error): Response => {
// We swallow errors because we need a default object anyhow
this.logger.error(e);
return {};
})
.then((r = {}) => {
const capabilities = r["capabilities"] || {};
// If the capabilities missed the cache, cache it for a shorter amount
// of time to try and refresh them later.
const cacheMs = Object.keys(capabilities).length ? CAPABILITIES_CACHE_MS : 60000 + Math.random() * 5000;
this.cachedCapabilities = {
capabilities,
expiration: now + cacheMs,
};
this.logger.debug("Caching capabilities: ", capabilities);
return capabilities;
});
/**
* Fetches the latest capabilities from the homeserver, ignoring any cached
* versions. The newly returned version is cached.
*
* @returns A promise which resolves to the capabilities of the homeserver
*/
public fetchCapabilities(): Promise<Capabilities> {
return this.serverCapabilitiesService.fetchCapabilities();
}
/**
@@ -2218,17 +2180,24 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* An alternative to {@link initCrypto}.
*
* *WARNING*: this API is very experimental, should not be used in production, and may change without notice!
* Eventually it will be deprecated and `initCrypto` will do the same thing.
*
* @experimental
*
* @param useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
* @param args.storagePassword - An alternative to `storageKey`. A password which will be used to derive a key to
* encrypt the store with. Deriving a key from a password is (deliberately) a slow operation, so prefer
* to pass a `storageKey` directly where possible.
*
* @returns a Promise which will resolve when the crypto layer has been
* successfully initialised.
*/
public async initRustCrypto({ useIndexedDB = true }: { useIndexedDB?: boolean } = {}): Promise<void> {
public async initRustCrypto(
args: {
useIndexedDB?: boolean;
storageKey?: Uint8Array;
storagePassword?: string;
} = {},
): Promise<void> {
if (this.cryptoBackend) {
this.logger.warn("Attempt to re-initialise e2e encryption on MatrixClient");
return;
@@ -2261,11 +2230,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
deviceId: deviceId,
secretStorage: this.secretStorage,
cryptoCallbacks: this.cryptoCallbacks,
storePrefix: useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
storePassphrase: this.pickleKey,
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
storeKey: args.storageKey,
// temporary compatibility hack: if there is no storageKey nor storagePassword, fall back to the pickleKey
storePassphrase: args.storagePassword ?? this.pickleKey,
legacyCryptoStore: this.cryptoStore,
legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY",
legacyMigrationProgressListener: (progress, total) => {
legacyMigrationProgressListener: (progress: number, total: number): void => {
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
},
});
@@ -2884,7 +2857,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @param event - event to be checked
* @returns The event information.
* @deprecated Prefer {@link CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}.
* @deprecated Prefer {@link Crypto.CryptoApi.getEncryptionInfoForEvent | `CryptoApi.getEncryptionInfoForEvent`}.
*/
public getEventEncryptionInfo(event: MatrixEvent): IEncryptedEventInfo {
if (!this.cryptoBackend) {
@@ -3302,7 +3275,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* trust information (as returned by isKeyBackupTrusted)
* in trustInfo.
*
* @deprecated Prefer {@link CryptoApi.checkKeyBackupAndEnable}.
* @deprecated Prefer {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
*/
public checkKeyBackup(): Promise<IKeyBackupCheck | null> {
if (!this.crypto) {
@@ -3359,7 +3332,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* the server, otherwise false. If we haven't completed a successful check
* of key backup status yet, returns null.
*
* @deprecated Prefer direct access to {@link CryptoApi.getActiveSessionBackupVersion}:
* @deprecated Prefer direct access to {@link Crypto.CryptoApi.getActiveSessionBackupVersion}:
*
* ```javascript
* let enabled = (await client.getCrypto().getActiveSessionBackupVersion()) !== null;
@@ -3379,7 +3352,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param info - Backup information object as returned by getKeyBackupVersion
* @returns Promise which resolves when complete.
*
* @deprecated Do not call this directly. Instead call {@link CryptoApi.checkKeyBackupAndEnable}.
* @deprecated Do not call this directly. Instead call {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
*/
public enableKeyBackup(info: IKeyBackupInfo): Promise<void> {
if (!this.crypto) {
@@ -3841,12 +3814,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (!backupInfo.version) {
throw new Error("Backup version must be defined");
}
const backupVersion = backupInfo.version!;
let totalKeyCount = 0;
let totalFailures = 0;
let totalImported = 0;
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupVersion);
const backupDecryptor = await this.cryptoBackend.getBackupDecryptor(backupInfo, privKey);
@@ -3860,7 +3834,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Cache the key, if possible.
// This is async.
this.cryptoBackend
.storeSessionBackupPrivateKey(privKey, backupInfo.version)
.storeSessionBackupPrivateKey(privKey, backupVersion)
.catch((e) => {
this.logger.warn("Error caching session backup key:", e);
})
@@ -3900,7 +3874,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
async (chunk) => {
// We have a chunk of decrypted keys: import them
try {
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, {
const backupVersion = backupInfo.version!;
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, backupVersion, {
untrusted,
});
totalImported += chunk.length;
@@ -3930,7 +3905,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
for (const k of keys) {
k.room_id = targetRoomId!;
}
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
await this.cryptoBackend.importBackedUpRoomKeys(keys, backupVersion, {
progressCallback,
untrusted,
});
@@ -3944,7 +3919,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
key.room_id = targetRoomId!;
key.session_id = targetSessionId!;
await this.cryptoBackend.importBackedUpRoomKeys([key], {
await this.cryptoBackend.importBackedUpRoomKeys([key], backupVersion, {
progressCallback,
untrusted,
});
@@ -4302,9 +4277,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
signPromise = this.http.requestOtherUrl<IThirdPartySigned>(Method.Post, url);
}
const queryString: Record<string, string | string[]> = {};
let queryParams: QueryDict = {};
if (opts.viaServers) {
queryString["server_name"] = opts.viaServers;
queryParams.server_name = opts.viaServers;
queryParams.via = opts.viaServers;
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
}
}
const data: IJoinRequestBody = {};
@@ -4314,7 +4293,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const path = utils.encodeUri("/join/$roomid", { $roomid: roomIdOrAlias });
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryString, data);
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
const roomId = res.room_id;
// In case we were originally given an alias, check the room cache again
@@ -4347,9 +4326,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const path = utils.encodeUri("/knock/$roomIdOrAlias", { $roomIdOrAlias: roomIdOrAlias });
const queryParams: Record<string, string | string[]> = {};
let queryParams: QueryDict = {};
if (opts.viaServers) {
queryParams.server_name = opts.viaServers;
queryParams.via = opts.viaServers;
if (this.canSupport.get(Feature.MigrateServerNameToVia) === ServerSupport.Unstable) {
queryParams = replaceParam("via", "org.matrix.msc4156.via", queryParams);
}
}
const body: Record<string, string> = {};
@@ -4862,10 +4845,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
pathTemplate = "/rooms/$roomId/state/$eventType/$stateKey";
}
path = utils.encodeUri(pathTemplate, pathParams);
} else if (event.isRedaction()) {
} else if (event.isRedaction() && event.event.redacts) {
const pathTemplate = `/rooms/$roomId/redact/$redactsEventId/$txnId`;
path = utils.encodeUri(pathTemplate, {
$redactsEventId: event.event.redacts!,
$redactsEventId: event.event.redacts,
...pathParams,
});
} else {
@@ -5761,7 +5744,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* anyone they share a room with. If false, will return null for such URLs.
* @param allowRedirects - If true, the caller supports the URL being 307 or
* 308 redirected to another resource upon request. If false, redirects
* are not expected.
* are not expected. Implied `true` when `useAuthentication` is `true`.
* @param useAuthentication - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @returns the avatar URL or null.
*/
public mxcUrlToHttp(
@@ -5771,8 +5759,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
resizeMethod?: string,
allowDirectLinks?: boolean,
allowRedirects?: boolean,
useAuthentication?: boolean,
): string | null {
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects);
return getHttpUriForMxc(
this.baseUrl,
mxcUrl,
width,
height,
resizeMethod,
allowDirectLinks,
allowRedirects,
useAuthentication,
);
}
/**
@@ -7077,7 +7075,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// append the new results to our existing results
const resultsLength = roomEvents.results?.length ?? 0;
for (let i = 0; i < resultsLength; i++) {
const sr = SearchResult.fromJson(roomEvents.results[i], mapper);
const sr = SearchResult.fromJson(roomEvents.results![i], mapper);
const room = this.getRoom(sr.context.getEvent().getRoomId());
if (room) {
// Copy over a known event sender if we can
@@ -7862,16 +7860,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
password: string,
sessionId: string | null,
auth: { session?: string; type: string },
bindThreepids?: boolean | null | { email?: boolean; msisdn?: boolean },
bindThreepids?: { email?: boolean; msisdn?: boolean },
guestAccessToken?: string,
inhibitLogin?: boolean,
): Promise<RegisterResponse> {
// backwards compat
if (bindThreepids === true) {
bindThreepids = { email: true };
} else if (bindThreepids === null || bindThreepids === undefined || bindThreepids === false) {
bindThreepids = {};
}
if (sessionId) {
auth.session = sessionId;
}
@@ -7886,27 +7878,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
if (password !== undefined && password !== null) {
params.password = password;
}
if (bindThreepids.email) {
params.bind_email = true;
}
if (bindThreepids.msisdn) {
params.bind_msisdn = true;
}
if (guestAccessToken !== undefined && guestAccessToken !== null) {
params.guest_access_token = guestAccessToken;
}
if (inhibitLogin !== undefined && inhibitLogin !== null) {
params.inhibit_login = inhibitLogin;
}
// Temporary parameter added to make the register endpoint advertise
// msisdn flows. This exists because there are clients that break
// when given stages they don't recognise. This parameter will cease
// to be necessary once these old clients are gone.
// Only send it if we send any params at all (the password param is
// mandatory, so if we send any params, we'll send the password param)
if (password !== undefined && password !== null) {
params.x_show_msisdn = true;
}
return this.registerRequest(params);
}
@@ -9786,11 +9763,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomIdOrAlias - The ID or alias of the room to get the summary of.
* @param via - The list of servers which know about the room if only an ID was provided.
*/
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<IRoomSummary> {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return this.http.authedRequest(Method.Get, path, { via }, undefined, {
public async getRoomSummary(roomIdOrAlias: string, via?: string[]): Promise<RoomSummary> {
const paramOpts = {
prefix: "/_matrix/client/unstable/im.nheko.summary",
});
};
try {
const path = utils.encodeUri("/summary/$roomid", { $roomid: roomIdOrAlias });
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_UNRECOGNIZED") {
const path = utils.encodeUri("/rooms/$roomid/summary", { $roomid: roomIdOrAlias });
return await this.http.authedRequest(Method.Get, path, { via }, undefined, paramOpts);
} else {
throw e;
}
}
}
/**
+2 -1
View File
@@ -113,10 +113,11 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* Import a list of room keys restored from backup
*
* @param keys - a list of session export objects
* @param backupVersion - the version of the backup these keys came from.
* @param opts - options object
* @returns a promise which resolves once the keys have been imported
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
+27 -3
View File
@@ -30,7 +30,12 @@ import { encodeParams } from "./utils";
* for such URLs.
* @param allowRedirects - If true, the caller supports the URL being 307 or
* 308 redirected to another resource upon request. If false, redirects
* are not expected.
* are not expected. Implied `true` when `useAuthentication` is `true`.
* @param useAuthentication - If true, the caller supports authenticated
* media and wants an authentication-required URL. Note that server support
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @returns The complete URL to the content.
*/
export function getHttpUriForMxc(
@@ -41,6 +46,7 @@ export function getHttpUriForMxc(
resizeMethod?: string,
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -52,8 +58,22 @@ export function getHttpUriForMxc(
return "";
}
}
if (useAuthentication) {
allowRedirects = true; // per docs (MSC3916 always expects redirects)
// Dev note: MSC3916 removes `allow_redirect` entirely, but
// for explicitness we set it here. This makes it slightly more obvious to
// callers, hopefully.
}
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix = "/_matrix/media/v3/download/";
let prefix: string;
if (useAuthentication) {
prefix = "/_matrix/client/v1/media/download/";
} else {
prefix = "/_matrix/media/v3/download/";
}
const params: Record<string, string> = {};
if (width) {
@@ -68,7 +88,11 @@ export function getHttpUriForMxc(
if (Object.keys(params).length > 0) {
// these are thumbnailing params so they probably want the
// thumbnailing API...
prefix = "/_matrix/media/v3/thumbnail/";
if (useAuthentication) {
prefix = "/_matrix/client/v1/media/thumbnail/";
} else {
prefix = "/_matrix/media/v3/thumbnail/";
}
}
if (typeof allowRedirects === "boolean") {
+77
View File
@@ -14,6 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
@@ -496,6 +497,59 @@ export interface CryptoApi {
* @param version - The backup version to delete.
*/
deleteKeyBackupVersion(version: string): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Dehydrated devices
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Returns whether MSC3814 dehydrated devices are supported by the crypto
* backend and by the server.
*
* This should be called before calling `startDehydration`, and if this
* returns `false`, `startDehydration` should not be called.
*/
isDehydrationSupported(): Promise<boolean>;
/**
* Start using device dehydration.
*
* - Rehydrates a dehydrated device, if one is available.
* - Creates a new dehydration key, if necessary, and stores it in Secret
* Storage.
* - If `createNewKey` is set to true, always creates a new key.
* - If a dehydration key is not available, creates a new one.
* - Creates a new dehydrated device, and schedules periodically creating
* new dehydrated devices.
*
* This function must not be called unless `isDehydrationSupported` returns
* `true`, and must not be called until after cross-signing and secret
* storage have been set up.
*
* @param createNewKey - whether to force creation of a new dehydration key.
* This can be used, for example, if Secret Storage is being reset. Defaults
* to false.
*/
startDehydration(createNewKey?: boolean): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Import/export of secret keys
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Export secrets bundle for transmitting to another device as part of OIDC QR login
*/
exportSecretsBundle?(): Promise<Awaited<ReturnType<SecretsBundle["to_json"]>>>;
/**
* Import secrets bundle transmitted from another device.
* @param secrets - The secrets bundle received from the other device
*/
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
}
/** A reason code for a failure to decrypt an event. */
@@ -506,6 +560,29 @@ export enum DecryptionFailureCode {
/** Message was encrypted with a Megolm session which has been shared with us, but in a later ratchet state. */
OLM_UNKNOWN_MESSAGE_INDEX = "OLM_UNKNOWN_MESSAGE_INDEX",
/**
* Message was sent before the current device was created; there is no key backup on the server, so this
* decryption failure is expected.
*/
HISTORICAL_MESSAGE_NO_KEY_BACKUP = "HISTORICAL_MESSAGE_NO_KEY_BACKUP",
/**
* Message was sent before the current device was created; there was a key backup on the server, but we don't
* seem to have access to the backup. (Probably we don't have the right key.)
*/
HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED = "HISTORICAL_MESSAGE_BACKUP_UNCONFIGURED",
/**
* Message was sent before the current device was created; there was a (usable) key backup on the server, but we
* still can't decrypt. (Either the session isn't in the backup, or we just haven't gotten around to checking yet.)
*/
HISTORICAL_MESSAGE_WORKING_BACKUP = "HISTORICAL_MESSAGE_WORKING_BACKUP",
/**
* Message was sent when the user was not a member of the room.
*/
HISTORICAL_MESSAGE_USER_NOT_JOINED = "HISTORICAL_MESSAGE_USER_NOT_JOINED",
/** Unknown or unclassified error. */
UNKNOWN_ERROR = "UNKNOWN_ERROR",
+1 -1
View File
@@ -63,7 +63,7 @@ export interface BackupTrustInfo {
}
/**
* The result of {@link CryptoApi.checkKeyBackupAndEnable}.
* The result of {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
*/
export interface KeyBackupCheck {
backupInfo: KeyBackupInfo;
+9 -9
View File
@@ -24,13 +24,13 @@ import { logger } from "../logger";
import { IndexedDBCryptoStore } from "../crypto/store/indexeddb-crypto-store";
import { decryptAES, encryptAES } from "./aes";
import { DeviceInfo } from "./deviceinfo";
import { ICrossSigningKey, ISignedKey, MatrixClient } from "../client";
import { ISignedKey, MatrixClient } from "../client";
import { OlmDevice } from "./OlmDevice";
import { ICryptoCallbacks } from ".";
import { ISignatures } from "../@types/signed";
import { CryptoStore, SecretStorePrivateKeys } from "./store/base";
import { ServerSideSecretStorage, SecretStorageKeyDescription } from "../secret-storage";
import { DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
import { CrossSigningKeyInfo, DeviceVerificationStatus, UserVerificationStatus as UserTrustLevel } from "../crypto-api";
import { decodeBase64, encodeBase64 } from "../base64";
// backwards-compatibility re-exports
@@ -38,7 +38,7 @@ export { UserTrustLevel };
const KEY_REQUEST_TIMEOUT_MS = 1000 * 60;
function publicKeyFromKeyInfo(keyInfo: ICrossSigningKey): string {
function publicKeyFromKeyInfo(keyInfo: CrossSigningKeyInfo): string {
// `keys` is an object with { [`ed25519:${pubKey}`]: pubKey }
// We assume only a single key, and we want the bare form without type
// prefix, so we select the values.
@@ -51,13 +51,13 @@ export interface ICacheCallbacks {
}
export interface ICrossSigningInfo {
keys: Record<string, ICrossSigningKey>;
keys: Record<string, CrossSigningKeyInfo>;
firstUse: boolean;
crossSigningVerifiedBefore: boolean;
}
export class CrossSigningInfo {
public keys: Record<string, ICrossSigningKey> = {};
public keys: Record<string, CrossSigningKeyInfo> = {};
public firstUse = true;
// This tracks whether we've ever verified this user with any identity.
// When you verify a user, any devices online at the time that receive
@@ -296,7 +296,7 @@ export class CrossSigningInfo {
}
const privateKeys: Record<string, Uint8Array> = {};
const keys: Record<string, ICrossSigningKey> = {};
const keys: Record<string, CrossSigningKeyInfo> = {};
let masterSigning: PkSigning | undefined;
let masterPub: string | undefined;
@@ -368,8 +368,8 @@ export class CrossSigningInfo {
this.keys = {};
}
public setKeys(keys: Record<string, ICrossSigningKey>): void {
const signingKeys: Record<string, ICrossSigningKey> = {};
public setKeys(keys: Record<string, CrossSigningKeyInfo>): void {
const signingKeys: Record<string, CrossSigningKeyInfo> = {};
if (keys.master) {
if (keys.master.user_id !== this.userId) {
const error = "Mismatched user ID " + keys.master.user_id + " in master key from " + this.userId;
@@ -457,7 +457,7 @@ export class CrossSigningInfo {
}
}
public async signUser(key: CrossSigningInfo): Promise<ICrossSigningKey | undefined> {
public async signUser(key: CrossSigningInfo): Promise<CrossSigningKeyInfo | undefined> {
if (!this.keys.user_signing) {
logger.info("No user signing key: not signing user");
return;
+3 -10
View File
@@ -20,22 +20,15 @@ import { createCryptoStoreCacheCallbacks, ICacheCallbacks } from "./CrossSigning
import { IndexedDBCryptoStore } from "./store/indexeddb-crypto-store";
import { Method, ClientPrefix } from "../http-api";
import { Crypto, ICryptoCallbacks } from "./index";
import {
ClientEvent,
ClientEventHandlerMap,
CrossSigningKeys,
ICrossSigningKey,
ISignedKey,
KeySignatures,
} from "../client";
import { ClientEvent, ClientEventHandlerMap, CrossSigningKeys, ISignedKey, KeySignatures } from "../client";
import { IKeyBackupInfo } from "./keybackup";
import { TypedEventEmitter } from "../models/typed-event-emitter";
import { AccountDataClient, SecretStorageKeyDescription } from "../secret-storage";
import { BootstrapCrossSigningOpts } from "../crypto-api";
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo } from "../crypto-api";
interface ICrossSigningKeys {
authUpload: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"];
keys: Record<"master" | "self_signing" | "user_signing", ICrossSigningKey>;
keys: Record<"master" | "self_signing" | "user_signing", CrossSigningKeyInfo>;
}
/**
+3
View File
@@ -871,6 +871,9 @@ export const algorithmsByName: Record<string, BackupAlgorithmClass> = {
[Aes256.algorithmName]: Aes256,
};
// the linter doesn't like this but knip does
// eslint-disable-next-line tsdoc/syntax
/** @alias */
export const DefaultAlgorithm: BackupAlgorithmClass = Curve25519;
/**
+3 -3
View File
@@ -16,9 +16,9 @@ limitations under the License.
import { logger } from "../logger";
export let crypto = globalThis.window?.crypto;
export let subtleCrypto = globalThis.window?.crypto?.subtle ?? global.window?.crypto?.webkitSubtle;
export let TextEncoder = globalThis.window?.TextEncoder;
export let crypto = globalThis.crypto;
export let subtleCrypto = crypto?.subtle ?? crypto?.webkitSubtle; // TODO: Stop using webkitSubtle fallback
export let TextEncoder = globalThis.TextEncoder;
/* eslint-disable @typescript-eslint/no-var-requires */
if (!crypto) {
+39 -26
View File
@@ -56,14 +56,7 @@ import { Room, RoomEvent } from "../models/room";
import { RoomMember, RoomMemberEvent } from "../models/room-member";
import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event";
import { ToDeviceBatch } from "../models/ToDeviceMessage";
import {
ClientEvent,
ICrossSigningKey,
IKeysUploadResponse,
ISignedKey,
IUploadKeySignaturesResponse,
MatrixClient,
} from "../client";
import { ClientEvent, IKeysUploadResponse, ISignedKey, IUploadKeySignaturesResponse, MatrixClient } from "../client";
import { IRoomEncryption, RoomList } from "./RoomList";
import { IKeyBackupInfo } from "./keybackup";
import { ISyncStateData } from "../sync";
@@ -89,6 +82,7 @@ import { ISecretRequest } from "./SecretSharing";
import {
BackupTrustInfo,
BootstrapCrossSigningOpts,
CrossSigningKeyInfo,
CrossSigningStatus,
DecryptionFailureCode,
DeviceVerificationStatus,
@@ -649,7 +643,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi#getVersion}.
* Implementation of {@link Crypto.CryptoApi#getVersion}.
*/
public getVersion(): string {
const olmVersionTuple = Crypto.getOlmVersion();
@@ -809,7 +803,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi#getCrossSigningStatus}
* Implementation of {@link Crypto.CryptoApi#getCrossSigningStatus}
*/
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
const publicKeysOnDevice = Boolean(this.crossSigningInfo.getId());
@@ -1173,7 +1167,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi#resetKeyBackup}.
* Implementation of {@link Crypto.CryptoApi#resetKeyBackup}.
*/
public async resetKeyBackup(): Promise<void> {
// Delete existing ones
@@ -1209,7 +1203,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi#deleteKeyBackupVersion}.
* Implementation of {@link Crypto.CryptoApi#deleteKeyBackupVersion}.
*/
public async deleteKeyBackupVersion(version: string): Promise<void> {
await this.backupManager.deleteKeyBackupVersion(version);
@@ -1356,7 +1350,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Get the current status of key backup.
*
* Implementation of {@link CryptoApi.getActiveSessionBackupVersion}.
* Implementation of {@link Crypto.CryptoApi.getActiveSessionBackupVersion}.
*/
public async getActiveSessionBackupVersion(): Promise<string | null> {
if (this.backupManager.getKeyBackupEnabled()) {
@@ -1378,7 +1372,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Force a re-check of the key backup and enable/disable it as appropriate.
*
* Implementation of {@link CryptoApi.checkKeyBackupAndEnable}.
* Implementation of {@link Crypto.CryptoApi.checkKeyBackupAndEnable}.
*/
public async checkKeyBackupAndEnable(): Promise<KeyBackupCheck | null> {
const checkResult = await this.backupManager.checkKeyBackup();
@@ -1527,7 +1521,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*/
private async checkForValidDeviceSignature(
userId: string,
key: ICrossSigningKey,
key: CrossSigningKeyInfo,
devices: Record<string, IDevice>,
): Promise<string[]> {
const deviceIds: string[] = [];
@@ -1595,7 +1589,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi.getUserVerificationStatus}.
* Implementation of {@link Crypto.CryptoApi.getUserVerificationStatus}.
*/
public async getUserVerificationStatus(userId: string): Promise<UserTrustLevel> {
return this.checkUserTrust(userId);
@@ -1894,7 +1888,11 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
*/
public importBackedUpRoomKeys(keys: IMegolmSessionData[], opts: ImportRoomKeysOpts = {}): Promise<void> {
public importBackedUpRoomKeys(
keys: IMegolmSessionData[],
backupVersion: string,
opts: ImportRoomKeysOpts = {},
): Promise<void> {
opts.source = "backup";
return this.importRoomKeys(keys, opts);
}
@@ -1904,7 +1902,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @param keys - The new trusted set of keys
*/
private async storeTrustedSelfKeys(keys: Record<string, ICrossSigningKey> | null): Promise<void> {
private async storeTrustedSelfKeys(keys: Record<string, CrossSigningKeyInfo> | null): Promise<void> {
if (keys) {
this.crossSigningInfo.setKeys(keys);
} else {
@@ -1992,7 +1990,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns base64-encoded ed25519 key.
*
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}.
* @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys}.
*/
public getDeviceEd25519Key(): string | null {
return this.olmDevice.deviceEd25519Key;
@@ -2003,14 +2001,14 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
*
* @returns base64-encoded curve25519 key.
*
* @deprecated Use {@link CryptoApi#getOwnDeviceKeys}
* @deprecated Use {@link Crypto.CryptoApi#getOwnDeviceKeys}
*/
public getDeviceCurve25519Key(): string | null {
return this.olmDevice.deviceCurve25519Key;
}
/**
* Implementation of {@link CryptoApi#getOwnDeviceKeys}.
* Implementation of {@link Crypto.CryptoApi#getOwnDeviceKeys}.
*/
public async getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
if (!this.olmDevice.deviceCurve25519Key) {
@@ -2346,7 +2344,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Mark the given device as locally verified.
*
* Implementation of {@link CryptoApi#setDeviceVerified}.
* Implementation of {@link Crypto.CryptoApi#setDeviceVerified}.
*/
public async setDeviceVerified(userId: string, deviceId: string, verified = true): Promise<void> {
await this.setDeviceVerification(userId, deviceId, verified);
@@ -2355,7 +2353,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
/**
* Blindly cross-sign one of our other devices.
*
* Implementation of {@link CryptoApi#crossSignDevice}.
* Implementation of {@link Crypto.CryptoApi#crossSignDevice}.
*/
public async crossSignDevice(deviceId: string): Promise<void> {
await this.setDeviceVerified(this.userId, deviceId, true);
@@ -2390,7 +2388,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
blocked: boolean | null = null,
known: boolean | null = null,
keys?: Record<string, string>,
): Promise<DeviceInfo | CrossSigningInfo | ICrossSigningKey | undefined> {
): Promise<DeviceInfo | CrossSigningInfo | CrossSigningKeyInfo | undefined> {
// Check if the 'device' is actually a cross signing key
// The js-sdk's verification treats cross-signing keys as devices
// and so uses this method to mark them verified.
@@ -2791,7 +2789,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
* Implementation of {@link Crypto.CryptoApi.getEncryptionInfoForEvent}.
*/
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
const encryptionInfo = this.getEventEncryptionInfo(event);
@@ -4274,7 +4272,7 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
}
/**
* Implementation of {@link CryptoApi#isEncryptionEnabledInRoom}.
* Implementation of {@link Crypto.CryptoApi#isEncryptionEnabledInRoom}.
*/
public async isEncryptionEnabledInRoom(roomId: string): Promise<boolean> {
return this.isRoomEncrypted(roomId);
@@ -4287,6 +4285,21 @@ export class Crypto extends TypedEventEmitter<CryptoEvent, CryptoEventHandlerMap
public getRoomEncryption(roomId: string): IRoomEncryption | null {
return this.roomList.getRoomEncryption(roomId);
}
/**
* Returns whether dehydrated devices are supported by the crypto backend
* and by the server.
*/
public async isDehydrationSupported(): Promise<boolean> {
return false;
}
/**
* Stub function -- dehydration is not implemented here, so throw error
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
throw new Error("Not implemented");
}
}
/**
+3 -3
View File
@@ -16,7 +16,6 @@ limitations under the License.
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { RoomKeyRequestState } from "../OutgoingRoomKeyRequestManager";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { TrackingStatus } from "../DeviceList";
import { IRoomEncryption } from "../RoomList";
@@ -27,6 +26,7 @@ import { InboundGroupSessionData } from "../OlmDevice";
import { MatrixEvent } from "../../models/event";
import { DehydrationManager } from "../dehydration";
import { IEncryptedPayload } from "../aes";
import { CrossSigningKeyInfo } from "../../crypto-api";
/**
* Internal module. Definitions for storage for the crypto module
@@ -100,13 +100,13 @@ export interface CryptoStore {
// Olm Account
getAccount(txn: unknown, func: (accountPickle: string | null) => void): void;
storeAccount(txn: unknown, accountPickle: string): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void;
getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void;
getSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
func: (key: SecretStorePrivateKeys[K] | null) => void,
type: K,
): void;
storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void;
storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void;
storeSecretStorePrivateKey<K extends keyof SecretStorePrivateKeys>(
txn: unknown,
type: K,
@@ -33,11 +33,11 @@ import {
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody, IRoomKeyRequestRecipient } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { IndexedDBCryptoStore } from "./indexeddb-crypto-store";
import { CrossSigningKeyInfo } from "../../crypto-api";
const PROFILE_TRANSACTIONS = false;
@@ -418,7 +418,7 @@ export class Backend implements CryptoStore {
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
): void {
const objectStore = txn.objectStore("account");
const getReq = objectStore.get("crossSigningKeys");
@@ -447,7 +447,7 @@ export class Backend implements CryptoStore {
};
}
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
const objectStore = txn.objectStore("account");
objectStore.put(keys, "crossSigningKeys");
}
+3 -3
View File
@@ -36,10 +36,10 @@ import {
ACCOUNT_OBJECT_KEY_MIGRATION_STATE,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { CrossSigningKeyInfo } from "../../crypto-api";
/*
* Internal module. indexeddb storage for e2e.
@@ -420,7 +420,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
*/
public getCrossSigningKeys(
txn: IDBTransaction,
func: (keys: Record<string, ICrossSigningKey> | null) => void,
func: (keys: Record<string, CrossSigningKeyInfo> | null) => void,
): void {
this.backend!.getCrossSigningKeys(txn, func);
}
@@ -444,7 +444,7 @@ export class IndexedDBCryptoStore implements CryptoStore {
* @param txn - An active transaction. See doTxn().
* @param keys - keys object as getCrossSigningKeys()
*/
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, ICrossSigningKey>): void {
public storeCrossSigningKeys(txn: IDBTransaction, keys: Record<string, CrossSigningKeyInfo>): void {
this.backend!.storeCrossSigningKeys(txn, keys);
}
@@ -31,9 +31,9 @@ import {
} from "./base";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { ICrossSigningKey } from "../../client";
import { InboundGroupSessionData } from "../OlmDevice";
import { safeSet } from "../../utils";
import { CrossSigningKeyInfo } from "../../crypto-api";
/**
* Internal module. Partial localStorage backed storage for e2e.
@@ -531,8 +531,8 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
setJsonItem(this.store, KEY_END_TO_END_ACCOUNT, accountPickle);
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
const keys = getJsonItem<Record<string, ICrossSigningKey>>(this.store, KEY_CROSS_SIGNING_KEYS);
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
const keys = getJsonItem<Record<string, CrossSigningKeyInfo>>(this.store, KEY_CROSS_SIGNING_KEYS);
func(keys);
}
@@ -545,7 +545,7 @@ export class LocalStorageCryptoStore extends MemoryCryptoStore implements Crypto
func(key);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
setJsonItem(this.store, KEY_CROSS_SIGNING_KEYS, keys);
}
+26 -22
View File
@@ -32,10 +32,21 @@ import {
SESSION_BATCH_SIZE,
} from "./base";
import { IRoomKeyRequestBody } from "../index";
import { ICrossSigningKey } from "../../client";
import { IOlmDevice } from "../algorithms/megolm";
import { IRoomEncryption } from "../RoomList";
import { InboundGroupSessionData } from "../OlmDevice";
import { CrossSigningKeyInfo } from "../../crypto-api";
function encodeSessionKey(senderCurve25519Key: string, sessionId: string): string {
return encodeURIComponent(senderCurve25519Key) + "/" + encodeURIComponent(sessionId);
}
function decodeSessionKey(key: string): { senderKey: string; sessionId: string } {
const keyParts = key.split("/");
const senderKey = decodeURIComponent(keyParts[0]);
const sessionId = decodeURIComponent(keyParts[1]);
return { senderKey, sessionId };
}
/**
* Internal module. in-memory storage for e2e.
@@ -45,7 +56,7 @@ export class MemoryCryptoStore implements CryptoStore {
private migrationState: MigrationState = MigrationState.NOT_STARTED;
private outgoingRoomKeyRequests: OutgoingRoomKeyRequest[] = [];
private account: string | null = null;
private crossSigningKeys: Record<string, ICrossSigningKey> | null = null;
private crossSigningKeys: Record<string, CrossSigningKeyInfo> | null = null;
private privateKeys: Partial<SecretStorePrivateKeys> = {};
private sessions: { [deviceKey: string]: { [sessionId: string]: ISessionInfo } } = {};
@@ -308,7 +319,7 @@ export class MemoryCryptoStore implements CryptoStore {
this.account = accountPickle;
}
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, ICrossSigningKey> | null) => void): void {
public getCrossSigningKeys(txn: unknown, func: (keys: Record<string, CrossSigningKeyInfo> | null) => void): void {
func(this.crossSigningKeys);
}
@@ -321,7 +332,7 @@ export class MemoryCryptoStore implements CryptoStore {
func(result || null);
}
public storeCrossSigningKeys(txn: unknown, keys: Record<string, ICrossSigningKey>): void {
public storeCrossSigningKeys(txn: unknown, keys: Record<string, CrossSigningKeyInfo>): void {
this.crossSigningKeys = keys;
}
@@ -481,20 +492,14 @@ export class MemoryCryptoStore implements CryptoStore {
txn: unknown,
func: (groupSession: InboundGroupSessionData | null, groupSessionWithheld: IWithheld | null) => void,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
func(this.inboundGroupSessions[k] || null, this.inboundGroupSessionsWithheld[k] || null);
}
public getAllEndToEndInboundGroupSessions(txn: unknown, func: (session: ISession | null) => void): void {
for (const key of Object.keys(this.inboundGroupSessions)) {
// we can't use split, as the components we are trying to split out
// might themselves contain '/' characters. We rely on the
// senderKey being a (32-byte) curve25519 key, base64-encoded
// (hence 43 characters long).
func({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
...decodeSessionKey(key),
sessionData: this.inboundGroupSessions[key],
});
}
@@ -507,7 +512,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
if (this.inboundGroupSessions[k] === undefined) {
this.inboundGroupSessions[k] = sessionData;
}
@@ -519,7 +524,8 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: InboundGroupSessionData,
txn: unknown,
): void {
this.inboundGroupSessions[senderCurve25519Key + "/" + sessionId] = sessionData;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessions[k] = sessionData;
}
public storeEndToEndInboundGroupSessionWithheld(
@@ -528,7 +534,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessionData: IWithheld,
txn: unknown,
): void {
const k = senderCurve25519Key + "/" + sessionId;
const k = encodeSessionKey(senderCurve25519Key, sessionId);
this.inboundGroupSessionsWithheld[k] = sessionData;
}
@@ -554,8 +560,7 @@ export class MemoryCryptoStore implements CryptoStore {
const result: SessionExtended[] = [];
for (const [key, session] of Object.entries(this.inboundGroupSessions)) {
result.push({
senderKey: key.slice(0, 43),
sessionId: key.slice(44),
...decodeSessionKey(key),
sessionData: session,
needsBackup: key in this.sessionsNeedingBackup,
});
@@ -584,7 +589,7 @@ export class MemoryCryptoStore implements CryptoStore {
sessions: { senderKey: string; sessionId: string }[],
): Promise<void> {
for (const { senderKey, sessionId } of sessions) {
const k = senderKey + "/" + sessionId;
const k = encodeSessionKey(senderKey, sessionId);
delete this.inboundGroupSessions[k];
}
}
@@ -614,8 +619,7 @@ export class MemoryCryptoStore implements CryptoStore {
for (const session in this.sessionsNeedingBackup) {
if (this.inboundGroupSessions[session]) {
sessions.push({
senderKey: session.slice(0, 43),
sessionId: session.slice(44),
...decodeSessionKey(session),
sessionData: this.inboundGroupSessions[session],
});
if (limit && session.length >= limit) {
@@ -632,7 +636,7 @@ export class MemoryCryptoStore implements CryptoStore {
public unmarkSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
delete this.sessionsNeedingBackup[sessionKey];
}
return Promise.resolve();
@@ -640,7 +644,7 @@ export class MemoryCryptoStore implements CryptoStore {
public markSessionsNeedingBackup(sessions: ISession[]): Promise<void> {
for (const session of sessions) {
const sessionKey = session.senderKey + "/" + session.sessionId;
const sessionKey = encodeSessionKey(session.senderKey, session.sessionId);
this.sessionsNeedingBackup[sessionKey] = true;
}
return Promise.resolve();
+1 -1
View File
@@ -74,7 +74,7 @@ export class ReciprocateQRCode extends Base {
await new Promise<void>((resolve, reject) => {
this.reciprocateQREvent = {
confirm: resolve,
cancel: () => reject(newUserCancelledError()),
cancel: (): void => reject(newUserCancelledError()),
};
this.emit(QrCodeEvent.ShowReciprocateQr, this.reciprocateQREvent);
});
+2 -2
View File
@@ -308,8 +308,8 @@ export class SAS extends Base {
reject(err);
}
},
cancel: () => reject(newUserCancelledError()),
mismatch: () => reject(newMismatchedSASError()),
cancel: (): void => reject(newUserCancelledError()),
mismatch: (): void => reject(newMismatchedSASError()),
};
this.emit(SasEvent.ShowSas, this.sasEvent);
});
+4
View File
@@ -33,6 +33,7 @@ export enum Feature {
AccountDataDeletion = "AccountDataDeletion",
RelationsRecursion = "RelationsRecursion",
IntentionalMentions = "IntentionalMentions",
MigrateServerNameToVia = "MigrateServerNameToVia",
}
type FeatureSupportCondition = {
@@ -65,6 +66,9 @@ const featureSupportResolver: Record<string, FeatureSupportCondition> = {
unstablePrefixes: ["org.matrix.msc3952_intentional_mentions"],
matrixVersion: "v1.7",
},
[Feature.MigrateServerNameToVia]: {
unstablePrefixes: ["org.matrix.msc4156"],
},
};
export async function buildFeatureSupportMap(versions: IServerVersions): Promise<Map<Feature, ServerSupport>> {
+3
View File
@@ -19,4 +19,7 @@ export enum Method {
Put = "PUT",
Post = "POST",
Delete = "DELETE",
Options = "OPTIONS",
Head = "HEAD",
Patch = "PATCH",
}
+4 -10
View File
@@ -121,10 +121,6 @@ interface ThreepidCreds {
type EmailIdentityDict = {
type: AuthType.Email;
threepid_creds: ThreepidCreds;
/**
* @deprecated in favour of `threepid_creds` - kept for backwards compatibility
*/
threepidCreds?: ThreepidCreds;
session: string;
};
@@ -343,15 +339,13 @@ export class InteractiveAuth<T> {
sid: this.emailSid,
client_secret: this.clientSecret,
};
const idServerParsedUrl = new URL(this.matrixClient.getIdentityServerUrl()!);
creds.id_server = idServerParsedUrl.host;
const isUrl = this.matrixClient.getIdentityServerUrl();
if (isUrl) {
creds.id_server = new URL(isUrl).host;
}
authDict = {
type: EMAIL_STAGE_TYPE,
// TODO: Remove `threepid_creds` once servers support proper UIA
// See https://github.com/matrix-org/synapse/issues/5665
// See https://github.com/matrix-org/matrix-doc/issues/2220
threepid_creds: creds,
threepidCreds: creds,
};
}
}
+2 -14
View File
@@ -24,6 +24,7 @@ import { RoomWidgetClient, ICapabilities } from "./embedded";
import { CryptoStore } from "./crypto/store/base";
export * from "./client";
export * from "./serverCapabilities";
export * from "./embedded";
export * from "./http-api";
export * from "./autodiscovery";
@@ -77,6 +78,7 @@ export * from "./@types/extensible_events";
export * from "./@types/IIdentityServerProvider";
export * from "./models/room-summary";
export * from "./models/event-status";
export type { RoomSummary } from "./client";
export * as ContentHelpers from "./content-helpers";
export * as SecretStorage from "./secret-storage";
export type { ICryptoCallbacks } from "./crypto"; // used to be located here
@@ -113,20 +115,6 @@ export { LocationAssetType } from "./@types/location";
*/
export * as Crypto from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.CryptoApi}
*/
export type { CryptoApi } from "./crypto-api";
/**
* Backwards compatibility re-export
* @internal
* @deprecated use {@link Crypto.DeviceVerificationStatus}
*/
export { DeviceVerificationStatus } from "./crypto-api";
let cryptoStoreFactory = (): CryptoStore => new MemoryCryptoStore();
/**
+136 -47
View File
@@ -14,52 +14,114 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EitherAnd } from "matrix-events-sdk/lib/types";
import { MatrixEvent } from "../matrix";
import { deepCompare } from "../utils";
import { Focus } from "./focus";
import { isLivekitFocusActive } from "./LivekitFocus";
type CallScope = "m.room" | "m.user";
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
export interface CallMembershipData {
application?: string;
// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
// MSC4143 (MatrixRTC) session membership data
export type SessionMembershipData = {
application: string;
call_id: string;
device_id: string;
focus_active: Focus;
foci_preferred: Focus[];
created_ts?: number;
// Application specific data
scope?: CallScope;
};
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
"focus_active" in data;
const checkSessionsMembershipData = (data: any, errors: string[]): data is SessionMembershipData => {
const prefix = "Malformed session membership event: ";
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.focus_active?.type !== "string") errors.push(prefix + "focus_active.type must be a string");
if (!Array.isArray(data.foci_preferred)) errors.push(prefix + "foci_preferred must be an array");
// optional parameters
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
// Legacy session membership data
export type CallMembershipDataLegacy = {
application: string;
call_id: string;
scope: CallScope;
device_id: string;
created_ts?: number;
expires?: number;
expires_ts?: number;
foci_active?: Focus[];
membershipID: string;
}
created_ts?: number;
foci_active?: Focus[];
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
"membershipID" in data;
const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is CallMembershipDataLegacy => {
const prefix = "Malformed legacy rtc membership event: ";
if (!("expires" in data || "expires_ts" in data)) {
errors.push(prefix + "expires_ts or expires must be present");
}
if ("expires" in data) {
if (typeof data.expires !== "number") {
errors.push(prefix + "expires must be numeric");
}
}
if ("expires_ts" in data) {
if (typeof data.expires_ts !== "number") {
errors.push(prefix + "expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") errors.push(prefix + "device_id must be string");
if (typeof data.call_id !== "string") errors.push(prefix + "call_id must be string");
if (typeof data.application !== "string") errors.push(prefix + "application must be a string");
if (typeof data.membershipID !== "string") errors.push(prefix + "membershipID must be a string");
// optional elements
if (data.created_ts && typeof data.created_ts !== "number") errors.push(prefix + "created_ts must be number");
// application specific data (we first need to check if they exist)
if (data.scope && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
return errors.length === 0;
};
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean {
return deepCompare(a.data, b.data);
return deepCompare(a.membershipData, b.membershipData);
}
private membershipData: CallMembershipData;
public constructor(
private parentEvent: MatrixEvent,
private data: CallMembershipData,
data: any,
) {
if (!(data.expires || data.expires_ts)) {
throw new Error("Malformed membership: expires_ts or expires must be present");
const sessionErrors: string[] = [];
const legacyErrors: string[] = [];
if (!checkSessionsMembershipData(data, sessionErrors) && !checkCallMembershipDataLegacy(data, legacyErrors)) {
throw Error(
`unknown CallMembership data. Does not match legacy call.member (${legacyErrors.join(" & ")}) events nor MSC4143 (${sessionErrors.join(" & ")})`,
);
} else {
this.membershipData = data;
}
if (data.expires) {
if (typeof data.expires !== "number") {
throw new Error("Malformed membership: expires must be numeric");
}
}
if (data.expires_ts) {
if (typeof data.expires_ts !== "number") {
throw new Error("Malformed membership: expires_ts must be numeric");
}
}
if (typeof data.device_id !== "string") throw new Error("Malformed membership event: device_id must be string");
if (typeof data.call_id !== "string") throw new Error("Malformed membership event: call_id must be string");
if (typeof data.scope !== "string") throw new Error("Malformed membership event: scope must be string");
if (!parentEvent.getSender()) throw new Error("Invalid parent event: sender is null");
}
public get sender(): string | undefined {
@@ -67,62 +129,89 @@ export class CallMembership {
}
public get callId(): string {
return this.data.call_id;
return this.membershipData.call_id;
}
public get deviceId(): string {
return this.data.device_id;
return this.membershipData.device_id;
}
public get application(): string | undefined {
return this.data.application;
return this.membershipData.application;
}
public get scope(): CallScope {
return this.data.scope;
public get scope(): CallScope | undefined {
return this.membershipData.scope;
}
public get membershipID(): string {
return this.data.membershipID;
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.membershipID;
// the createdTs behaves equivalent to the membershipID.
// we only need the field for the legacy member envents where we needed to update them
// synapse ignores sending state events if they have the same content.
else return this.createdTs().toString();
}
public createdTs(): number {
return this.data.created_ts ?? this.parentEvent.getTs();
return this.membershipData.created_ts ?? this.parentEvent.getTs();
}
public getAbsoluteExpiry(): number {
if (this.data.expires) {
return this.createdTs() + this.data.expires;
public getAbsoluteExpiry(): number | undefined {
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
return this.createdTs() + this.membershipData.expires;
} else {
// We know it exists because we checked for this in the constructor.
return this.data.expires_ts!;
return this.membershipData.expires_ts;
}
}
// gets the expiry time of the event, converted into the device's local time
public getLocalExpiry(): number {
if (this.data.expires) {
public getLocalExpiry(): number | undefined {
if (!isLegacyCallMembershipData(this.membershipData)) return undefined;
if ("expires" in this.membershipData) {
// we know createdTs exists since we already do the isLegacyCallMembershipData check
const relativeCreationTime = this.parentEvent.getTs() - this.createdTs();
const localCreationTs = this.parentEvent.localTimestamp - relativeCreationTime;
return localCreationTs + this.data.expires;
return localCreationTs + this.membershipData.expires;
} else {
// With expires_ts we cannot convert to local time.
// TODO: Check the server timestamp and compute a diff to local time.
return this.data.expires_ts!;
return this.membershipData.expires_ts;
}
}
public getMsUntilExpiry(): number {
return this.getLocalExpiry() - Date.now();
public getMsUntilExpiry(): number | undefined {
if (isLegacyCallMembershipData(this.membershipData)) return this.getLocalExpiry()! - Date.now();
}
public isExpired(): boolean {
return this.getMsUntilExpiry() <= 0;
if (isLegacyCallMembershipData(this.membershipData)) return this.getMsUntilExpiry()! <= 0;
// MSC4143 events expire by being updated. So if the event exists, its not expired.
return false;
}
public getActiveFoci(): Focus[] {
return this.data.foci_active ?? [];
public getPreferredFoci(): Focus[] {
// To support both, the new and the old MatrixRTC memberships have two cases based
// on the availablitiy of `foci_preferred`
if (isLegacyCallMembershipData(this.membershipData)) return this.membershipData.foci_active ?? [];
// MSC4143 style membership
return this.membershipData.foci_preferred;
}
public getFocusSelection(): string | undefined {
if (isLegacyCallMembershipData(this.membershipData)) {
return "oldest_membership";
} else {
const focusActive = this.membershipData.focus_active;
if (isLivekitFocusActive(focusActive)) {
return focusActive.focus_selection;
}
}
}
}
+39
View File
@@ -0,0 +1,39 @@
/*
Copyright 2023 New Vector Ltd
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Focus } from "./focus";
export interface LivekitFocusConfig extends Focus {
type: "livekit";
livekit_service_url: string;
}
export const isLivekitFocusConfig = (object: any): object is LivekitFocusConfig =>
object.type === "livekit" && "livekit_service_url" in object;
export interface LivekitFocus extends LivekitFocusConfig {
livekit_alias: string;
}
export const isLivekitFocus = (object: any): object is LivekitFocus =>
isLivekitFocusConfig(object) && "livekit_alias" in object;
export interface LivekitFocusActive extends Focus {
type: "livekit";
focus_selection: "oldest_membership";
}
export const isLivekitFocusActive = (object: any): object is LivekitFocusActive =>
object.type === "livekit" && "focus_selection" in object;
+202 -87
View File
@@ -20,7 +20,13 @@ import { EventTimeline } from "../models/event-timeline";
import { Room } from "../models/room";
import { MatrixClient } from "../client";
import { EventType } from "../@types/event";
import { CallMembership, CallMembershipData } from "./CallMembership";
import {
CallMembership,
CallMembershipData,
CallMembershipDataLegacy,
SessionMembershipData,
isLegacyCallMembershipData,
} from "./CallMembership";
import { RoomStateEvent } from "../models/room-state";
import { Focus } from "./focus";
import { randomString, secureRandomBase64Url } from "../randomstring";
@@ -29,6 +35,8 @@ import { decodeBase64, encodeUnpaddedBase64 } from "../base64";
import { KnownMembership } from "../@types/membership";
import { MatrixError } from "../http-api/errors";
import { MatrixEvent } from "../models/event";
import { isLivekitFocusActive } from "./LivekitFocus";
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall";
const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000;
const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event
@@ -57,7 +65,7 @@ export enum MatrixRTCSessionEvent {
MembershipsChanged = "memberships_changed",
// We joined or left the session: our own local idea of whether we are joined,
// separate from MembershipsChanged, ie. independent of whether our member event
// has succesfully gone through.
// has successfully gone through.
JoinStateChanged = "join_state_changed",
// The key used to encrypt media has changed
EncryptionKeyChanged = "encryption_key_changed",
@@ -75,7 +83,20 @@ export type MatrixRTCSessionEventHandlerMap = {
participantId: string,
) => void;
};
export interface JoinSessionConfig {
/** If true, generate and share a media key for this participant,
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
* media keys for other participants become available.
*/
manageMediaKeys?: boolean;
/** Lets you configure how the events for the session are formatted.
* - legacy: use one event with a membership array.
* - MSC4143: use one event per membership (with only one membership per event)
* More details can be found in MSC4143 and by checking the types:
* `CallMembershipDataLegacy` and `SessionMembershipData`
*/
useLegacyMemberEvents?: boolean;
}
/**
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
* This class doesn't deal with media at all, just membership & properties of a session.
@@ -102,12 +123,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
private activeFoci: Focus[] | undefined;
// This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
private ownFocusActive?: Focus;
// This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
private ownFociPreferred?: Focus[];
private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false;
private manageMediaKeys = false;
private useLegacyMemberEvents = true;
// userId:deviceId => array of keys
private encryptionKeys = new Map<string, Array<Uint8Array>>();
private lastEncryptionKeyUpdateRequest?: number;
@@ -134,22 +159,33 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
const callMemberships: CallMembership[] = [];
for (const memberEvent of callMemberEvents) {
const eventMemberships: CallMembershipData[] = memberEvent.getContent()["memberships"];
if (eventMemberships === undefined) {
logger.debug(`Ignoring malformed member event from ${memberEvent.getSender()}: no memberships section`);
continue;
const content = memberEvent.getContent();
let membershipContents: any[] = [];
// We first decide if its a MSC4143 event (per device state key)
if ("memberships" in content) {
// we have a legacy (one event for all devices) event
if (!Array.isArray(content["memberships"])) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
continue;
}
membershipContents = content["memberships"];
} else {
// We have a MSC4143 event membership event
if (Object.keys(content).length !== 0) {
// We checked for empty content to not try to construct CallMembership's with {}.
membershipContents.push(content);
}
}
if (!Array.isArray(eventMemberships)) {
logger.warn(`Malformed member event from ${memberEvent.getSender()}: memberships is not an array`);
if (membershipContents.length === 0) {
continue;
}
for (const membershipData of eventMemberships) {
for (const membershipData of membershipContents) {
try {
const membership = new CallMembership(memberEvent, membershipData);
if (membership.callId !== "" || membership.scope !== "m.room") {
// for now, just ignore anything that isn't the a room scope call
// for now, just ignore anything that isn't a room scope call
logger.info(`Ignoring user-scoped call`);
continue;
}
@@ -203,6 +239,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/*
* Returns true if we intend to be participating in the MatrixRTC session.
* This is determined by checking if the relativeExpiry has been set.
*/
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
@@ -233,30 +270,34 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* desired.
* This method will return immediately and the session will be joined in the background.
*
* @param activeFoci - The list of foci to set as currently active in the call member event
* @param manageMediaKeys - If true, generate and share a a media key for this participant,
* and emit MatrixRTCSessionEvent.EncryptionKeyChanged when
* media keys for other participants become available.
* @param fociActive - The object representing the active focus. (This depends on the focus type.)
* @param fociPreferred - The list of preferred foci this member proposes to use/knows/has access to.
* For the livekit case this is a list of foci generated from the homeserver well-known, the current rtc session,
* or optionally other room members homeserver well known.
* @param joinConfig - Additional configuration for the joined session.
*/
public joinRoomSession(activeFoci: Focus[], manageMediaKeys?: boolean): void {
public joinRoomSession(fociPreferred: Focus[], fociActive?: Focus, joinConfig?: JoinSessionConfig): void {
if (this.isJoined()) {
logger.info(`Already joined to session in room ${this.room.roomId}: ignoring join call`);
return;
}
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${manageMediaKeys}`);
this.activeFoci = activeFoci;
this.ownFocusActive = fociActive;
this.ownFociPreferred = fociPreferred;
this.relativeExpiry = MEMBERSHIP_EXPIRY_TIME;
this.manageMediaKeys = manageMediaKeys ?? false;
this.manageMediaKeys = joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.useLegacyMemberEvents = joinConfig?.useLegacyMemberEvents ?? this.useLegacyMemberEvents;
this.membershipId = randomString(5);
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
if (manageMediaKeys) {
logger.info(`Joining call session in room ${this.room.roomId} with manageMediaKeys=${this.manageMediaKeys}`);
if (joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
this.requestKeyEventSend();
}
// We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all.
this.triggerCallMembershipEventUpdate();
this.emit(MatrixRTCSessionEvent.JoinStateChanged, true);
}
/**
@@ -296,7 +337,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
logger.info(`Leaving call session in room ${this.room.roomId}`);
this.relativeExpiry = undefined;
this.activeFoci = undefined;
this.ownFocusActive = undefined;
this.manageMediaKeys = false;
this.membershipId = undefined;
this.emit(MatrixRTCSessionEvent.JoinStateChanged, false);
@@ -316,6 +357,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
});
}
public getActiveFocus(): Focus | undefined {
if (this.ownFocusActive && isLivekitFocusActive(this.ownFocusActive)) {
// A livekit active focus
if (this.ownFocusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
if (!this.ownFocusActive) {
// we use the legacy call.member events so default to oldest member
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
public getKeysForParticipant(userId: string, deviceId: string): Array<Uint8Array> | undefined {
return this.encryptionKeys.get(getParticipantId(userId, deviceId));
}
@@ -345,7 +401,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* @param userId - The user ID of the participant
* @param deviceId - Device ID of the participant
* @param encryptionKeyIndex - The index of the key to set
* @param encryptionKeyString - The string represenation of the key to set in base64
* @param encryptionKeyString - The string representation of the key to set in base64
* @param delayBeforeuse - If true, delay before emitting a key changed event. Useful when setting
* encryption keys for the local participant to allow time for the key to
* be distributed.
@@ -380,7 +436,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
/**
* Generate a new sender key and add it at the next available index
* @param delayBeforeUse - If true, wait for a short period before settign the key for the
* @param delayBeforeUse - If true, wait for a short period before setting the key for the
* media encryptor to use. If false, set the key immediately.
*/
private makeNewSenderKey(delayBeforeUse = false): void {
@@ -489,7 +545,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
let soonestExpiry;
for (const membership of this.memberships) {
const thisExpiry = membership.getMsUntilExpiry();
if (soonestExpiry === undefined || thisExpiry < soonestExpiry) {
// If getMsUntilExpiry is undefined we have a MSC4143 (MatrixRTC) compliant event - it never expires
// but will be reliably resent on disconnect.
if (thisExpiry !== undefined && (soonestExpiry === undefined || thisExpiry < soonestExpiry)) {
soonestExpiry = thisExpiry;
}
}
@@ -503,6 +561,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
return this.memberships[0];
}
public getFocusInUse(): Focus | undefined {
const oldestMembership = this.getOldestMembership();
if (oldestMembership?.getFocusSelection() === "oldest_membership") {
return oldestMembership.getPreferredFoci()[0];
}
}
public onCallEncryption = (event: MatrixEvent): void => {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
@@ -614,30 +679,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
* Constructs our own membership
* @param prevMembership - The previous value of our call membership, if any
*/
private makeMyMembership(prevMembership?: CallMembership): CallMembershipData {
private makeMyMembershipLegacy(deviceId: string, prevMembership?: CallMembership): CallMembershipDataLegacy {
if (this.relativeExpiry === undefined) {
throw new Error("Tried to create our own membership event when we're not joined!");
}
if (this.membershipId === undefined) {
throw new Error("Tried to create our own membership event when we have no membership ID!");
}
const m: CallMembershipData = {
const createdTs = prevMembership?.createdTs();
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: this.client.getDeviceId()!,
device_id: deviceId,
expires: this.relativeExpiry,
foci_active: this.activeFoci,
// TODO: Date.now() should be the origin_server_ts (now).
expires_ts: this.relativeExpiry + (createdTs ?? Date.now()),
// we use the fociPreferred since this is the list of foci.
// it is named wrong in the Legacy events.
foci_active: this.ownFociPreferred,
membershipID: this.membershipId,
...(createdTs ? { created_ts: createdTs } : {}),
};
}
/**
* Constructs our own membership
*/
private makeMyMembership(deviceId: string): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: deviceId,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
};
if (prevMembership) m.created_ts = prevMembership.createdTs();
if (m.created_ts) m.expires_ts = m.created_ts + (m.expires ?? 0);
// TODO: Date.now() should be the origin_server_ts (now).
else m.expires_ts = Date.now() + (m.expires ?? 0);
return m;
}
/**
@@ -647,36 +723,41 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
myPrevMembershipData?: CallMembershipData,
myPrevMembership?: CallMembership,
): boolean {
// work out if we need to update our membership event
let needsUpdate = false;
if (myPrevMembership && myPrevMembership.getMsUntilExpiry() === undefined) return false;
// Need to update if there's a membership for us but we're not joined (valid or otherwise)
if (!this.isJoined() && myPrevMembershipData) needsUpdate = true;
if (this.isJoined()) {
// ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) {
needsUpdate = true;
} else if (myPrevMembership.getMsUntilExpiry() < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping
needsUpdate = true;
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
}
if (!this.isJoined()) return !!myPrevMembershipData;
// ...or if we are joined, but there's no valid membership event
if (!myPrevMembership) return true;
const expiryTime = myPrevMembership.getMsUntilExpiry();
if (expiryTime !== undefined && expiryTime < MEMBERSHIP_EXPIRY_TIME / 2) {
// ...or if the expiry time needs bumping
this.relativeExpiry! += MEMBERSHIP_EXPIRY_TIME;
return true;
}
return needsUpdate;
return false;
}
private makeNewMembership(deviceId: string): SessionMembershipData | {} {
// If we're joined, add our own
if (this.isJoined()) {
return this.makeMyMembership(deviceId);
}
return {};
}
/**
* Makes a new membership list given the old list alonng with this user's previous membership event
* (if any) and this device's previous membership (if any)
*/
private makeNewMemberships(
private makeNewLegacyMemberships(
oldMemberships: CallMembershipData[],
localDeviceId: string,
myCallMemberEvent?: MatrixEvent,
myPrevMembership?: CallMembership,
): CallMembershipData[] {
const localDeviceId = this.client.getDeviceId();
if (!localDeviceId) throw new Error("Local device ID is null!");
): ExperimentalGroupCallRoomMemberState {
const filterExpired = (m: CallMembershipData): boolean => {
let membershipObj;
try {
@@ -705,10 +786,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
// If we're joined, add our own
if (this.isJoined()) {
newMemberships.push(this.makeMyMembership(myPrevMembership));
newMemberships.push(this.makeMyMembershipLegacy(localDeviceId, myPrevMembership));
}
return newMemberships;
return { memberships: newMemberships };
}
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
@@ -742,47 +823,58 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
const localDeviceId = this.client.getDeviceId();
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
const myCallMemberEvent = roomState.getStateEvents(EventType.GroupCallMemberPrefix, localUserId) ?? undefined;
const content = myCallMemberEvent?.getContent<Record<any, unknown>>() ?? {};
const memberships: CallMembershipData[] = Array.isArray(content["memberships"]) ? content["memberships"] : [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
let myPrevMembership;
try {
if (myCallMemberEvent && myPrevMembershipData && myPrevMembershipData.membershipID === this.membershipId) {
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
const callMemberEvents = roomState.events.get(EventType.GroupCallMemberPrefix);
const legacy =
!!this.useLegacyMemberEvents ||
(callMemberEvents?.size && this.stateEventsContainOngoingLegacySession(callMemberEvents));
let newContent: {} | ExperimentalGroupCallRoomMemberState | SessionMembershipData = {};
if (legacy) {
const myCallMemberEvent = callMemberEvents?.get(localUserId);
const content = myCallMemberEvent?.getContent() ?? {};
let myPrevMembership: CallMembership | undefined;
// We know its CallMembershipDataLegacy
const memberships: CallMembershipDataLegacy[] = Array.isArray(content["memberships"])
? content["memberships"]
: [];
const myPrevMembershipData = memberships.find((m) => m.device_id === localDeviceId);
try {
if (
myCallMemberEvent &&
myPrevMembershipData &&
isLegacyCallMembershipData(myPrevMembershipData) &&
myPrevMembershipData.membershipID === this.membershipId
) {
myPrevMembership = new CallMembership(myCallMemberEvent, myPrevMembershipData);
}
} catch (e) {
// This would indicate a bug or something weird if our own call membership
// wasn't valid
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
}
} catch (e) {
// This would indicate a bug or something weird if our own call membership
// wasn't valid
logger.warn("Our previous call membership was invalid - this shouldn't happen.", e);
if (myPrevMembership) {
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
}
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
// nothing to do - reschedule the check again
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
return;
}
newContent = this.makeNewLegacyMemberships(memberships, localDeviceId, myCallMemberEvent, myPrevMembership);
} else {
newContent = this.makeNewMembership(localDeviceId);
}
if (myPrevMembership) {
logger.debug(`${myPrevMembership.getMsUntilExpiry()} until our membership expires`);
}
if (!this.membershipEventNeedsUpdate(myPrevMembershipData, myPrevMembership)) {
// nothing to do - reschedule the check again
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
return;
}
const newContent = {
memberships: this.makeNewMemberships(memberships, myCallMemberEvent, myPrevMembership),
};
try {
await this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
newContent,
localUserId,
legacy ? localUserId : this.makeMembershipStateKey(localUserId, localDeviceId),
);
logger.info(`Sent updated call member event.`);
// check periodically to see if we need to refresh our member event
if (this.isJoined()) {
if (this.isJoined() && legacy) {
this.memberEventTimeout = setTimeout(this.triggerCallMembershipEventUpdate, MEMBER_EVENT_CHECK_PERIOD);
}
} catch (e) {
@@ -793,6 +885,29 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
}
}
private stateEventsContainOngoingLegacySession(callMemberEvents: Map<string, MatrixEvent>): boolean {
for (const callMemberEvent of callMemberEvents.values()) {
const content = callMemberEvent.getContent();
if (Array.isArray(content["memberships"])) {
for (const membership of content.memberships) {
if (!new CallMembership(callMemberEvent, membership).isExpired()) {
return true;
}
}
}
}
return false;
}
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
if (/^org\.matrix\.msc3779\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
return `_${stateKey}`;
}
}
private onRotateKeyTimeout = (): void => {
if (!this.manageMediaKeys) return;
+3 -3
View File
@@ -73,9 +73,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
}
this.roomSessions.clear();
this.client.removeListener(ClientEvent.Room, this.onRoom);
this.client.removeListener(RoomEvent.Timeline, this.onTimeline);
this.client.removeListener(RoomStateEvent.Events, this.onRoomState);
this.client.off(ClientEvent.Room, this.onRoom);
this.client.off(RoomEvent.Timeline, this.onTimeline);
this.client.off(RoomStateEvent.Events, this.onRoomState);
}
/**
+1
View File
@@ -21,4 +21,5 @@ limitations under the License.
*/
export interface Focus {
type: string;
[key: string]: unknown;
}

Some files were not shown because too many files have changed in this diff Show More