Compare commits

..

77 Commits

Author SHA1 Message Date
RiotRobot 7a51798acb v34.0.0 2024-07-08 12:18:02 +00:00
RiotRobot 24c589923b v34.0.0-rc.1 2024-07-04 12:49:22 +00:00
ElementRobot 1586de44bd [Backport staging] Fix "Unable to restore session" error (#4299)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Fixes https://github.com/element-hq/element-web/issues/27666
2024-07-03 20:56:40 +01:00
David Baker 04ea2a4e5d Merge pull request #4297 from matrix-org/backport-4296-to-staging
[Backport staging] Fix error when sending encrypted messages in large rooms
2024-07-03 15:22:31 +01:00
Richard van der Hoff b71099d0f8 Bump matrix-rust-sdk to 6.2.0 (#4296)
Fixes https://github.com/element-hq/element-web/issues/27658

(cherry picked from commit ccc2fb5663)
2024-07-03 14:04:38 +00:00
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
105 changed files with 5417 additions and 2338 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",
{
+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@132879b972cb7f2ac593006455875098e73cc7f2 # v5
- uses: mheap/github-action-required-labels@5847eef68201219cf0a4643ea7be61e77837bbce # v5
if: github.event_name != 'merge_group'
with:
labels: |
+1 -1
View File
@@ -30,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
+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 }}
+83
View File
@@ -1,3 +1,86 @@
Changes in [34.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v34.0.0) (2024-07-08)
==================================================================================================
## 🚨 BREAKING CHANGES
* Fetch capabilities in the background ([#4246](https://github.com/matrix-org/matrix-js-sdk/pull/4246)). Contributed by @dbkr.
## ✨ Features
* Prefix the user+device state key if needed ([#4262](https://github.com/matrix-org/matrix-js-sdk/pull/4262)). Contributed by @AndrewFerr.
* Use legacy call membership if anyone else is ([#4260](https://github.com/matrix-org/matrix-js-sdk/pull/4260)). Contributed by @AndrewFerr.
* Fetch capabilities in the background ([#4246](https://github.com/matrix-org/matrix-js-sdk/pull/4246)). Contributed by @dbkr.
* Use server name instead of homeserver url to allow well-known lookups during QR OIDC reciprocation ([#4233](https://github.com/matrix-org/matrix-js-sdk/pull/4233)). Contributed by @t3chguy.
* Add via parameter for MSC4156 ([#4247](https://github.com/matrix-org/matrix-js-sdk/pull/4247)). Contributed by @Johennes.
* Make the js-sdk compatible with MSC preferred foci and active focus. ([#4195](https://github.com/matrix-org/matrix-js-sdk/pull/4195)). Contributed by @toger5.
* Replace usages of setImmediate with setTimeout for wider compatibility ([#4240](https://github.com/matrix-org/matrix-js-sdk/pull/4240)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* [Backport staging] Fix "Unable to restore session" error ([#4299](https://github.com/matrix-org/matrix-js-sdk/pull/4299)). Contributed by @RiotRobot.
* [Backport staging] Fix error when sending encrypted messages in large rooms ([#4297](https://github.com/matrix-org/matrix-js-sdk/pull/4297)). Contributed by @RiotRobot.
* Element-R: Fix resource leaks in verification logic ([#4263](https://github.com/matrix-org/matrix-js-sdk/pull/4263)). Contributed by @richvdh.
* Upgrade Rust Crypto SDK to 6.1.0 ([#4261](https://github.com/matrix-org/matrix-js-sdk/pull/4261)). Contributed by @richvdh.
* Correctly transform base64 with multiple instances of + or / ([#4252](https://github.com/matrix-org/matrix-js-sdk/pull/4252)). Contributed by @robintown.
* Work around spec bug for m.room.avatar state event content type ([#4245](https://github.com/matrix-org/matrix-js-sdk/pull/4245)). Contributed by @t3chguy.
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
+1 -1
View File
@@ -95,7 +95,7 @@ Object.keys(client.store.rooms).forEach((roomId) => {
## Authenticated media
Servers supporting [MSC3916](https://github.com/matrix-org/matrix-spec-proposals/pull/3916) will require clients, like
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.
+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",
],
};
+9 -11
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "32.3.0-rc.0",
"version": "34.0.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
@@ -53,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",
@@ -65,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",
@@ -74,10 +74,10 @@
"@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",
@@ -87,7 +87,6 @@
"@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,7 +95,6 @@
"@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",
@@ -105,8 +103,8 @@
"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": "^52.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",
@@ -119,7 +117,7 @@
"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.2",
"typedoc": "^0.25.10",
+68
View File
@@ -630,6 +630,27 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
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 () => {
@@ -654,6 +675,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
},
);
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 () => {
@@ -676,6 +721,29 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
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 () => {
+4 -4
View File
@@ -21,7 +21,7 @@ import { Mocked } from "jest-mock";
import {
createClient,
CryptoApi,
Crypto,
CryptoEvent,
ICreateClientOpts,
IEvent,
@@ -310,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);
@@ -796,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
@@ -829,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;
});
+71
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",
@@ -266,6 +305,38 @@ describe("MatrixClient.initRustCrypto", () => {
});
});
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";
+100 -33
View File
@@ -257,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 });
@@ -1293,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);
});
});
@@ -1825,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",
@@ -1846,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",
@@ -1869,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) =>
@@ -1909,7 +1996,6 @@ const buildEventRedaction = (event: MatrixEvent) =>
const buildEventPollStartThreadRoot = () =>
new MatrixEvent({
age: 80108647,
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext: "ENCRYPTEDSTUFF",
@@ -1923,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",
@@ -1947,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",
},
@@ -1963,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",
},
@@ -1979,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",
},
@@ -1995,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",
},
@@ -2011,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,
},
@@ -2027,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: {
@@ -2063,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",
@@ -2081,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",
},
@@ -2097,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),
});
+5 -2
View File
@@ -38,7 +38,10 @@ 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",
@@ -47,6 +50,6 @@ export const mockOpenIdConfiguration = (issuer = "https://auth.org/"): Validated
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";
@@ -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", () => {
+4 -6
View File
@@ -80,24 +80,22 @@ describe("ContentRepo", function () {
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/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
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/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
"/_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/unstable/org.matrix.msc3916/media/download/server.name/resourceid?allow_redirect=true",
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/unstable/org.matrix.msc3916/media/thumbnail/server.name/resourceid?width=64&height=64&method=scale&allow_redirect=true",
"/_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", () => {
-1
View File
@@ -1560,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);
+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();
});
});
});
+71 -7
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);
});
@@ -1395,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
@@ -1437,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,
@@ -1489,6 +1515,44 @@ describe("RustCrypto", () => {
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.
+7 -9
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
@@ -303,12 +303,7 @@ export const UNSIGNED_THREAD_ID_FIELD = new UnstableValue("thread_id", "org.matr
*
* @experimental
*/
export const UNSIGNED_MEMBERSHIP_FIELD = new UnstableValue("membership", "io.element.msc4115.membership");
/**
* @deprecated in favour of {@link EncryptedFile}
*/
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.
@@ -362,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];
}
}
+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]: {
+3 -1
View File
@@ -95,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);
+87 -139
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,
@@ -1296,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[] = [];
@@ -1328,6 +1288,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public readonly matrixRTC: MatrixRTCSessionManager;
private serverCapabilitiesService: ServerCapabilities;
public constructor(opts: IMatrixClientCreateOpts) {
super();
@@ -1421,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);
@@ -1483,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();
@@ -1550,6 +1507,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
this.toDeviceMessageQueue.start();
this.serverCapabilitiesService.start();
}
/**
@@ -1603,6 +1561,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.toDeviceMessageQueue.stop();
this.matrixRTC.stop();
this.serverCapabilitiesService.stop();
}
/**
@@ -2105,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();
}
/**
@@ -2232,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;
@@ -2275,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);
},
});
@@ -2898,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) {
@@ -3316,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) {
@@ -3373,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;
@@ -3393,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) {
@@ -3855,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);
@@ -3874,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);
})
@@ -3914,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;
@@ -3944,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,
});
@@ -3958,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,
});
@@ -4316,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 = {};
@@ -4328,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
@@ -4361,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> = {};
@@ -7106,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
@@ -7891,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;
}
@@ -7915,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);
}
+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
+3 -5
View File
@@ -62,7 +62,7 @@ export function getHttpUriForMxc(
if (useAuthentication) {
allowRedirects = true; // per docs (MSC3916 always expects redirects)
// Dev note: MSC3916 (as of writing) removes `allow_redirect` entirely, but
// Dev note: MSC3916 removes `allow_redirect` entirely, but
// for explicitness we set it here. This makes it slightly more obvious to
// callers, hopefully.
}
@@ -70,8 +70,7 @@ export function getHttpUriForMxc(
let serverAndMediaId = mxc.slice(6); // strips mxc://
let prefix: string;
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/download/";
prefix = "/_matrix/client/v1/media/download/";
} else {
prefix = "/_matrix/media/v3/download/";
}
@@ -90,8 +89,7 @@ export function getHttpUriForMxc(
// these are thumbnailing params so they probably want the
// thumbnailing API...
if (useAuthentication) {
// TODO: Use stable once available (requires FCP on MSC3916).
prefix = "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/";
prefix = "/_matrix/client/v1/media/thumbnail/";
} else {
prefix = "/_matrix/media/v3/thumbnail/";
}
+18
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";
@@ -532,6 +533,23 @@ export interface CryptoApi {
* 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. */
+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>;
}
/**
+24 -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);
+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);
}
+4 -4
View File
@@ -32,10 +32,10 @@ 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);
@@ -56,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 } } = {};
@@ -319,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);
}
@@ -332,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;
}
+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>> {
+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,
};
}
}
+1 -18
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";
@@ -105,10 +106,6 @@ export { IdentityProviderBrand, SSOAction } from "./@types/auth";
export type { ISSOFlow as SSOFlow, LoginFlow } from "./@types/auth";
export type { IHierarchyRelation as HierarchyRelation, IHierarchyRoom as HierarchyRoom } from "./@types/spaces";
export { LocationAssetType } from "./@types/location";
/**
* @deprecated in favour of RoomJoinRulesEventContent on the types export
*/
export type { RoomJoinRulesEventContent as IJoinRuleEventContent } from "./@types/state_events";
/**
* Types supporting cryptography.
@@ -118,20 +115,6 @@ export type { RoomJoinRulesEventContent as IJoinRuleEventContent } from "./@type
*/
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 -86
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,21 +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) {
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;
}
@@ -202,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;
@@ -232,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);
}
/**
@@ -295,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);
@@ -315,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));
}
@@ -344,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.
@@ -379,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 {
@@ -488,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;
}
}
@@ -502,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>();
@@ -613,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;
}
/**
@@ -646,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 {
@@ -704,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> => {
@@ -741,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) {
@@ -792,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;
}
+1 -1
View File
@@ -35,7 +35,7 @@ import { ISendEventResponse } from "../@types/requests";
import { FileType } from "../http-api";
import { KnownMembership } from "../@types/membership";
import { RoomPowerLevelsEventContent, SpaceChildEventContent } from "../@types/state_events";
import { EncryptedFile, FileContent } from "../@types/media";
import type { EncryptedFile, FileContent } from "../@types/media";
/**
* The recommended defaults for a tree space's power levels. Note that this
+1 -36
View File
@@ -592,43 +592,8 @@ export class EventTimelineSet extends TypedEventEmitter<EmittedEvents, EventTime
*/
public addLiveEvent(
event: MatrixEvent,
{ duplicateStrategy, fromCache, roomState, timelineWasEmpty }: IAddLiveEventOptions,
): void;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public addLiveEvent(
event: MatrixEvent,
duplicateStrategy?: DuplicateStrategy,
fromCache?: boolean,
roomState?: RoomState,
): void;
public addLiveEvent(
event: MatrixEvent,
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false,
roomState?: RoomState,
{ duplicateStrategy, fromCache, roomState, timelineWasEmpty }: IAddLiveEventOptions = {},
): void {
let duplicateStrategy = (duplicateStrategyOrOpts as DuplicateStrategy) || DuplicateStrategy.Ignore;
let timelineWasEmpty: boolean | undefined;
if (typeof duplicateStrategyOrOpts === "object") {
({
duplicateStrategy = DuplicateStrategy.Ignore,
fromCache = false,
roomState,
timelineWasEmpty,
} = duplicateStrategyOrOpts);
} else if (duplicateStrategyOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
"Overload deprecated: " +
"`EventTimelineSet.addLiveEvent(event, duplicateStrategy?, fromCache?, roomState?)` " +
"is deprecated in favor of the overload with " +
"`EventTimelineSet.addLiveEvent(event, IAddLiveEventOptions)`",
);
}
if (this.filter) {
const events = this.filter.filterRoomTimeline([event]);
if (!events.length) {
+1 -22
View File
@@ -14,7 +14,6 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../logger";
import { IMarkerFoundOptions, RoomState } from "./room-state";
import { EventTimelineSet } from "./event-timeline-set";
import { MatrixEvent } from "./event";
@@ -361,30 +360,10 @@ export class EventTimeline {
* @param event - new event
* @param options - addEvent options
*/
public addEvent(event: MatrixEvent, { toStartOfTimeline, roomState, timelineWasEmpty }: IAddEventOptions): void;
/**
* @deprecated In favor of the overload with `IAddEventOptions`
*/
public addEvent(event: MatrixEvent, toStartOfTimeline: boolean, roomState?: RoomState): void;
public addEvent(
event: MatrixEvent,
toStartOfTimelineOrOpts: boolean | IAddEventOptions,
roomState?: RoomState,
{ toStartOfTimeline, roomState, timelineWasEmpty }: IAddEventOptions = { toStartOfTimeline: false },
): void {
let toStartOfTimeline = !!toStartOfTimelineOrOpts;
let timelineWasEmpty: boolean | undefined;
if (typeof toStartOfTimelineOrOpts === "object") {
({ toStartOfTimeline, roomState, timelineWasEmpty } = toStartOfTimelineOrOpts);
} else if (toStartOfTimelineOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
"Overload deprecated: " +
"`EventTimeline.addEvent(event, toStartOfTimeline, roomState?)` " +
"is deprecated in favor of the overload with `EventTimeline.addEvent(event, IAddEventOptions)`",
);
}
if (!roomState) {
roomState = toStartOfTimeline ? this.startState : this.endState;
}
+6 -24
View File
@@ -77,7 +77,6 @@ export interface IUnsigned {
"invite_room_state"?: StrippedState[];
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
[UNSIGNED_MEMBERSHIP_FIELD.name]?: Membership | string;
}
export interface IThreadBundledRelationship {
@@ -98,19 +97,6 @@ export interface IEvent {
membership?: Membership;
unsigned: IUnsigned;
redacts?: string;
/**
* @deprecated in favour of `sender`
*/
user_id?: string;
/**
* @deprecated in favour of `unsigned.prev_content`
*/
prev_content?: IContent;
/**
* @deprecated in favour of `origin_server_ts`
*/
age?: number;
}
export interface IAggregatedRelation {
@@ -495,7 +481,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns The user ID, e.g. `@alice:matrix.org`
*/
public getSender(): string | undefined {
return this.event.sender || this.event.user_id; // v2 / v1
return this.event.sender; // v2 / v1
}
/**
@@ -669,7 +655,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
public getPrevContent(): IContent {
// v2 then v1 then default
return this.getUnsigned().prev_content || this.event.prev_content || {};
return this.getUnsigned().prev_content || {};
}
/**
@@ -693,7 +679,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns The age of this event in milliseconds.
*/
public getAge(): number | undefined {
return this.getUnsigned().age || this.event.age; // v2 / v1
return this.getUnsigned().age;
}
/**
@@ -730,13 +716,9 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* @returns The user's room membership, or `undefined` if the server does
* not report it.
*/
public getMembershipAtEvent(): Membership | string | undefined {
public getMembershipAtEvent(): Optional<Membership | string> {
const unsigned = this.getUnsigned();
if (typeof unsigned[UNSIGNED_MEMBERSHIP_FIELD.name] === "string") {
return unsigned[UNSIGNED_MEMBERSHIP_FIELD.name];
} else {
return undefined;
}
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
}
/**
@@ -1084,7 +1066,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
* signing the public curve25519 key with the ed25519 key.
*
* In general, applications should not use this method directly, but should
* instead use {@link CryptoApi#getEncryptionInfoForEvent}.
* instead use {@link Crypto.CryptoApi#getEncryptionInfoForEvent}.
*/
public getClaimedEd25519Key(): string | null {
return this.claimedEd25519Key;
+20 -101
View File
@@ -41,7 +41,7 @@ import {
RelationType,
UNSIGNED_THREAD_ID_FIELD,
} from "../@types/event";
import { IRoomVersionsCapability, MatrixClient, PendingEventOrdering, RoomVersionStability } from "../client";
import { MatrixClient, PendingEventOrdering } from "../client";
import { GuestAccess, HistoryVisibility, JoinRule, ResizeMethod } from "../@types/partials";
import { Filter, IFilterDefinition } from "../filter";
import { RoomState, RoomStateEvent, RoomStateEventHandlerMap } from "./room-state";
@@ -70,6 +70,7 @@ import { RoomReceipts } from "./room-receipts";
import { compareEventOrdering } from "./compare-event-ordering";
import * as utils from "../utils";
import { KnownMembership, Membership } from "../@types/membership";
import { Capabilities, IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities";
// These constants are used as sane defaults when the homeserver doesn't support
// the m.room_versions capability. In practice, KNOWN_SAFE_ROOM_VERSION should be
@@ -397,7 +398,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Use getLiveTimeline().getState(EventTimeline.FORWARDS) instead.
*/
public currentState!: RoomState;
public readonly relations = new RelationsContainer(this.client, this);
public readonly relations;
/**
* A collection of events known by the client
@@ -405,12 +407,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
*/
private threads = new Map<string, Thread>();
/**
* @deprecated This value is unreliable. It may not contain the last thread.
* Use {@link Room.getLastThread} instead.
*/
public lastThread?: Thread;
/**
* A mapping of eventId to all visibility changes to apply
* to the event, by chronological order, as per
@@ -465,6 +461,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
private readonly opts: IOpts = {},
) {
super();
// In some cases, we add listeners for every displayed Matrix event, so it's
// common to have quite a few more than the default limit.
this.setMaxListeners(100);
@@ -475,6 +472,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
this.name = roomId;
this.normalizedName = roomId;
this.relations = new RelationsContainer(this.client, this);
// Listen to our own receipt event as a more modular way of processing our own
// receipts. No need to remove the listener: it's on ourself anyway.
this.on(RoomEvent.Receipt, this.onReceipt);
@@ -605,27 +604,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return createEvent.getContent()["room_version"] ?? "1";
}
/**
* Determines whether this room needs to be upgraded to a new version
* @returns What version the room should be upgraded to, or null if
* the room does not require upgrading at this time.
* @deprecated Use #getRecommendedVersion() instead
*/
public shouldUpgradeToVersion(): string | null {
// TODO: Remove this function.
// This makes assumptions about which versions are safe, and can easily
// be wrong. Instead, people are encouraged to use getRecommendedVersion
// which determines a safer value. This function doesn't use that function
// because this is not async-capable, and to avoid breaking the contract
// we're deprecating this.
if (!SAFE_ROOM_VERSIONS.includes(this.getVersion())) {
return KNOWN_SAFE_ROOM_VERSION;
}
return null;
}
/**
* Determines the recommended room version for the room. This returns an
* object with 3 properties: `version` as the new version the
@@ -638,7 +616,10 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Resolves to the version the room should be upgraded to.
*/
public async getRecommendedVersion(): Promise<IRecommendedVersion> {
const capabilities = await this.client.getCapabilities();
let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
let versionCap = capabilities["m.room_versions"];
if (!versionCap) {
versionCap = {
@@ -663,8 +644,12 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
"to be supporting a newer room version we don't know about.",
);
const caps = await this.client.getCapabilities(true);
versionCap = caps["m.room_versions"];
try {
capabilities = await this.client.fetchCapabilities();
} catch (e) {
logger.warn("Failed to refresh room version capabilities", e);
}
versionCap = capabilities["m.room_versions"];
if (!versionCap) {
logger.warn("No room version capability - assuming upgrade required.");
return result;
@@ -1403,32 +1388,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
}
}
/**
* Returns whether there are any devices in the room that are unverified
*
* Note: Callers should first check if crypto is enabled on this device. If it is
* disabled, then we aren't tracking room devices at all, so we can't answer this, and an
* error will be thrown.
*
* @returns the result
*
* @deprecated Not supported under rust crypto. Instead, call {@link Room.getEncryptionTargetMembers},
* {@link CryptoApi.getUserDeviceInfo}, and {@link CryptoApi.getDeviceVerificationStatus}.
*/
public async hasUnverifiedDevices(): Promise<boolean> {
if (!this.hasEncryptionStateEvent()) {
return false;
}
const e2eMembers = await this.getEncryptionTargetMembers();
for (const member of e2eMembers) {
const devices = this.client.getStoredDevicesForUser(member.userId);
if (devices.some((device) => device.isUnverified())) {
return true;
}
}
return false;
}
/**
* Return the timeline sets for this room.
* @returns array of timeline sets for this room
@@ -2479,15 +2438,6 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
// and pass the event through this.
thread.addEvents(events, false);
const isNewer =
this.lastThread?.rootEvent &&
rootEvent?.localTimestamp &&
this.lastThread.rootEvent?.localTimestamp < rootEvent?.localTimestamp;
if (!this.lastThread || isNewer) {
this.lastThread = thread;
}
// We need to update the thread root events, but the thread may not be ready yet.
// If it isn't, it will fire ThreadEvent.Update when it is and we'll call updateThreadRootEvents then.
if (this.threadsReady && thread.initialEventsFetched) {
@@ -2917,39 +2867,8 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* @param addLiveEventOptions - addLiveEvent options
* @throws If `duplicateStrategy` is not falsey, 'replace' or 'ignore'.
*/
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void>;
/**
* @deprecated In favor of the overload with `IAddLiveEventOptions`
*/
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategy?: DuplicateStrategy,
fromCache?: boolean,
): Promise<void>;
public async addLiveEvents(
events: MatrixEvent[],
duplicateStrategyOrOpts?: DuplicateStrategy | IAddLiveEventOptions,
fromCache = false,
): Promise<void> {
let duplicateStrategy: DuplicateStrategy | undefined = duplicateStrategyOrOpts as DuplicateStrategy;
let timelineWasEmpty: boolean | undefined = false;
if (typeof duplicateStrategyOrOpts === "object") {
({
duplicateStrategy,
fromCache = false,
/* roomState, (not used here) */
timelineWasEmpty,
} = duplicateStrategyOrOpts);
} else if (duplicateStrategyOrOpts !== undefined) {
// Deprecation warning
// FIXME: Remove after 2023-06-01 (technical debt)
logger.warn(
"Overload deprecated: " +
"`Room.addLiveEvents(events, duplicateStrategy?, fromCache?)` " +
"is deprecated in favor of the overload with `Room.addLiveEvents(events, IAddLiveEventOptions)`",
);
}
public async addLiveEvents(events: MatrixEvent[], addLiveEventOptions?: IAddLiveEventOptions): Promise<void> {
const { duplicateStrategy, fromCache, timelineWasEmpty = false } = addLiveEventOptions ?? {};
if (duplicateStrategy && ["replace", "ignore"].indexOf(duplicateStrategy) === -1) {
throw new Error("duplicateStrategy MUST be either 'replace' or 'ignore'");
}
@@ -3250,7 +3169,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
content: strippedEvent.content,
event_id: "$fake" + Date.now(),
room_id: this.roomId,
user_id: this.myUserId, // technically a lie
sender: this.myUserId, // technically a lie
}),
]);
}
+2
View File
@@ -49,6 +49,8 @@ interface OidcRegistrationRequestBody {
application_type: "web" | "native";
}
export const DEVICE_CODE_SCOPE = "urn:ietf:params:oauth:grant-type:device_code";
/**
* Attempts dynamic registration against the configured registration endpoint
* @param delegatedAuthConfig - Auth config from {@link discoverAndValidateOIDCIssuerWellKnown}
+15 -4
View File
@@ -16,13 +16,18 @@ limitations under the License.
import { UnstableValue } from "matrix-events-sdk";
import { RendezvousChannel, RendezvousFailureListener, RendezvousFailureReason, RendezvousIntent } from ".";
import { IGetLoginTokenCapability, MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import {
RendezvousChannel,
RendezvousFailureListener,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousIntent,
} from ".";
import { MatrixClient, GET_LOGIN_TOKEN_CAPABILITY } from "../client";
import { buildFeatureSupportMap, Feature, ServerSupport } from "../feature";
import { logger } from "../logger";
import { sleep } from "../utils";
import { CrossSigningKey } from "../crypto-api";
import { Device } from "../matrix";
import { Capabilities, Device, IGetLoginTokenCapability } from "../matrix";
enum PayloadType {
Start = "m.login.start",
@@ -59,6 +64,9 @@ const LOGIN_TOKEN_PROTOCOL = new UnstableValue("login_token", "org.matrix.msc390
* Implements MSC3906 to allow a user to sign in on a new device using QR code.
* This implementation only supports generating a QR code on a device that is already signed in.
* Note that this is UNSTABLE and may have breaking changes without notice.
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
* However, we want to keep this implementation around for some time.
* TODO: define an end-of-life date for this implementation.
*/
export class MSC3906Rendezvous {
private newDeviceId?: string;
@@ -101,7 +109,10 @@ export class MSC3906Rendezvous {
logger.info(`Connected to secure channel with checksum: ${checksum} our intent is ${this.ourIntent}`);
// in stable and unstable r1 the availability is exposed as a capability
const capabilities = await this.client.getCapabilities();
let capabilities: Capabilities = {};
try {
capabilities = await this.client.getCapabilities();
} catch (e) {}
// in r0 of MSC3882 the availability is exposed as a feature flag
const features = await buildFeatureSupportMap(await this.client.getVersions());
const capability = GET_LOGIN_TOKEN_CAPABILITY.findIn<IGetLoginTokenCapability>(capabilities);
+439
View File
@@ -0,0 +1,439 @@
/*
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 { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousError, RendezvousFailureListener } from ".";
import { MatrixClient } from "../client";
import { logger } from "../logger";
import { MSC4108SecureChannel } from "./channels/MSC4108SecureChannel";
import { MatrixError } from "../http-api";
import { sleep } from "../utils";
import { DEVICE_CODE_SCOPE, discoverAndValidateOIDCIssuerWellKnown, OidcClientConfig } from "../oidc";
import { CryptoApi } from "../crypto-api";
/**
* Enum representing the payload types transmissible over [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* secure channels.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export enum PayloadType {
Protocols = "m.login.protocols",
Protocol = "m.login.protocol",
Failure = "m.login.failure",
Success = "m.login.success",
Secrets = "m.login.secrets",
ProtocolAccepted = "m.login.protocol_accepted",
Declined = "m.login.declined",
}
/**
* Type representing the base payload format for [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* messages sent over the secure channel.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export interface MSC4108Payload {
type: PayloadType;
}
interface ProtocolsPayload extends MSC4108Payload {
type: PayloadType.Protocols;
protocols: string[];
homeserver: string;
}
interface ProtocolPayload extends MSC4108Payload {
type: PayloadType.Protocol;
protocol: Exclude<string, "device_authorization_grant">;
device_id: string;
}
interface DeviceAuthorizationGrantProtocolPayload extends ProtocolPayload {
protocol: "device_authorization_grant";
device_authorization_grant: {
verification_uri: string;
verification_uri_complete?: string;
};
}
function isDeviceAuthorizationGrantProtocolPayload(
payload: ProtocolPayload,
): payload is DeviceAuthorizationGrantProtocolPayload {
return payload.protocol === "device_authorization_grant";
}
interface FailurePayload extends MSC4108Payload {
type: PayloadType.Failure;
reason: MSC4108FailureReason;
homeserver?: string;
}
interface DeclinedPayload extends MSC4108Payload {
type: PayloadType.Declined;
}
interface SuccessPayload extends MSC4108Payload {
type: PayloadType.Success;
}
interface AcceptedPayload extends MSC4108Payload {
type: PayloadType.ProtocolAccepted;
}
interface SecretsPayload extends MSC4108Payload, Awaited<ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>>> {
type: PayloadType.Secrets;
}
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* sign in with QR + OIDC flow.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108SignInWithQR {
private readonly ourIntent: QrCodeMode;
private _code?: Uint8Array;
private expectingNewDeviceId?: string;
/**
* Returns the check code for the secure channel or undefined if not generated yet.
*/
public get checkCode(): string | undefined {
return this.channel?.getCheckCode();
}
/**
* @param channel - The secure channel used for communication
* @param client - The Matrix client in used on the device already logged in
* @param didScanCode - Whether this side of the channel scanned the QR code from the other party
* @param onFailure - Callback for when the rendezvous fails
*/
public constructor(
private readonly channel: MSC4108SecureChannel,
private readonly didScanCode: boolean,
private readonly client?: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {
this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login;
}
/**
* Returns the code representing the rendezvous suitable for rendering in a QR code or undefined if not generated yet.
*/
public get code(): Uint8Array | undefined {
return this._code;
}
/**
* Generate the code including doing partial set up of the channel where required.
*/
public async generateCode(): Promise<void> {
if (this._code) {
return;
}
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain()!);
} else if (this.ourIntent === QrCodeMode.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
}
/**
* Returns true if the device is the already logged in device reciprocating a new login on the other side of the channel.
*/
public get isExistingDevice(): boolean {
return this.ourIntent === QrCodeMode.Reciprocate;
}
/**
* Returns true if the device is the new device logging in being reciprocated by the device on the other side of the channel.
*/
public get isNewDevice(): boolean {
return !this.isExistingDevice;
}
/**
* The first step in the OIDC QR login process.
* To be called after the QR code has been rendered or scanned.
* The scanning device has to discover the homeserver details, if they scanned the code then they already have it.
* If the new device is the one rendering the QR code then it has to wait be sent the homeserver details via the rendezvous channel.
*/
public async negotiateProtocols(): Promise<{ serverName?: string }> {
logger.info(`negotiateProtocols(isNewDevice=${this.isNewDevice} didScanCode=${this.didScanCode})`);
await this.channel.connect();
if (this.didScanCode) {
// Secure Channel step 6 completed, we trust the channel
if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - take homeserver from QR code which should already be set
} else {
// MSC4108-Flow: NewScanned -send protocols message
let oidcClientConfig: OidcClientConfig | undefined;
try {
const { issuer } = await this.client!.getAuthIssuer();
oidcClientConfig = await discoverAndValidateOIDCIssuerWellKnown(issuer);
} catch (e) {
logger.error("Failed to discover OIDC metadata", e);
}
if (oidcClientConfig?.metadata.grant_types_supported.includes(DEVICE_CODE_SCOPE)) {
await this.send<ProtocolsPayload>({
type: PayloadType.Protocols,
protocols: ["device_authorization_grant"],
homeserver: this.client!.getDomain()!,
});
} else {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol,
});
throw new RendezvousError(
"Device code grant unsupported",
MSC4108FailureReason.UnsupportedProtocol,
);
}
}
} else if (this.isNewDevice) {
// MSC4108-Flow: ExistingScanned - wait for protocols message
logger.info("Waiting for protocols message");
const payload = await this.receive<ProtocolsPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocols) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
return { serverName: payload.homeserver };
} else {
// MSC4108-Flow: NewScanned - nothing to do
}
return {};
}
/**
* The second & third step in the OIDC QR login process.
* To be called after `negotiateProtocols` for the existing device.
* To be called after OIDC negotiation for the new device. (Currently unsupported)
*/
public async deviceAuthorizationGrant(): Promise<{
verificationUri?: string;
userCode?: string;
}> {
if (this.isNewDevice) {
throw new Error("New device flows around OIDC are not yet implemented");
} else {
// The user needs to do step 7 for the out-of-band confirmation
// but, first we receive the protocol chosen by the other device so that
// the confirmation_uri is ready to go
logger.info("Waiting for protocol message");
const payload = await this.receive<ProtocolPayload | DeviceAuthorizationGrantProtocolPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Protocol) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
if (isDeviceAuthorizationGrantProtocolPayload(payload)) {
const { device_authorization_grant: dag, device_id: expectingNewDeviceId } = payload;
const { verification_uri: verificationUri, verification_uri_complete: verificationUriComplete } = dag;
let deviceAlreadyExists = true;
try {
await this.client?.getDevice(expectingNewDeviceId);
} catch (err: MatrixError | unknown) {
if (err instanceof MatrixError && err.httpStatus === 404) {
deviceAlreadyExists = false;
}
}
if (deviceAlreadyExists) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceAlreadyExists,
});
throw new RendezvousError(
"Specified device ID already exists",
MSC4108FailureReason.DeviceAlreadyExists,
);
}
this.expectingNewDeviceId = expectingNewDeviceId;
return { verificationUri: verificationUriComplete ?? verificationUri };
}
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnsupportedProtocol,
});
throw new RendezvousError(
"Received a request for an unsupported protocol",
MSC4108FailureReason.UnsupportedProtocol,
);
}
}
/**
* The fifth (and final) step in the OIDC QR login process.
* To be called after the new device has completed authentication.
*/
public async shareSecrets(): Promise<{ secrets?: Omit<SecretsPayload, "type"> }> {
if (this.isNewDevice) {
await this.send<SuccessPayload>({
type: PayloadType.Success,
});
// then wait for secrets
logger.info("Waiting for secrets message");
const payload = await this.receive<SecretsPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type !== PayloadType.Secrets) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError(
"Unexpected message received",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
return { secrets: payload };
// then done?
} else {
if (!this.expectingNewDeviceId) {
throw new Error("No new device ID expected");
}
await this.send<AcceptedPayload>({
type: PayloadType.ProtocolAccepted,
});
logger.info("Waiting for outcome message");
const payload = await this.receive<SuccessPayload | DeclinedPayload>();
if (payload?.type === PayloadType.Failure) {
throw new RendezvousError("Failed", payload.reason);
}
if (payload?.type === PayloadType.Declined) {
throw new RendezvousError("User declined", ClientRendezvousFailureReason.UserDeclined);
}
if (payload?.type !== PayloadType.Success) {
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UnexpectedMessageReceived,
});
throw new RendezvousError("Unexpected message", MSC4108FailureReason.UnexpectedMessageReceived);
}
const timeout = Date.now() + 10000; // wait up to 10 seconds
do {
// is the device visible via the Homeserver?
try {
const device = await this.client?.getDevice(this.expectingNewDeviceId);
if (device) {
// if so, return the secrets
const secretsBundle = await this.client!.getCrypto()!.exportSecretsBundle!();
if (this.channel.cancelled) {
throw new RendezvousError("User cancelled", MSC4108FailureReason.UserCancelled);
}
// send secrets
await this.send<SecretsPayload>({
type: PayloadType.Secrets,
...secretsBundle,
});
return { secrets: secretsBundle };
// let the other side close the rendezvous session
}
} catch (err: MatrixError | unknown) {
if (err instanceof MatrixError && err.httpStatus === 404) {
// not found, so keep waiting until timeout
} else {
throw err;
}
}
await sleep(1000);
} while (Date.now() < timeout);
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.DeviceNotFound,
});
throw new RendezvousError("New device not found", MSC4108FailureReason.DeviceNotFound);
}
}
private async receive<T extends MSC4108Payload>(): Promise<T | FailurePayload | undefined> {
return (await this.channel.secureReceive()) as T | undefined;
}
private async send<T extends MSC4108Payload>(payload: T): Promise<void> {
await this.channel.secureSend(payload);
}
/**
* Decline the login on the existing device.
*/
public async declineLoginOnExistingDevice(): Promise<void> {
if (!this.isExistingDevice) {
throw new Error("Can only decline login on existing device");
}
await this.send<FailurePayload>({
type: PayloadType.Failure,
reason: MSC4108FailureReason.UserCancelled,
});
}
/**
* Cancels the rendezvous session.
* @param reason the reason for the cancellation
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
this.onFailure?.(reason);
await this.channel.cancel(reason);
}
/**
* Closes the rendezvous session.
*/
public async close(): Promise<void> {
await this.channel.close();
}
}
+37 -6
View File
@@ -16,16 +16,47 @@ limitations under the License.
export type RendezvousFailureListener = (reason: RendezvousFailureReason) => void;
export enum RendezvousFailureReason {
export type RendezvousFailureReason =
| LegacyRendezvousFailureReason
| MSC4108FailureReason
| ClientRendezvousFailureReason;
export enum LegacyRendezvousFailureReason {
UserDeclined = "user_declined",
OtherDeviceNotSignedIn = "other_device_not_signed_in",
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
Unknown = "unknown",
Expired = "expired",
UserCancelled = "user_cancelled",
InvalidCode = "invalid_code",
UnsupportedAlgorithm = "unsupported_algorithm",
DataMismatch = "data_mismatch",
UnsupportedTransport = "unsupported_transport",
UnsupportedProtocol = "unsupported_protocol",
HomeserverLacksSupport = "homeserver_lacks_support",
}
export enum MSC4108FailureReason {
AuthorizationExpired = "authorization_expired",
DeviceAlreadyExists = "device_already_exists",
DeviceNotFound = "device_not_found",
UnexpectedMessageReceived = "unexpected_message_received",
UnsupportedProtocol = "unsupported_protocol",
UserCancelled = "user_cancelled",
}
export enum ClientRendezvousFailureReason {
/** The sign in request has expired */
Expired = "expired",
/** The homeserver is lacking support for the required features */
HomeserverLacksSupport = "homeserver_lacks_support",
/** The secure channel verification failed meaning that it might be compromised */
InsecureChannelDetected = "insecure_channel_detected",
/** An invalid/incompatible QR code was scanned */
InvalidCode = "invalid_code",
/** The other device is not signed in */
OtherDeviceNotSignedIn = "other_device_not_signed_in",
/** The other device is already signed in */
OtherDeviceAlreadySignedIn = "other_device_already_signed_in",
/** Other */
Unknown = "unknown",
/** The user declined the sign in request */
UserDeclined = "user_declined",
/** The rendezvous request is missing an ETag header */
ETagMissing = "etag_missing",
}
@@ -23,7 +23,7 @@ import {
RendezvousChannel,
RendezvousTransportDetails,
RendezvousTransport,
RendezvousFailureReason,
LegacyRendezvousFailureReason as RendezvousFailureReason,
} from "..";
import { encodeUnpaddedBase64, decodeBase64 } from "../../base64";
import { crypto, subtleCrypto, TextEncoder } from "../../crypto/crypto";
@@ -69,6 +69,9 @@ async function importKey(key: Uint8Array): Promise<CryptoKey> {
* Implementation of the unstable [MSC3903](https://github.com/matrix-org/matrix-spec-proposals/pull/3903)
* X25519/ECDH key agreement based secure rendezvous channel.
* Note that this is UNSTABLE and may have breaking changes without notice.
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
* However, we want to keep this implementation around for some time.
* TODO: define an end-of-life date for this implementation.
*/
export class MSC3903ECDHv2RendezvousChannel<T> implements RendezvousChannel<T> {
private olmSAS?: SAS;
@@ -0,0 +1,270 @@
/*
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 {
Curve25519PublicKey,
Ecies,
EstablishedEcies,
QrCodeData,
QrCodeMode,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import {
ClientRendezvousFailureReason,
MSC4108FailureReason,
MSC4108Payload,
RendezvousError,
RendezvousFailureListener,
} from "..";
import { MSC4108RendezvousSession } from "../transports/MSC4108RendezvousSession";
import { logger } from "../../logger";
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* secure rendezvous session protocol.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
* Imports @matrix-org/matrix-sdk-crypto-wasm so should be async-imported to avoid bundling the WASM into the main bundle.
*/
export class MSC4108SecureChannel {
private readonly secureChannel: Ecies;
private establishedChannel?: EstablishedEcies;
private connected = false;
public constructor(
private rendezvousSession: MSC4108RendezvousSession,
private theirPublicKey?: Curve25519PublicKey,
public onFailure?: RendezvousFailureListener,
) {
this.secureChannel = new Ecies();
}
/**
* Generate a QR code for the current session.
* @param mode the mode to generate the QR code in, either `Login` or `Reciprocate`.
* @param serverName the name of the homeserver to connect to, as defined by server discovery in the spec, required for `Reciprocate` mode.
*/
public async generateCode(mode: QrCodeMode.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode.Reciprocate, serverName: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeMode, serverName?: string): Promise<Uint8Array> {
const { url } = this.rendezvousSession;
if (!url) {
throw new Error("No rendezvous session URL");
}
return new QrCodeData(
this.secureChannel.public_key(),
url,
mode === QrCodeMode.Reciprocate ? serverName : undefined,
).toBytes();
}
/**
* Returns the check code for the secure channel or undefined if not generated yet.
*/
public getCheckCode(): string | undefined {
const x = this.establishedChannel?.check_code();
if (!x) {
return undefined;
}
return Array.from(x.as_bytes())
.map((b) => `${b % 10}`)
.join("");
}
/**
* Connects and establishes a secure channel with the other device.
*/
public async connect(): Promise<void> {
if (this.connected) {
throw new Error("Channel already connected");
}
if (this.theirPublicKey) {
// We are the scanning device
const result = this.secureChannel.establish_outbound_channel(
this.theirPublicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
);
this.establishedChannel = result.channel;
/*
Secure Channel step 4. Device S sends the initial message
Nonce := 0
SH := ECDH(Ss, Gp)
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
TaggedCiphertext := ChaCha20Poly1305_Encrypt(EncKey, Nonce, "MATRIX_QR_CODE_LOGIN_INITIATE")
Nonce := Nonce + 2
LoginInitiateMessage := UnpaddedBase64(TaggedCiphertext) || "|" || UnpaddedBase64(Sp)
*/
{
logger.info("Sending LoginInitiateMessage");
await this.rendezvousSession.send(result.initial_message);
}
/*
Secure Channel step 6. Verification by Device S
Nonce_G := 1
(TaggedCiphertext, Sp) := Unpack(Message)
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_G, TaggedCiphertext)
Nonce_G := Nonce_G + 2
unless Plaintext == "MATRIX_QR_CODE_LOGIN_OK":
FAIL
*/
{
logger.info("Waiting for LoginOkMessage");
const ciphertext = await this.rendezvousSession.receive();
if (!ciphertext) {
throw new RendezvousError(
"No response from other device",
MSC4108FailureReason.UnexpectedMessageReceived,
);
}
const candidateLoginOkMessage = await this.decrypt(ciphertext);
if (candidateLoginOkMessage !== "MATRIX_QR_CODE_LOGIN_OK") {
throw new RendezvousError(
"Invalid response from other device",
ClientRendezvousFailureReason.InsecureChannelDetected,
);
}
// Step 6 is now complete. We trust the channel
}
} else {
/*
Secure Channel step 5. Device G confirms
Nonce_S := 0
(TaggedCiphertext, Sp) := Unpack(LoginInitiateMessage)
SH := ECDH(Gs, Sp)
EncKey := HKDF_SHA256(SH, "MATRIX_QR_CODE_LOGIN|" || Gp || "|" || Sp, 0, 32)
Plaintext := ChaCha20Poly1305_Decrypt(EncKey, Nonce_S, TaggedCiphertext)
Nonce_S := Nonce_S + 2
*/
// wait for the other side to send us their public key
logger.info("Waiting for LoginInitiateMessage");
const loginInitiateMessage = await this.rendezvousSession.receive();
if (!loginInitiateMessage) {
throw new Error("No response from other device");
}
const { channel, message: candidateLoginInitiateMessage } =
this.secureChannel.establish_inbound_channel(loginInitiateMessage);
this.establishedChannel = channel;
if (candidateLoginInitiateMessage !== "MATRIX_QR_CODE_LOGIN_INITIATE") {
throw new RendezvousError(
"Invalid response from other device",
ClientRendezvousFailureReason.InsecureChannelDetected,
);
}
logger.info("LoginInitiateMessage received");
logger.info("Sending LoginOkMessage");
const loginOkMessage = await this.encrypt("MATRIX_QR_CODE_LOGIN_OK");
await this.rendezvousSession.send(loginOkMessage);
// Step 5 is complete. We don't yet trust the channel
// next step will be for the user to confirm the check code on the other device
}
this.connected = true;
}
private async decrypt(ciphertext: string): Promise<string> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
return this.establishedChannel.decrypt(ciphertext);
}
private async encrypt(plaintext: string): Promise<string> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
return this.establishedChannel.encrypt(plaintext);
}
/**
* Sends a payload securely to the other device.
* @param payload the payload to encrypt and send
*/
public async secureSend<T extends MSC4108Payload>(payload: T): Promise<void> {
if (!this.connected) {
throw new Error("Channel closed");
}
const stringifiedPayload = JSON.stringify(payload);
logger.debug(`=> {"type": ${JSON.stringify(payload.type)}, ...}`);
await this.rendezvousSession.send(await this.encrypt(stringifiedPayload));
}
/**
* Receives an encrypted payload from the other device and decrypts it.
*/
public async secureReceive<T extends MSC4108Payload>(): Promise<Partial<T> | undefined> {
if (!this.establishedChannel) {
throw new Error("Channel closed");
}
const ciphertext = await this.rendezvousSession.receive();
if (!ciphertext) {
return undefined;
}
const plaintext = await this.decrypt(ciphertext);
const json = JSON.parse(plaintext);
logger.debug(`<= {"type": ${JSON.stringify(json.type)}, ...}`);
return json as Partial<T> | undefined;
}
/**
* Closes the secure channel.
*/
public async close(): Promise<void> {
await this.rendezvousSession.close();
}
/**
* Cancels the secure channel.
* @param reason the reason for the cancellation
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
try {
await this.rendezvousSession.cancel(reason);
this.onFailure?.(reason);
} finally {
await this.close();
}
}
/**
* Returns whether the rendezvous session has been cancelled.
*/
public get cancelled(): boolean {
return this.rendezvousSession.cancelled;
}
}
+4
View File
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3903ECDHv2RendezvousChannel";
export * from "./MSC4108SecureChannel";
+6
View File
@@ -14,10 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3906Rendezvous";
export * from "./MSC4108SignInWithQR";
export * from "./RendezvousChannel";
export * from "./RendezvousCode";
export * from "./RendezvousError";
export * from "./RendezvousFailureReason";
export * from "./RendezvousIntent";
export * from "./RendezvousTransport";
export * from "./transports";
export * from "./channels";
@@ -20,7 +20,7 @@ import { logger } from "../../logger";
import { sleep } from "../../utils";
import {
RendezvousFailureListener,
RendezvousFailureReason,
LegacyRendezvousFailureReason as RendezvousFailureReason,
RendezvousTransport,
RendezvousTransportDetails,
} from "..";
@@ -37,6 +37,9 @@ export interface MSC3886SimpleHttpRendezvousTransportDetails extends RendezvousT
* Implementation of the unstable [MSC3886](https://github.com/matrix-org/matrix-spec-proposals/pull/3886)
* simple HTTP rendezvous protocol.
* Note that this is UNSTABLE and may have breaking changes without notice.
* MSC3886/MSC3903/MSC3906 are now closed and so this functionality will be removed in future.
* However, we want to keep this implementation around for some time.
* TODO: define an end-of-life date for this implementation.
*/
export class MSC3886SimpleHttpRendezvousTransport<T extends {}> implements RendezvousTransport<T> {
private uri?: string;
@@ -0,0 +1,270 @@
/*
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 { logger } from "../../logger";
import { sleep } from "../../utils";
import { ClientRendezvousFailureReason, MSC4108FailureReason, RendezvousFailureListener } from "..";
import { MatrixClient, Method } from "../../matrix";
import { ClientPrefix } from "../../http-api";
/**
* Prototype of the unstable [MSC4108](https://github.com/matrix-org/matrix-spec-proposals/pull/4108)
* insecure rendezvous session protocol.
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108RendezvousSession {
public url?: string;
private readonly client?: MatrixClient;
private readonly fallbackRzServer?: string;
private readonly fetchFn?: typeof global.fetch;
private readonly onFailure?: RendezvousFailureListener;
private etag?: string;
private expiresAt?: Date;
private expiresTimer?: ReturnType<typeof setTimeout>;
private _cancelled = false;
private _ready = false;
public constructor({
onFailure,
url,
fetchFn,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
url: string;
});
public constructor({
onFailure,
client,
fallbackRzServer,
fetchFn,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
client?: MatrixClient;
fallbackRzServer?: string;
});
public constructor({
fetchFn,
onFailure,
url,
client,
fallbackRzServer,
}: {
fetchFn?: typeof global.fetch;
onFailure?: RendezvousFailureListener;
url?: string;
client?: MatrixClient;
fallbackRzServer?: string;
}) {
this.fetchFn = fetchFn;
this.onFailure = onFailure;
this.client = client;
this.fallbackRzServer = fallbackRzServer;
this.url = url;
}
/**
* Returns whether the channel is ready to be used.
*/
public get ready(): boolean {
return this._ready;
}
/**
* Returns whether the channel has been cancelled.
*/
public get cancelled(): boolean {
return this._cancelled;
}
private fetch(resource: URL | string, options?: RequestInit): ReturnType<typeof global.fetch> {
if (this.fetchFn) {
return this.fetchFn(resource, options);
}
return global.fetch(resource, options);
}
private async getPostEndpoint(): Promise<string | undefined> {
if (this.client) {
try {
if (await this.client.doesServerSupportUnstableFeature("org.matrix.msc4108")) {
return this.client.http
.getUrl("/org.matrix.msc4108/rendezvous", undefined, ClientPrefix.Unstable)
.toString();
}
} catch (err) {
logger.warn("Failed to get unstable features", err);
}
}
return this.fallbackRzServer;
}
/**
* Sends data via the rendezvous channel.
* @param data the payload to send
*/
public async send(data: string): Promise<void> {
if (this._cancelled) {
return;
}
const method = this.url ? Method.Put : Method.Post;
const uri = this.url ?? (await this.getPostEndpoint());
if (!uri) {
throw new Error("Invalid rendezvous URI");
}
const headers: Record<string, string> = { "content-type": "text/plain" };
// if we didn't create the rendezvous channel, we need to fetch the first etag if needed
if (!this.etag && this.url) {
await this.receive();
}
if (this.etag) {
headers["if-match"] = this.etag;
}
logger.info(`=> ${method} ${uri} with ${data} if-match: ${this.etag}`);
const res = await this.fetch(uri, { method, headers, body: data, redirect: "follow" });
if (res.status === 404) {
return this.cancel(ClientRendezvousFailureReason.Unknown);
}
this.etag = res.headers.get("etag") ?? undefined;
logger.info(`Received etag: ${this.etag}`);
if (method === Method.Post) {
const expires = res.headers.get("expires");
if (expires) {
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
this.expiresAt = new Date(expires);
this.expiresTimer = setTimeout(() => {
this.expiresTimer = undefined;
this.cancel(ClientRendezvousFailureReason.Expired);
}, this.expiresAt.getTime() - Date.now());
}
// MSC4108: we expect a JSON response with a rendezvous URL
const json = await res.json();
if (typeof json.url !== "string") {
throw new Error("No rendezvous URL given");
}
this.url = json.url;
this._ready = true;
}
}
/**
* Receives data from the rendezvous channel.
* @return the returned promise won't resolve until new data is acquired or the channel is closed either by the server or the other party.
*/
public async receive(): Promise<string | undefined> {
if (!this.url) {
throw new Error("Rendezvous not set up");
}
// eslint-disable-next-line no-constant-condition
while (true) {
if (this._cancelled) {
return undefined;
}
const headers: Record<string, string> = {};
if (this.etag) {
headers["if-none-match"] = this.etag;
}
logger.info(`=> GET ${this.url} if-none-match: ${this.etag}`);
const poll = await this.fetch(this.url, { method: Method.Get, headers });
if (poll.status === 404) {
await this.cancel(ClientRendezvousFailureReason.Unknown);
return undefined;
}
// rely on server expiring the channel rather than checking ourselves
const etag = poll.headers.get("etag") ?? undefined;
if (poll.headers.get("content-type") !== "text/plain") {
this.etag = etag;
} else if (poll.status === 200) {
if (!etag) {
// Some browsers & extensions block the ETag header for anti-tracking purposes
// We try and detect this so the client can give the user a somewhat helpful message
await this.cancel(ClientRendezvousFailureReason.ETagMissing);
return undefined;
}
this.etag = etag;
const text = await poll.text();
logger.info(`Received: ${text} with etag ${this.etag}`);
return text;
}
await sleep(1000);
}
}
/**
* Cancels the rendezvous channel.
* If the reason is user_declined or user_cancelled then the channel will also be closed.
* @param reason the reason to cancel with
*/
public async cancel(reason: MSC4108FailureReason | ClientRendezvousFailureReason): Promise<void> {
if (this._cancelled) return;
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
if (
reason === ClientRendezvousFailureReason.Unknown &&
this.expiresAt &&
this.expiresAt.getTime() < Date.now()
) {
reason = ClientRendezvousFailureReason.Expired;
}
this._cancelled = true;
this._ready = false;
this.onFailure?.(reason);
if (reason === ClientRendezvousFailureReason.UserDeclined || reason === MSC4108FailureReason.UserCancelled) {
await this.close();
}
}
/**
* Closes the rendezvous channel.
*/
public async close(): Promise<void> {
if (this.expiresTimer) {
clearTimeout(this.expiresTimer);
this.expiresTimer = undefined;
}
if (!this.url) return;
try {
await this.fetch(this.url, { method: Method.Delete });
} catch (e) {
logger.warn(e);
}
}
}
+4
View File
@@ -14,4 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
/**
* @deprecated in favour of MSC4108-based implementation
*/
export * from "./MSC3886SimpleHttpRendezvousTransport";
export * from "./MSC4108RendezvousSession";
@@ -57,10 +57,14 @@ class KeyDownloadRateLimitError extends Error {
/** Details of a megolm session whose key we are trying to fetch. */
type SessionInfo = { roomId: string; megolmSessionId: string };
/** Holds the current backup decryptor and version that should be used. */
/** Holds the current backup decryptor and version that should be used.
*
* This is intended to be used as an immutable object (a new instance should be created if the configuration changes),
* and some of the logic relies on that, so the properties are marked as `readonly`.
*/
type Configuration = {
backupVersion: string;
decryptor: BackupDecryptor;
readonly backupVersion: string;
readonly decryptor: BackupDecryptor;
};
/**
@@ -392,7 +396,7 @@ export class PerSessionKeyBackupDownloader {
for (const k of keys) {
k.room_id = sessionInfo.roomId;
}
await this.backupManager.importBackedUpRoomKeys(keys);
await this.backupManager.importBackedUpRoomKeys(keys, configuration.backupVersion);
}
/**
+6 -1
View File
@@ -239,7 +239,11 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/**
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
*/
public async importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
public async importBackedUpRoomKeys(
keys: IMegolmSessionData[],
backupVersion: string,
opts?: ImportRoomKeysOpts,
): Promise<void> {
const keysByRoom: Map<RustSdkCryptoJs.RoomId, Map<string, IMegolmSessionData>> = new Map();
for (const key of keys) {
const roomId = new RustSdkCryptoJs.RoomId(key.room_id);
@@ -259,6 +263,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
};
opts?.progressCallback?.(importOpt);
},
backupVersion,
);
}
+21 -7
View File
@@ -64,13 +64,21 @@ export async function initRustCrypto(args: {
storePrefix: string | null;
/**
* A passphrase to use to encrypt the indexeddbs created by rust-crypto.
* A passphrase to use to encrypt the indexeddb created by rust-crypto.
*
* Ignored if `storePrefix` is null. If this is `undefined` (and `storePrefix` is not null), the indexeddbs
* will be unencrypted.
* Ignored if `storePrefix` is null, or `storeKey` is set. If neither this nor `storeKey` is set
* (and `storePrefix` is not null), the indexeddb will be unencrypted.
*/
storePassphrase?: string;
/**
* A key to use to encrypt the indexeddb created by rust-crypto.
*
* Ignored if `storePrefix` is null. Otherwise, if it is set, it must be a 32-byte cryptographic key, which
* will be used to encrypt the indexeddb. See also `storePassphrase`.
*/
storeKey?: Uint8Array;
/** If defined, we will check if any data needs migrating from this store to the rust store. */
legacyCryptoStore?: CryptoStore;
@@ -94,10 +102,16 @@ export async function initRustCrypto(args: {
new RustSdkCryptoJs.Tracing(RustSdkCryptoJs.LoggerLevel.Debug).turnOn();
logger.debug("Opening Rust CryptoStore");
const storeHandle: StoreHandle = await StoreHandle.open(
args.storePrefix ?? undefined,
(args.storePrefix && args.storePassphrase) ?? undefined,
);
let storeHandle;
if (args.storePrefix) {
if (args.storeKey) {
storeHandle = await StoreHandle.openWithKey(args.storePrefix, args.storeKey);
} else {
storeHandle = await StoreHandle.open(args.storePrefix, args.storePassphrase);
}
} else {
storeHandle = await StoreHandle.open();
}
if (args.legacyCryptoStore) {
// We have a legacy crypto store, which we may need to migrate from.
+13
View File
@@ -83,6 +83,19 @@ export async function migrateFromLegacyCrypto(args: {
}
await legacyStore.startup();
let accountPickle: string | null = null;
await legacyStore.doTxn("readonly", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
legacyStore.getAccount(txn, (acctPickle) => {
accountPickle = acctPickle;
});
});
if (!accountPickle) {
// This store is not properly set up. Nothing to migrate.
logger.debug("Legacy crypto store is not set up (no account found). Not migrating.");
return;
}
let migrationState = await legacyStore.getMigrationState();
if (migrationState >= MigrationState.MEGOLM_SESSIONS_MIGRATED) {
+63 -37
View File
@@ -38,6 +38,7 @@ import {
CrossSigningKey,
CrossSigningKeyInfo,
CrossSigningStatus,
CryptoApi,
CryptoCallbacks,
Curve25519AuthData,
DecryptionFailureCode,
@@ -308,6 +309,40 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
return;
}
/**
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
*/
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
}
const authData = <Curve25519AuthData>backupInfo.auth_data;
if (!(privKey instanceof Uint8Array)) {
throw new Error(`getBackupDecryptor expects Uint8Array`);
}
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
throw new Error(`getBackupDecryptor key mismatch error`);
}
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
}
/**
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
*/
public async importBackedUpRoomKeys(
keys: IMegolmSessionData[],
backupVersion: string,
opts?: ImportRoomKeysOpts,
): Promise<void> {
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// CryptoApi implementation
@@ -865,7 +900,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
}
/**
* Implementation of {@link CryptoApi.getEncryptionInfoForEvent}.
* Implementation of {@link Crypto.CryptoApi.getEncryptionInfoForEvent}.
*/
public async getEncryptionInfoForEvent(event: MatrixEvent): Promise<EventEncryptionInfo | null> {
return this.eventDecryptor.getEncryptionInfoForEvent(event);
@@ -1065,10 +1100,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
}
try {
const [request, outgoingRequest]: [RustSdkCryptoJs.VerificationRequest, RustSdkCryptoJs.ToDeviceRequest] =
await device.requestVerification(
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
const [request, outgoingRequest] = device.requestVerification(
this._supportedVerificationMethods.map(verificationMethodIdentifierToMethod),
);
await this.outgoingRequestProcessor.makeOutgoingRequest(outgoingRequest);
return new RustVerificationRequest(
this.olmMachine,
@@ -1169,6 +1203,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
* Signs the given object with the current device and current identity (if available).
* As defined in {@link https://spec.matrix.org/v1.8/appendices/#signing-json | Signing JSON}.
*
* Helper for {@link RustCrypto#resetKeyBackup}.
*
* @param obj - The object to sign
*/
private async signObject<T extends ISignableObject & object>(obj: T): Promise<void> {
@@ -1192,44 +1228,14 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
}
/**
* Implementation of {@link CryptoBackend#getBackupDecryptor}.
*/
public async getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor> {
if (backupInfo.algorithm != "m.megolm_backup.v1.curve25519-aes-sha2") {
throw new Error(`getBackupDecryptor Unsupported algorithm ${backupInfo.algorithm}`);
}
const authData = <Curve25519AuthData>backupInfo.auth_data;
if (!(privKey instanceof Uint8Array)) {
throw new Error(`getBackupDecryptor expects Uint8Array`);
}
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(encodeBase64(privKey));
if (authData.public_key != backupDecryptionKey.megolmV1PublicKey.publicKeyBase64) {
throw new Error(`getBackupDecryptor key mismatch error`);
}
return this.backupManager.createBackupDecryptor(backupDecryptionKey);
}
/**
* Implementation of {@link CryptoBackend#importBackedUpRoomKeys}.
*/
public async importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void> {
return await this.backupManager.importBackedUpRoomKeys(keys, opts);
}
/**
* Implementation of {@link CryptoBackend#isDehydrationSupported}.
* Implementation of {@link CryptoApi#isDehydrationSupported}.
*/
public async isDehydrationSupported(): Promise<boolean> {
return await this.dehydratedDeviceManager.isSupported();
}
/**
* Implementation of {@link CryptoBackend#startDehydration}.
* Implementation of {@link CryptoApi#startDehydration}.
*/
public async startDehydration(createNewKey?: boolean): Promise<void> {
if (!(await this.isCrossSigningReady()) || !(await this.isSecretStorageReady())) {
@@ -1238,6 +1244,26 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, RustCryptoEv
return await this.dehydratedDeviceManager.start(createNewKey);
}
/**
* Implementation of {@link CryptoApi#importSecretsBundle}.
*/
public async importSecretsBundle(
secrets: Parameters<NonNullable<CryptoApi["importSecretsBundle"]>>[0],
): Promise<void> {
const secretsBundle = RustSdkCryptoJs.SecretsBundle.from_json(secrets);
await this.getOlmMachineOrThrow().importSecretsBundle(secretsBundle); // this method frees the SecretsBundle
}
/**
* Implementation of {@link CryptoApi#exportSecretsBundle}.
*/
public async exportSecretsBundle(): ReturnType<NonNullable<CryptoApi["exportSecretsBundle"]>> {
const secretsBundle = await this.getOlmMachineOrThrow().exportSecretsBundle();
const secrets = secretsBundle.to_json();
secretsBundle.free();
return secrets;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// SyncCryptoCallbacks implementation
+49 -27
View File
@@ -72,30 +72,40 @@ export class RustVerificationRequest
private readonly supportedVerificationMethods: string[],
) {
super();
this.reEmitter = new TypedReEmitter(this);
const onChange = async (): Promise<void> => {
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
// Obviously, the Rust object maintains a reference to the callback function. If the callback function maintains
// a reference to the Rust object, then we have a reference cycle which means that `RustVerificationRequest`
// will never be garbage-collected, and hence the underlying rust object will never be freed.
//
// To avoid this reference cycle, use a weak reference in the callback function. If the `RustVerificationRequest`
// gets garbage-collected, then there is nothing to update!
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
}
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
// - we now have a `Verification` where we lacked one before
// - we have transitioned from QR to SAS
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
// tried to start verification at the same time, and we lost the tie breaking
if (verification instanceof RustSdkCryptoJs.Sas) {
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
this.setVerifier(new RustSASVerifier(verification, this, outgoingRequestProcessor));
} else if (this._verifier instanceof RustSASVerifier) {
this._verifier.replaceInner(verification);
}
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
this.setVerifier(new RustQrCodeVerifier(verification, outgoingRequestProcessor));
/**
* Hook which is called when the underlying rust class notifies us that there has been a change.
*/
private onChange(): void {
const verification: RustSdkCryptoJs.Qr | RustSdkCryptoJs.Sas | undefined = this.inner.getVerification();
// Set the _verifier object (wrapping the rust `Verification` as a js-sdk Verifier) if:
// - we now have a `Verification` where we lacked one before
// - we have transitioned from QR to SAS
// - we are verifying with SAS, but we need to replace our verifier with a new one because both parties
// tried to start verification at the same time, and we lost the tie breaking
if (verification instanceof RustSdkCryptoJs.Sas) {
if (this._verifier === undefined || this._verifier instanceof RustQrCodeVerifier) {
this.setVerifier(new RustSASVerifier(verification, this, this.outgoingRequestProcessor));
} else if (this._verifier instanceof RustSASVerifier) {
this._verifier.replaceInner(verification);
}
} else if (verification instanceof RustSdkCryptoJs.Qr && this._verifier === undefined) {
this.setVerifier(new RustQrCodeVerifier(verification, this.outgoingRequestProcessor));
}
this.emit(VerificationRequestEvent.Change);
};
inner.registerChangesCallback(onChange);
this.emit(VerificationRequestEvent.Change);
}
private setVerifier(verifier: RustSASVerifier | RustQrCodeVerifier): void {
@@ -473,9 +483,12 @@ abstract class BaseRustVerifer<InnerType extends RustSdkCryptoJs.Qr | RustSdkCry
super();
this.completionDeferred = defer();
inner.registerChangesCallback(async () => {
this.onChange();
});
// As with RustVerificationRequest, we need to avoid a reference cycle.
// See the comments in RustVerificationRequest.
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
// stop the runtime complaining if nobody catches a failure
this.completionDeferred.promise.catch(() => null);
}
@@ -710,10 +723,16 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
}
},
mismatch: (): void => {
throw new Error("impl");
const request = this.inner.cancelWithCode("m.mismatched_sas");
if (request) {
this.outgoingRequestProcessor.makeOutgoingRequest(request);
}
},
cancel: (): void => {
throw new Error("impl");
const request = this.inner.cancelWithCode("m.user");
if (request) {
this.outgoingRequestProcessor.makeOutgoingRequest(request);
}
},
};
this.emit(VerifierEvent.ShowSas, this.callbacks);
@@ -746,9 +765,12 @@ export class RustSASVerifier extends BaseRustVerifer<RustSdkCryptoJs.Sas> implem
public replaceInner(inner: RustSdkCryptoJs.Sas): void {
if (this.inner != inner) {
this.inner = inner;
inner.registerChangesCallback(async () => {
this.onChange();
});
// As with RustVerificationRequest, we need to avoid a reference cycle.
// See the comments in RustVerificationRequest.
const weakThis = new WeakRef(this);
inner.registerChangesCallback(async () => weakThis.deref()?.onChange());
// replaceInner will only get called if we started the verification at the same time as the other side, and we lost
// the tie breaker. So we need to re-accept their verification.
this.sendAccept();
+136
View File
@@ -0,0 +1,136 @@
/*
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 { IHttpOpts, MatrixHttpApi, Method } from "./http-api";
import { logger } from "./logger";
// How often we update the server capabilities.
// 6 hours - an arbitrary value, but they should change very infrequently.
const CAPABILITIES_CACHE_MS = 6 * 60 * 60 * 1000;
// How long we want before retrying if we couldn't fetch
const CAPABILITIES_RETRY_MS = 30 * 1000;
export interface ICapability {
enabled: boolean;
}
export interface IChangePasswordCapability extends ICapability {}
export interface IThreadsCapability extends ICapability {}
export interface IGetLoginTokenCapability extends ICapability {}
export interface ISetDisplayNameCapability extends ICapability {}
export interface ISetAvatarUrlCapability extends ICapability {}
export enum RoomVersionStability {
Stable = "stable",
Unstable = "unstable",
}
export interface IRoomVersionsCapability {
default: string;
available: Record<string, RoomVersionStability>;
}
/**
* 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;
"m.set_displayname"?: ISetDisplayNameCapability;
"m.set_avatar_url"?: ISetAvatarUrlCapability;
}
type CapabilitiesResponse = {
capabilities: Capabilities;
};
/**
* Manages storing and periodically refreshing the server capabilities.
*/
export class ServerCapabilities {
private capabilities?: Capabilities;
private retryTimeout?: ReturnType<typeof setTimeout>;
private refreshTimeout?: ReturnType<typeof setInterval>;
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
/**
* Starts periodically fetching the server capabilities.
*/
public start(): void {
this.poll().then();
}
/**
* Stops the service
*/
public stop(): void {
this.clearTimeouts();
}
/**
* Returns the cached capabilities, or undefined if none are cached.
* @returns the current capabilities, if any.
*/
public getCachedCapabilities(): Capabilities | undefined {
return this.capabilities;
}
/**
* Fetches the latest server capabilities from the homeserver and returns them, or rejects
* on failure.
*/
public fetchCapabilities = async (): Promise<Capabilities> => {
const resp = await this.http.authedRequest<CapabilitiesResponse>(Method.Get, "/capabilities");
this.capabilities = resp["capabilities"];
return this.capabilities;
};
private poll = async (): Promise<void> => {
try {
await this.fetchCapabilities();
this.clearTimeouts();
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
logger.debug("Fetched new server capabilities");
} catch (e) {
this.clearTimeouts();
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
this.retryTimeout = setTimeout(this.poll, howLong);
logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
}
};
private clearTimeouts(): void {
if (this.refreshTimeout) {
clearInterval(this.refreshTimeout);
this.refreshTimeout = undefined;
}
if (this.retryTimeout) {
clearTimeout(this.retryTimeout);
this.retryTimeout = undefined;
}
}
}

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