Compare commits

..

130 Commits

Author SHA1 Message Date
Michael Telatynski 14b2ee2da4 Remove dependency on uuid (#5297)
Notify Downstream Projects / notify-downstream (element-web-notify, element-hq/element-web) (push) Has been skipped
Static Analysis / Typescript Syntax Check (push) Failing after 1m36s
Static Analysis / ESLint (push) Failing after 31s
Static Analysis / Node.js example (push) Failing after 42s
Static Analysis / Workflow Lint (push) Failing after 9m59s
Static Analysis / JSDoc Checker (push) Failing after 50s
Static Analysis / Analyse Dead Code (push) Failing after 36s
Static Analysis / Downstream tsc element-web (push) Has been skipped
Tests / Vitest [integ] (Node 22) (push) Failing after 4s
Tests / Vitest [unit] (Node 22) (push) Failing after 39s
Tests / Vitest [integ] (Node lts/*) (push) Failing after 39s
Tests / Vitest [unit] (Node lts/*) (push) Failing after 35s
Tests / Downstream test element-web (push) Has been skipped
Tests / Run Complement Crypto tests (push) Has been skipped
Static Analysis / Static Analysis (push) Successful in 1s
Tests / Tests (push) Failing after 1s
Tests / Downstream Complement Crypto tests (push) Successful in 1s
Tests / Downstream tests (push) Successful in 4s
2026-04-23 09:53:18 +00:00
renovate[bot] bb083222d9 Update dependency vite to v8 (#5295)
* Update dependency vite to v8

* Update lockfile

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-04-22 16:16:29 +00:00
Andy Balaam fa424c44b4 Support stable identifiers for MSC4268 (#5290)
* Support stable identifier m.room_key_bundle

* Support stable identifier m.shared_history

* Test that checks isRoomKeyBundleMessage works for stable and unstable identifiers

* Replace similar tests with use of it.each
2026-04-22 09:10:39 +00:00
renovate[bot] fef093747e Update dependency @typescript-eslint/eslint-plugin to v8.59.0 (#5292)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 14:38:05 +00:00
renovate[bot] 4b33892d48 Update npm non-major dependencies (#5293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 13:58:22 +00:00
renovate[bot] d7d771fadb Update vite to v4.1.5 (#5291)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-21 12:37:38 +00:00
Hubert Chathi 4ee3e591bf Handle secret pushing for key backups (#5189)
* push backup key to other verified devices when we reset backup

* handle receiving pushed backup keys

- make sure that backup gets enabled after we receive a pushed key that
  matches the current, valid backup

* apply requested changes from review
2026-04-20 21:48:51 +00:00
renovate[bot] 668183d722 Update vite to v4.1.4 (#5289)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 11:25:37 +00:00
renovate[bot] 854dae0dc0 Update typescript (#5288)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 10:01:43 +00:00
renovate[bot] 9d1aca2232 Update eslint-plugins (#5286)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 09:11:28 +00:00
renovate[bot] 81569f3461 Update actions/setup-node digest to 48b55a0 (#5285)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:50:10 +00:00
renovate[bot] 50783aba76 Update npm non-major dependencies (#5287)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-20 08:17:20 +00:00
Michael Telatynski fd01b17236 Tidy knip config (#5284) 2026-04-17 13:00:21 +00:00
Will Hunt 25e92009b7 Throw more helpful errors from indexedb-local-backend (#5282)
* Throw more helpful errors from indexedb-local-backend

* Add a test

* Do not tempt fate
2026-04-17 08:33:56 +00:00
Michael Telatynski eb7acfb810 Add support for m.recent_emoji account data event (#5280) 2026-04-16 21:59:30 +00:00
Andy Balaam ca5655bced Rotate the room key when anyone leaves a room for any reason (#5279) 2026-04-16 12:09:42 +00:00
Michael Telatynski ef9b13e2a6 Fix coverage reports clobbering each other (#5267)
merge-multiple would silently drop files with clashing names - it ultimately isn't necessary given the `find` command will happily find them in nested subdirs
2026-04-16 11:48:51 +00:00
fkwp 159cca0363 Adapt LiveKit Identity hash calculation to latest MSC4195 update (#5268)
* adapt hash calculation to latest MSC4195 update.

Stop using '|' delimiters in hashes; use JSON arrays + canonical JSON instead

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* Update the test for RTC backend identities and add tests for calculating the identity hash.

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* linting

Signed-off-by: fkwp <github-fkwp@w4ve.de>

* add copyright header

---------

Signed-off-by: fkwp <github-fkwp@w4ve.de>
2026-04-16 09:48:11 +00:00
renovate[bot] 3879111850 Update typescript (#5274)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 11:37:50 +00:00
Andy Balaam f9a5aa87e3 Support the stable prefix for MSC4287 (key backup preference) (#5258)
* Support the stable prefix for MSC4287 (key backup preference)

* Remove incorrect doc coment on disableKeyStorage
2026-04-15 11:07:48 +00:00
renovate[bot] ed58df040c Update eslint-plugins (#5276)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:51:25 +00:00
Michael Telatynski 0a3448d4c9 Fix references to matrix-react-sdk in CI (#5269) 2026-04-15 09:25:54 +00:00
renovate[bot] 25c1c1ea26 Update actions/github-script action to v9 (#5277)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 09:11:09 +00:00
renovate[bot] a5e67af31f Update actions/upload-pages-artifact action to v5 (#5278)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:38 +00:00
renovate[bot] b91e80814a Update zizmorcore/zizmor-action action to v0.5.3 (#5275)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:37:07 +00:00
renovate[bot] d096a72605 Update npm non-major dependencies (#5273)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:56 +00:00
renovate[bot] fb547e7b4b Update actions/upload-artifact digest to 043fb46 (#5272)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-15 08:36:46 +00:00
Michael Telatynski 815294ca5a Allow oidc jwks_uri to be omitted (#5271)
* Allow oidc jwks_uri to be null

As signing keys are seemingly not (yet?) in the spec

* Add test coverage
2026-04-15 08:15:29 +00:00
Michael Telatynski 6d270b4685 Handle response_mode=fragment in completeAuthorizationCodeGrant (#5266)
* Handle response_mode=fragment in completeAuthorizationCodeGrant

* Add test

* Fix docs
2026-04-14 14:59:10 +00:00
Michael Telatynski 8bc3d96f6b Allow generating OIDC URIs with response_mode=fragment (#5265) 2026-04-13 22:53:05 +00:00
Richard van der Hoff b6ea6e105e Log clarifications for handleBackupSecretReceived (#5233)
Some changes to make this a bit easier to understand.
2026-04-10 12:33:30 +00:00
Valere Fedronic cd4e053fa5 Suppress and Reduce noisy logs for rtc (#5260) 2026-04-09 14:24:36 +00:00
Andy Balaam 727473af62 Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server (#5256)
* Expand the comment on CryptoApi.getUserDeviceInfo saying we request info from the server

* Update comment to reflect waiting for in-progress requests, not making new ones

* Update the comment for userHasCrossSigningKeys too
2026-04-09 14:19:22 +00:00
Michael Telatynski f17f013f1e Satisfy pnpm audit (#5262) 2026-04-09 11:52:19 +00:00
renovate[bot] 9f4ab0b840 Update matrix-org/sonarcloud-workflow-action digest to 13968a2 (#5263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-04-08 13:28:46 +00:00
Michael Telatynski 6371e4b252 Update pnpm command for version bumping 2026-04-08 08:23:52 +01:00
Michael Telatynski b69929e01a Allow release-make to bump multiple package.json versions (#5261) 2026-04-07 14:44:15 +00:00
RiotRobot 9dc12baaa9 Merge branch 'master' into develop 2026-04-07 13:17:46 +00:00
RiotRobot 159738597d v41.3.0 2026-04-07 13:17:11 +00:00
Andy Balaam d02205652f Re-enable Complement Crypto tests (#5257)
Since https://github.com/matrix-org/complement-crypto/pull/235 these
should be more reliable.

This reverts commit 4d59291538.
2026-04-01 12:38:11 +00:00
Richard van der Hoff 5e03add29a Expose UserVerificationStatus.known flag (#5255)
Indicate whether we have a record of this user's identity.
2026-04-01 09:58:50 +00:00
renovate[bot] eeafd7fcaa Update npm non-major dependencies (#5251)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 16:36:53 +00:00
renovate[bot] 78a3c5372d Update vite (#5253)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:58:28 +00:00
renovate[bot] c0c3bc2a8c Update dependency p-retry to v8 (#5254)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:28:47 +00:00
renovate[bot] c3ce49cabf Update pnpm to v10.33.0 (#5252)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:19:11 +00:00
renovate[bot] 5408168dfd Update dependency eslint-plugin-jsdoc to v62.8.1 (#5250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:17:51 +00:00
renovate[bot] 61452ddc11 Update pnpm/action-setup action to v5 (#5239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-31 13:11:15 +00:00
Michael Telatynski d5160a5380 Add permissions for contents in workflow 2026-03-31 14:36:05 +01:00
Michael Telatynski 7ff4960a27 Update workflow to use build-and-test.yaml from EW
Moved in https://github.com/element-hq/element-web/pull/32929
2026-03-31 14:34:06 +01:00
RiotRobot 00f63db80f v41.3.0-rc.0 2026-03-31 12:33:30 +00:00
dependabot[bot] 9bcb83a20a Bump smol-toml from 1.6.0 to 1.6.1 (#5249)
Bumps [smol-toml](https://github.com/squirrelchat/smol-toml) from 1.6.0 to 1.6.1.
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.6.0...v1.6.1)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.6.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:59:06 +00:00
dependabot[bot] dd8d8e5410 Bump brace-expansion from 1.1.12 to 1.1.13 (#5248)
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.12 to 1.1.13.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.12...v1.1.13)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 1.1.13
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:41:40 +00:00
dependabot[bot] 32b8ff8116 Bump minimatch from 3.1.2 to 3.1.5 (#5247)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 3.1.2 to 3.1.5.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v3.1.2...v3.1.5)

---
updated-dependencies:
- dependency-name: minimatch
  dependency-version: 3.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-31 11:40:24 +00:00
Skye Elliot 3ceadd512d Refactor history sharing tests using setupClients helper (#5235)
* tests: Refactor history sharing tests using `setupClients` helper

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Use separate destructors for test clients

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-03-30 11:13:37 +00:00
renovate[bot] 8182180550 Update dependency happy-dom to v20.8.9 [SECURITY] (#5244)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-30 09:09:17 +00:00
renovate[bot] aed74c5a72 Update dependency happy-dom to v20.8.8 [SECURITY] (#5243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-27 20:09:41 +00:00
renovate[bot] 8c259c53a6 Update actions/deploy-pages action to v5 (#5238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-27 20:06:08 +00:00
Michael Telatynski 4d59291538 Disable Complement Crypto tests (#5242)
Disabled Complement Crypto tests due to flakiness.
2026-03-27 17:38:37 +00:00
Michael Telatynski 80009a1b31 Add support for non-root package.json for version calculation in Sonar workflow (#5240)
* Add support for non-root package.json for version calculation in Sonar workflow

* Fix version_cmd
2026-03-27 09:16:01 +00:00
renovate[bot] 93e6c95953 Update dependency typescript to v6 (#5234)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 03:16:33 +00:00
renovate[bot] 27a5507cef Update dependency knip to v6 (#5237)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-26 02:41:41 +00:00
renovate[bot] be06f6655e Update dependency typedoc to v0.28.18 (#5236)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-25 22:56:30 +00:00
Skye Elliot 71152f33bf Rotate the current room key when we see a member leave (#5231)
* feat: Rotate room key when a member leaves the room

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* test: Assert room key rotated to prevent MSC4268 leaking keys

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* docs: Outline key rotation scenario above discard logic

* feat: Use `RoomStateEvents.Events` over membership event

* docs: Correct spelling in scenario explanation

Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>

* docs: Pull scenario explanation up to `onRoomStateEvent`

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Assert room key is rotated under leave va gappy sync

* tests: Build sync response incrementally for gappy/ungappy sync

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-25 16:39:58 +00:00
RiotRobot bc8f67089c Merge branch 'master' into develop 2026-03-24 11:22:32 +00:00
RiotRobot acc9aa8939 v41.2.0 2026-03-24 11:21:57 +00:00
Richard van der Hoff e76f627fe3 Add some docs to the DeviceIsolationModes (#5232)
* Add some docs to the DeviceIsolationModes

Notes to help us/me remember how these relate to MSC4153.

* Apply suggestions from code review

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2026-03-23 15:29:35 +00:00
renovate[bot] 45b1e73842 Update npm non-major dependencies (#5229)
* Update npm non-major dependencies

* Update 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>
2026-03-20 09:58:04 +00:00
renovate[bot] f3eefd2f32 Update element-hq/element-meta digest to 7f2f93f (#5222)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:27:19 +00:00
renovate[bot] f7c053216b Update dependency eslint-plugin-jsdoc to v62.8.0 (#5228)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:18:31 +00:00
renovate[bot] 9c7739f14f Update actions/download-artifact digest to 3e5f45b (#5220)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-20 09:10:38 +00:00
renovate[bot] 897afe153a Update pnpm/action-setup digest to fc06bc1 (#5225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 19:03:49 +00:00
renovate[bot] a929391dcd Update shogo82148/actions-upload-release-asset digest to 96bc1f0 (#5226)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 18:06:53 +00:00
renovate[bot] 45c5ee9f65 Update actions/setup-node digest to 53b8394 (#5221)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 16:11:41 +00:00
renovate[bot] e56aaa16c7 Update mheap/github-action-required-labels digest to 0ac283b (#5224)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 15:47:21 +00:00
renovate[bot] da0d3d791e Update element-hq/element-web digest to 9730933 (#5223)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 15:04:32 +00:00
renovate[bot] ed5eb670a1 Update zizmorcore/zizmor-action action to v0.5.2 (#5227)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 14:27:14 +00:00
renovate[bot] b7fcb6e4c1 Update pnpm to v10.32.1 (#5230)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-17 14:19:06 +00:00
RiotRobot bd775f6b61 v41.2.0-rc.0 2026-03-17 11:39:31 +00:00
Skye Elliot c2f9ad28fc Only share history if room history visibility is shared (#5216)
* feat: Only share history if room history visibility is shared

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* docs: Update documentation for `InviteOpts.shareEncryptedHistory`

* tests: Ensure shared history respects current history visibility

This commit additionally modifies `expectSendRoomEvent` to remove
the matcher on success, since fetchmock takes a while to do this
automatically.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-03-16 13:03:41 +00:00
Richard van der Hoff 7f33e3462e History sharing: resume key-bundle import on restart (#5214)
* Store rooms pending key bundles in the CryptoStore

Replace the in-memory storage of which rooms are waiting for a key bundle with
permanent storage in the crypto store.

* Clear pending-key-bundle flag on malformed bundles

If we cannot import the key bundle, there is no point trying again another
time: we may as well clear the flag either way.

* Factor out some helpers in history sharing integ test

* Do not accept key bundles for rooms we joined more than 24h ago

Per discussion in crypto-internal.

* Clear pending key bundle data when we leave a room

* Resume key-bundle import on restart

* Clear pending-key-bundle flag on rooms that we joined ages ago

* fixup! Clear pending-key-bundle flag on malformed bundles
2026-03-16 11:58:36 +00:00
Richard van der Hoff d99363d288 Move shareRoomHistoryWithUser to CryptoBackend (#5218)
There is no need for this method to be exposed to the application, and it's a
footgun waiting to trap the unwary user.

It's marked `@experimental` so we're allowed to move it without a major version
bump.
2026-03-12 21:21:31 +00:00
renovate[bot] 3642b99212 Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18 (#5217)
* Update dependency @matrix-org/matrix-sdk-crypto-wasm to v18

* Adapt to breaking changes in rust-sdk wasm bindings

* more types fixes

* types fixes for tests

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Richard van der Hoff <richard@matrix.org>
2026-03-12 18:16:44 +00:00
Valere Fedronic 6ec0987286 re export sticky event types (#5213)
Co-authored-by: David Baker <dbkr@users.noreply.github.com>
2026-03-12 17:27:11 +00:00
RiotRobot 219eb617dc Merge branch 'master' into develop 2026-03-10 13:46:12 +00:00
RiotRobot c6f9b25046 v41.1.0 2026-03-10 13:45:36 +00:00
Michael Telatynski 5d0e2efaf3 Add zizmor CI & make it happy (#5212)
* Add zizmor CI & make it happy

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

* Fix additional zizmor warning

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-04 09:27:47 +00:00
renovate[bot] c7cd5570d3 Update eslint-plugins (#5207)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 14:01:28 +00:00
renovate[bot] c2f6dd2ce0 Update crazy-max/ghaction-import-gpg action to v7 (#5210)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:24:07 +00:00
renovate[bot] 393732aaae Update npm non-major dependencies (#5208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:23:47 +00:00
renovate[bot] d373fd8540 Update GitHub Artifact Actions (#5211)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:31 +00:00
renovate[bot] 44a8a9a47a Update pnpm to v10.30.3 (#5209)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-03-03 13:22:19 +00:00
RiotRobot 8a0b7ad68b v41.1.0-rc.0 2026-03-03 13:12:49 +00:00
Andy Balaam 09663302e1 Throw a specific error when the backup decryption key does not match the public backup (#5202) 2026-03-02 13:57:25 +00:00
dependabot[bot] 94f83b702c Bump rollup from 4.57.1 to 4.59.0 (#5203)
Bumps [rollup](https://github.com/rollup/rollup) from 4.57.1 to 4.59.0.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.57.1...v4.59.0)

---
updated-dependencies:
- dependency-name: rollup
  dependency-version: 4.59.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-02 13:48:20 +00:00
Michael Telatynski 9df27ee672 Fix downstream tsc (#5204)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-03-02 13:29:42 +00:00
Michael Telatynski 5739b59faa Update release workflows to deal with monorepos (#5201)
* Update release workflows to deal with monorepos

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

* Do the same for release-gitflow.yml

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-25 08:58:06 +00:00
Valere Fedronic e4425570c7 cleanup: Remove deprecated rtc room key transport (#5119)
* cleanup: Remove deprecated rtc room key transport

* fix: rtc statistics are managed by transport directly

* mark as readonly

* cleanup do not use deprecated `room`

* doc: Add missing param doc

* fixup: add back test wrongly removed
2026-02-24 13:32:08 +00:00
RiotRobot 145cb26054 Merge branch 'master' into develop 2026-02-24 13:59:12 +00:00
RiotRobot 26d5b1cde2 v41.0.0 2026-02-24 13:58:36 +00:00
renovate[bot] 0666d6b4e1 Update dependency typedoc to v0.28.17 (#5196)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-24 11:23:34 +00:00
Michael Telatynski 9002064f10 Remove rimraf in favour of --delete-dir-on-start (#5200)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-24 11:01:56 +00:00
Will Hunt 5ea1554612 RTC Slots: Refactoring of membership event parsing and handling (#5134)
* Split out membership into seperate files.

* First pass of merging in new changes.

* More cleanup

* fix import

* Lots of test fixes

* remove skips

* unrelated change

* docstring

* comment

* lint lint lint

* copyright updates

* cleanup

* Ensure we await initial membership in all tests.

* fix race

* Use promises which are more reliable

* Even more promise stability

* cleanup

* Cleanup

* rename legacy.ts -> session.ts

* Update imports

* cleanup

* Rename files

* Rename + remove claimed_

* renaming

* Rename function

* All the cleanup

* tidy

* commit changes

* fix call membership

* fix claimed

* update slot_id

* fix device_id / claimed_device_id

* Update src/matrixrtc/utils.ts

Co-authored-by: R Midhun Suresh <hi@midhun.dev>

* use an aggregate error

* Export types

---------

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2026-02-24 10:51:23 +00:00
Will Hunt bd6547c081 Update getUrlPreview to use /_matrix/client/v1/media/preview_url (#5191)
* Update preview_url endpoint.

* support <v1.11

* Add tests
2026-02-23 19:49:05 +00:00
Michael Telatynski 8073f27d98 Specify this type for BaseLogger methods (#5198)
This allows https://typescript-eslint.io/rules/unbound-method/ to be happy

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-19 23:05:15 +00:00
renovate[bot] 3bb22a9b28 Update npm non-major dependencies (#5197)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-18 11:13:22 +00:00
renovate[bot] ba8bb3228d Update dependency eslint-plugin-jsdoc to v62.5.4 (#5195)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 17:18:07 +00:00
renovate[bot] de23c9587b Update actions/checkout digest to de0fac2 (#5193)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 17:17:30 +00:00
renovate[bot] 64eb482a49 Update actions/stale digest to b5d41d4 (#5194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-17 16:17:31 +00:00
RiotRobot aba7f8a0d4 v41.0.0-rc.0 2026-02-17 15:11:09 +00:00
RiotRobot 5495153c63 Merge remote-tracking branch 'origin/develop' into staging 2026-02-17 14:43:42 +00:00
Michael Telatynski 4f0696e2a4 Revert "Disable complement crypto tests temporarily (#5188)" (#5190)
This reverts commit 327d2fa7c8.
2026-02-17 11:13:05 +00:00
Bas Nijholt 0e659d294e fix(relations): prevent stale m.replace from overriding newer edits (#5192)
When multiple m.replace edits arrive concurrently, getLastReplacement()
may block on decryption. If an older edit's decryption completes after a
newer edit has already been applied, the older async result overwrites
the target event with stale content.

Add a monotonic update counter (replacementUpdateId) and centralise all
replacement updates through updateTargetEventReplacement(). The method
captures the counter before awaiting and discards the result if a newer
update has started in the meantime.

This race is especially pronounced in encrypted rooms with rapid
streaming-style edits, where variable decryption timing causes
out-of-order promise resolution.
2026-02-17 09:47:08 +00:00
Skye Elliot e74eb4928e Download room keys from backup prior to buliding historic room key bundles (#5171)
* chore: Update `@matrix-org/matrix-sdk-crypto-wasm` to v17.1.0

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* feat: Download keys from key backup.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* tests: Ensure backup is downloaded before building room key bundle.

Signed-off-by: Skye Elliot <actuallyori@gmail.com>

* fix: Address review comments in history sharing tests.

* docs: Improve `getSyncResponse` and `assertInviteAndShareHistory` docs

* feat: Log `backupVersion`, `hasDecryptionKey` on download failure

* fix: Group backup data, add casts to `generate-test-data.py`.

* tests: Update `getSyncResponse` calls in history sharing integ tests

* fix: Add history visibility argument to state events test.

---------

Signed-off-by: Skye Elliot <actuallyori@gmail.com>
2026-02-13 13:31:14 +00:00
David Baker 327d2fa7c8 Disable complement crypto tests temporarily (#5188)
* Disable complement crypto tests temporarily

As per comment

* and the downstream one too

* Stub downstream test instead

* prettier
2026-02-12 14:57:34 +00:00
Aditya Cherukuru 028357f15f Fix reactive display name disambiguation (#5135)
* Fix reactive display name disambiguation

When a room member changes their display name, recalculate the disambiguation flag for all other members who share (or previously shared) that display name. This ensures that the 'disambiguate' flag is updated reactively when display name conflicts appear or are resolved.

Fixes element-hq/element-web#468

Fixes element-hq/element-web#4795

Fixes element-hq/element-web#31551

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Refactor: move disambiguation logic per review feedback

- Added updateDisambiguation() method to RoomMember for direct disambiguation recalculation

- Moved affected display name tracking to setStateEvents() instead of updateDisplayNameCache()

- Removed setMembershipEvent() hack, now calls updateDisambiguation() directly

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

* Exclude processed members from disambiguation loop

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>

---------

Signed-off-by: aditya-cherukuru <cherukuru.aditya01@gmail.com>
2026-02-12 14:23:04 +00:00
Michael Telatynski 872ec6755e Add support for Matrix Spec v1.13 (#5160) 2026-02-11 16:14:02 +00:00
RiotRobot 333d6a7bd6 v40.3.0-rc.0 2026-02-11 15:06:14 +00:00
Michael Telatynski 47532de452 Switch from yarn classic to pnpm (#5184) 2026-02-11 10:35:25 +00:00
RiotRobot 87e1049dae Merge branch 'master' into develop 2026-02-10 15:17:11 +00:00
Timo 6e3efef0c5 Fix empty string to room compatibility trick to only apply to m.call (#5172)
* Fix empty string to room compatibility trick to only apply to m.call

* add logging

* fix linter

* Add tests

* limit logging.
2026-02-08 11:25:34 +00:00
Olivier 'reivilibre fb590627bb Add logging on MSC4108 DELETE request (#5140)
Just noticed these requests aren't logged,
which makes debugging difficult.
This is very drive-by, done in the web editor.

Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2026-02-05 10:13:44 +00:00
Michael Telatynski 24cc17c270 Merge remote-tracking branch 'origin/develop' into develop 2026-02-04 11:47:05 +00:00
Michael Telatynski 68084e8fc3 Fix vitest slow reporter crashing CI
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2026-02-04 11:46:48 +00:00
Richard van der Hoff 0c3bb1f246 Add m.invite_permission_config account data type (#5183)
For MSC4380
2026-02-04 11:00:16 +00:00
renovate[bot] 7f42b67f68 Update matrix-org (#5181)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 15:11:44 +00:00
renovate[bot] 9b871ac969 Update dependency eslint-plugin-jsdoc to v62.5.0 (#5179)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:39:51 +00:00
renovate[bot] 49f7972a9e Update peter-evans/repository-dispatch digest to 28959ce (#5177)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:16:42 +00:00
renovate[bot] c5ae4c8c0d Update dependency typedoc-plugin-mdn-links to v5.1.1 (#5180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:02:42 +00:00
renovate[bot] 2423300acd Update npm non-major dependencies (#5178)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 14:01:03 +00:00
renovate[bot] 6cafa175b8 Update guibranco/github-status-action-v2 digest to 9bfa877 (#5176)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-02-03 13:55:10 +00:00
105 changed files with 12328 additions and 9141 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/pnpm-lock.yaml @matrix-org/element-web-team
/scripts/** @matrix-org/element-web-team
/src/webrtc @matrix-org/element-call-reviewers
/src/matrixrtc @matrix-org/element-call-reviewers
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ env.VERSION }}.tar.gz.asc
@@ -29,13 +29,13 @@ runs:
- name: Upload asset signatures
if: inputs.gpg-fingerprint
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@8f6863c6c894ba46f9e676ef5cccec4752723c1e # v1
uses: shogo82148/actions-upload-release-asset@96bc1f0cb850b65efd58a6b5eaa0a69f88d38077 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+3 -1
View File
@@ -1,6 +1,8 @@
name: Backport
on:
pull_request_target:
# Privilege escalation necessary to enable backporting PRs from forks
# 🚨 We must not execute any checked out code here.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types:
- closed
- labeled
+4 -2
View File
@@ -1,7 +1,9 @@
name: Deploy documentation PR preview
on:
workflow_run:
# Privilege escalation necessary to publish to Netlify
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Static Analysis"]
types:
- completed
@@ -15,7 +17,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
@@ -1,7 +1,7 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# matrix-react-sdk playwright tests (with access to repo secrets)
# element-web playwright tests (with access to repo secrets)
name: matrix-react-sdk End to End Tests
name: Element Web End to End Tests
on:
merge_group:
types: [checks_requested]
@@ -21,11 +21,12 @@ concurrency:
jobs:
playwright:
name: Playwright
uses: element-hq/element-web/.github/workflows/end-to-end-tests.yaml@develop
uses: element-hq/element-web/.github/workflows/build-and-test.yaml@develop # zizmor: ignore[unpinned-uses]
permissions:
actions: read
issues: read
pull-requests: read
contents: read
with:
matrix-js-sdk-sha: ${{ github.sha }}
# We only want to run the playwright tests on merge queue to prevent regressions
+2 -2
View File
@@ -18,8 +18,8 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@5fc4efd1a4797ddb68ffd0714a238564e4cc0e6f # v4
- name: Notify element-web repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@28959ce8df70de7be546dd1250a005dd32156697 # v4
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+8 -5
View File
@@ -1,6 +1,9 @@
name: Pull Request
on:
pull_request_target:
# Privilege escalation necessary access members of the review teams
# 🚨 We must not execute any checked out code here, and be careful around use of user-controlled inputs.
# FIXME: only `community-prs` job needs this privilege, so it should be in its own workflow file.
pull_request_target: # zizmor: ignore[dangerous-triggers]
types: [opened, edited, labeled, unlabeled, synchronize]
merge_group:
types: [checks_requested]
@@ -15,7 +18,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-24.04
steps:
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
- uses: mheap/github-action-required-labels@0ac283b4e65c1fb28ce6079dea5546ceca98ccbe # v5
if: github.event_name != 'merge_group'
with:
labels: |
@@ -35,7 +38,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -60,7 +63,7 @@ jobs:
- name: Add label
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
github.rest.issues.addLabels({
@@ -81,7 +84,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
github.rest.issues.createComment({
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Check for X-Release-Blocker label on any open issues or PRs
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
REPO: ${{ inputs.repository }}
with:
@@ -16,18 +16,20 @@ jobs:
contents: write
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version-file: package.json
cache: "yarn"
cache: "pnpm"
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
id: draft-release
@@ -37,7 +39,7 @@ jobs:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -48,7 +50,7 @@ jobs:
- name: Ingest upstream changes
if: inputs.include-changes
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
+1 -1
View File
@@ -13,4 +13,4 @@ jobs:
draft:
permissions:
contents: write
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop # zizmor: ignore[unpinned-uses]
+13 -6
View File
@@ -12,20 +12,25 @@ on:
description: List of dependencies to reset.
type: string
required: false
dir:
description: The directory to release
type: string
default: "."
concurrency: ${{ github.workflow }}
permissions: {} # Uses ELEMENT_BOT_TOKEN
jobs:
merge:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -33,13 +38,14 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Set up git
run: |
@@ -53,6 +59,7 @@ jobs:
- name: Reset dependencies
if: inputs.dependencies
working-directory: ${{ inputs.dir }}
run: |
while IFS= read -r PACKAGE; do
[ -z "$PACKAGE" ] && continue
@@ -73,7 +80,7 @@ jobs:
fi
echo "Resetting $PACKAGE to develop branch..."
yarn add "github:matrix-org/$PACKAGE#develop"
pnpm add "github:matrix-org/$PACKAGE#develop"
git add -u
git commit -m "Reset $PACKAGE back to develop branch"
done <<< "$DEPENDENCIES"
+39 -19
View File
@@ -26,12 +26,21 @@ on:
description: |
The path to the asset you want to upload, if any. You can use glob patterns here.
Will be GPG signed and an `.asc` file included in the release artifacts if `gpg-fingerprint` is set.
Relative to `dir`.
type: string
required: false
expected-asset-count:
description: The number of expected assets, including signatures, excluding generated zip & tarball.
type: number
required: false
dist-dir:
description: The directory to release
type: string
default: "."
version-dirs:
description: Directories in which to update package.json `version` field
type: string
required: false
outputs:
npm-id:
description: "The npm package@version string we published"
@@ -43,7 +52,7 @@ jobs:
permissions:
issues: read
pull-requests: read
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-checks.yml@develop # zizmor: ignore[unpinned-uses]
release:
name: Release
@@ -56,7 +65,7 @@ jobs:
- name: Load GPG key
id: gpg
if: inputs.gpg-fingerprint
uses: crazy-max/ghaction-import-gpg@e89d40939c28e39f97cf32126055eeae86ba74ec # v6
uses: crazy-max/ghaction-import-gpg@2dc316deee8e90f13e1a351ab510b4d5bc0c82cd # v7
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
@@ -64,22 +73,23 @@ jobs:
- name: Get draft release
id: draft-release
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # v1
uses: cardinalby/git-get-release-action@5172c3a026600b1d459b117738c605fabc9e4e44 # 1.2.5
env:
GITHUB_TOKEN: ${{ github.token }}
with:
draft: true
latest: true
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
# We will be pushing to this branch and want the CI to run after we do so we cannot use the GITHUB_TOKEN
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
fetch-depth: 0
persist-credentials: true
- name: Get actions scripts
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
@@ -90,6 +100,7 @@ jobs:
- name: Prepare variables
id: prepare
working-directory: ${{ inputs.dist-dir }}
run: |
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -104,7 +115,7 @@ jobs:
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
const { VERSION } = process.env;
@@ -123,15 +134,17 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
node-version-file: package.json
cache: "pnpm"
node-version-file: ${{ inputs.dist-dir }}/package.json
- name: Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Handle develop dependencies
working-directory: ${{ inputs.dist-dir }}
run: |
ret=0
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
@@ -140,15 +153,19 @@ jobs:
VERSION=${dep[1]}
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
yarn upgrade "$PACKAGE@$VERSION" --exact
pnpm add "$PACKAGE@$VERSION" --save-exact
git add -u
git commit -m "Keep $PACKAGE at $VERSION"
done
- name: Bump package.json version
- name: Bump package.json versions
run: |
yarn version --no-git-tag-version --new-version "${VERSION#v}"
git add package.json
for DIR in $DIRS; do
pnpm version -C "$DIR" --no-git-tag-version "${VERSION#v}"
git add "$DIR"/package.json
done
env:
DIRS: ${{ inputs.version-dirs || inputs.dist-dir }}
- name: Add to CHANGELOG.md
if: inputs.final
@@ -175,7 +192,8 @@ jobs:
- name: Build assets
if: steps.prepare.outputs.has-dist-script == '1'
run: DIST_VERSION="$VERSION" yarn dist
working-directory: ${{ inputs.dist-dir }}
run: DIST_VERSION="$VERSION" pnpm dist
- name: Upload release assets & signatures
if: inputs.asset-path
@@ -183,7 +201,7 @@ jobs:
with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.draft-release.outputs.upload_url }}
asset-path: ${{ inputs.asset-path }}
asset-path: ${{ inputs.dist-dir }}/${{ inputs.asset-path }}
- name: Create signed tag
if: inputs.gpg-fingerprint
@@ -216,7 +234,7 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
@@ -244,7 +262,7 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
env:
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
@@ -276,7 +294,9 @@ jobs:
name: Publish to npm
needs: release
if: inputs.npm
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses]
with:
dir: ${{ inputs.dist-dir }}
permissions:
contents: read
id-token: write
+14 -6
View File
@@ -1,6 +1,11 @@
name: Publish to npm
on:
workflow_call:
inputs:
dir:
description: The directory to release
type: string
default: "."
outputs:
id:
description: "The npm package@version string we published"
@@ -17,26 +22,29 @@ jobs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: staging
persist-credentials: false
- name: 🔧 Yarn cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
node-version-file: package.json
node-version-file: ${{ inputs.dir }}/package.json
# Ensure npm 11.5.1 or later is installed
- name: Update npm
run: npm install -g npm@latest
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: 🚀 Publish to npm
id: npm-publish
working-directory: ${{ inputs.dir }}
run: |
npm publish --provenance --access public --tag "$TAG"
release=$(jq -r '"\(.name)@\(.version)"' package.json)
+24 -17
View File
@@ -24,7 +24,7 @@ concurrency: ${{ github.workflow }}
permissions: {} # No permissions required
jobs:
release:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop # zizmor: ignore[unpinned-uses,secrets-inherit]
permissions:
contents: write
issues: write
@@ -41,28 +41,32 @@ jobs:
runs-on: ubuntu-24.04
strategy:
matrix:
repo:
- element-hq/element-web
include:
- repo: element-hq/element-web
path: apps/web
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
persist-credentials: true
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Bump dependency
env:
DEPENDENCY: ${{ needs.release.outputs.npm-id }}
DIR: ${{ matrix.path }}
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
yarn upgrade "$DEPENDENCY" --exact
git add package.json yarn.lock
pnpm add -C "$DIR" "$DEPENDENCY" --save-exact
git add "$DIR"/package.json pnpm-lock.yaml
git commit -am"Upgrade dependency to $DEPENDENCY"
git push origin staging
@@ -73,22 +77,25 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: 🧮 Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: 🔧 Yarn cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: 🔨 Install dependencies
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: 📖 Generate docs
run: yarn gendoc
run: pnpm gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: _docs
@@ -106,4 +113,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
+12 -8
View File
@@ -13,6 +13,10 @@ on:
type: boolean
required: false
description: "Whether to combine multiple LCOV and sonar-report files in coverage artifact"
version-pkg-json-dir:
type: string
default: "."
description: "Relative path of the directory containing package.json with the `version` to use."
permissions: {}
jobs:
sonarqube:
@@ -27,7 +31,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -36,14 +40,15 @@ jobs:
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
persist-credentials: false
- name: 📥 Download artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -51,14 +56,13 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: inputs.sharded
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: coverage-*
path: coverage
merge-multiple: true
- name: Check coverage artifact
run: |
if [ ! -d coverage ]; then
@@ -75,7 +79,7 @@ jobs:
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@ea0cd9dbd5562e79816685972bc0d03c235a900c
uses: matrix-org/sonarcloud-workflow-action@13968a27c924fa19b1dacbce6ca3ff217daa775b
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
@@ -83,12 +87,12 @@ jobs:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
skip_coverage_label: Z-Skip-Coverage
version_cmd: "cat package.json | jq -r .version"
version_cmd: "cat ${{ inputs.version-pkg-json-dir }}/package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
- uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+4 -2
View File
@@ -1,6 +1,8 @@
name: SonarQube
on:
workflow_run:
# Privilege escalation necessary to call upon SonarCloud
# 🚨 We must not execute any checked out code here.
workflow_run: # zizmor: ignore[dangerous-triggers]
workflows: ["Tests"]
types:
- completed
@@ -16,7 +18,7 @@ jobs:
actions: read
statuses: write
id-token: write # sonar
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop # zizmor: ignore[unpinned-uses]
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+79 -53
View File
@@ -14,58 +14,61 @@ jobs:
name: "Typescript Syntax Check"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Typecheck
run: "yarn run lint:types"
run: "pnpm run lint:types"
js_lint:
name: "ESLint"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Run Linter
run: "yarn run lint:js"
run: "pnpm run lint:js"
node_example_lint:
name: "Node.js example"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Build Types
run: "yarn build:types"
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
with:
cache: "npm"
node-version-file: "examples/node/package.json"
# cache-dependency-path: '**/package-lock.json'
run: "pnpm build:types"
- name: Install Example Deps
run: "npm install"
@@ -82,39 +85,50 @@ jobs:
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-24.04
permissions:
security-events: write
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run Linter
run: "yarn lint:workflows"
run: "pnpm lint:workflows"
- name: Run zizmor
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
docs:
name: "JSDoc Checker"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install"
run: "pnpm install"
- name: Generate Docs
run: "yarn run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docs
path: _docs
@@ -125,31 +139,36 @@ jobs:
name: "Analyse Dead Code"
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
cache: "yarn"
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
- name: Install Deps
run: "yarn install --frozen-lockfile"
run: "pnpm install --frozen-lockfile"
- name: Run linter
run: "yarn run lint:knip"
run: "pnpm run lint:knip"
element-web:
name: Downstream tsc element-web
if: github.event_name == 'merge_group'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
repository: element-hq/element-web
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: "lts/*"
- name: Install Dependencies
@@ -159,15 +178,22 @@ jobs:
JS_SDK_GITHUB_BASE_REF: ${{ github.sha }}
- name: Typecheck
run: "yarn run lint:types"
working-directory: apps/web
run: "pnpm run lint:types"
# Hook for branch protection to skip downstream typechecking outside of merge queues
downstream:
name: Downstream Typescript Syntax Check
# Workflow consolidation job
done:
needs:
- ts_lint
- js_lint
- node_example_lint
- workflow_lint
- docs
- analyse_dead_code
- element-web
name: Static Analysis
runs-on: ubuntu-24.04
if: always()
needs:
- element-web
steps:
- if: needs.element-web.result != 'skipped' && needs.element-web.result != 'success'
- if: contains(needs.*.result , 'failure') || contains(needs.*.result, 'cancelled')
run: exit 1
+1 -1
View File
@@ -11,7 +11,7 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
sync-labels:
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@develop
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
with:
LABELS: |
element-hq/element-meta
+19 -13
View File
@@ -22,17 +22,20 @@ jobs:
node: ["lts/*", 22]
steps:
- name: Checkout code
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: Setup Node
id: setupNode
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "yarn"
cache: "pnpm"
node-version: ${{ matrix.node }}
- name: Install dependencies
run: "yarn install"
run: "pnpm install"
- name: Get number of CPU cores
id: cpu-cores
@@ -40,20 +43,23 @@ jobs:
- name: Run tests
run: |
yarn test \
--coverage=${{ env.ENABLE_COVERAGE }} \
--maxWorkers ${{ steps.cpu-cores.outputs.count }} \
pnpm test \
--coverage=${ENABLE_COVERAGE} \
--maxWorkers ${NUM_WORKERS} \
./spec/${{ matrix.specs }}
env:
SHARD: ${{ matrix.specs }}
NUM_WORKERS: ${{ steps.cpu-cores.outputs.count }}
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${NODE_VERSION}-${{ matrix.specs }}.lcov.info
env:
NODE_VERSION: ${{ steps.setupNode.outputs.node-version }}
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
@@ -73,7 +79,7 @@ jobs:
element-web:
name: Downstream test element-web
if: github.event_name == 'merge_group'
uses: element-hq/element-web/.github/workflows/tests.yml@develop
uses: element-hq/element-web/.github/workflows/tests.yml@develop # zizmor: ignore[unpinned-uses]
permissions:
statuses: write
with:
@@ -83,8 +89,8 @@ jobs:
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
permissions: read-all
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main
permissions: read-all # zizmor: ignore[excessive-permissions]
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses]
with:
use_js_sdk: "."
@@ -112,7 +118,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5530c593759f489bba08272e96986ffc571c1ea1
uses: guibranco/github-status-action-v2@9bfa8773cdbdc6c185747fd43cd7faa9d7c32f09
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1 -1
View File
@@ -8,7 +8,7 @@ jobs:
automate-project-columns-next:
runs-on: ubuntu-24.04
steps:
- uses: actions/add-to-project@main
- uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
with:
project-url: https://github.com/orgs/element-hq/projects/120
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+1 -1
View File
@@ -6,6 +6,6 @@ on:
permissions: {} # We use ELEMENT_BOT_TOKEN instead
jobs:
call-triage-labelled:
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@develop
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@6339bcda15c71d209303b18a06a9b1c021220bf9
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10
with:
operations-per-run: 250
days-before-issue-stale: -1
+1
View File
@@ -3,6 +3,7 @@
/.npmrc
/*.log
pnpm-lock.yaml
package-lock.json
.lock-wscript
build/Release
+44
View File
@@ -1,3 +1,47 @@
Changes in [41.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.3.0) (2026-04-07)
==================================================================================================
## 🐛 Bug Fixes
* Rotate the current room key when we see a member leave ([#5231](https://github.com/matrix-org/matrix-js-sdk/pull/5231)). Contributed by @kaylendog.
Changes in [41.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.2.0) (2026-03-24)
==================================================================================================
## ✨ Features
* Only share history if room history visibility is shared ([#5216](https://github.com/matrix-org/matrix-js-sdk/pull/5216)). Contributed by @kaylendog.
* History sharing: resume key-bundle import on restart ([#5214](https://github.com/matrix-org/matrix-js-sdk/pull/5214)). Contributed by @richvdh.
* Move `CryptoApi.shareRoomHistoryWithUser` to `CryptoBackend` ([#5218](https://github.com/matrix-org/matrix-js-sdk/pull/5218)). Contributed by @richvdh.
Changes in [41.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.1.0) (2026-03-10)
==================================================================================================
## ✨ Features
* Throw a specific error when the backup decryption key does not match the public backup ([#5202](https://github.com/matrix-org/matrix-js-sdk/pull/5202)). Contributed by @andybalaam.
* Update getUrlPreview to use /\_matrix/client/v1/media/preview\_url ([#5191](https://github.com/matrix-org/matrix-js-sdk/pull/5191)). Contributed by @Half-Shot.
Changes in [41.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v41.0.0) (2026-02-24)
==================================================================================================
## 🚨 BREAKING CHANGES
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
## ✨ Features
* Download room keys from backup prior to buliding historic room key bundles ([#5171](https://github.com/matrix-org/matrix-js-sdk/pull/5171)). Contributed by @kaylendog.
* Add support for Matrix Spec v1.13 ([#5160](https://github.com/matrix-org/matrix-js-sdk/pull/5160)). Contributed by @t3chguy.
* Add logging on MSC4108 DELETE request ([#5140](https://github.com/matrix-org/matrix-js-sdk/pull/5140)). Contributed by @reivilibre.
* Add `m.invite_permission_config` account data type ([#5183](https://github.com/matrix-org/matrix-js-sdk/pull/5183)). Contributed by @richvdh.
## 🐛 Bug Fixes
* fix(relations): prevent stale m.replace from overriding newer edits ([#5192](https://github.com/matrix-org/matrix-js-sdk/pull/5192)). Contributed by @basnijholt.
* Fix reactive display name disambiguation ([#5135](https://github.com/matrix-org/matrix-js-sdk/pull/5135)). Contributed by @aditya-cherukuru.
* Fix empty string to room compatibility trick to only apply to m.call ([#5172](https://github.com/matrix-org/matrix-js-sdk/pull/5172)). Contributed by @toger5.
Changes in [40.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v40.2.0) (2026-02-10)
==================================================================================================
## 🦖 Deprecations
+8 -8
View File
@@ -41,10 +41,10 @@ endpoints from before Matrix 1.1, for example.
> Servers may require or use authenticated endpoints for media (images, files, avatars, etc). See the
> [Authenticated Media](#authenticated-media) section for information on how to enable support for this.
Using `yarn` instead of `npm` is recommended. Please see the Yarn [install guide](https://classic.yarnpkg.com/en/docs/install)
if you do not have it already.
Using `pnpm` instead of `npm` is recommended. Please see the pnpm [install
guide](https://pnpm.io/installation#using-corepack) if you do not have it already.
`yarn add matrix-js-sdk`
`pnpm add matrix-js-sdk`
```javascript
import * as sdk from "matrix-js-sdk";
@@ -310,7 +310,7 @@ This SDK uses [Typedoc](https://typedoc.org/guides/doccomments) doc comments. Yo
host the API reference from the source files like this:
```
$ yarn gendoc
$ pnpm gendoc
$ cd docs
$ python -m http.server 8005
```
@@ -453,7 +453,7 @@ want to use this SDK, skip this section._
First, you need to pull in the right build tools:
```
$ yarn install
$ pnpm install
```
## Building
@@ -461,17 +461,17 @@ First, you need to pull in the right build tools:
To build a browser version from scratch when developing:
```
$ yarn build
$ pnpm build
```
To run tests:
```
$ yarn test
$ pnpm test
```
To run linting:
```
$ yarn lint
$ pnpm lint
```
+1 -1
View File
@@ -21,4 +21,4 @@ export PATH="$rootdir/node_modules/.bin:$PATH"
# now run our checks
cd "$tmpdir"
yarn lint
pnpm lint
+3 -4
View File
@@ -1,5 +1,8 @@
import { KnipConfig } from "knip";
// Specify this as knip loads config files which may conditionally add reporters, e.g. `vitest-sonar-reporter'
process.env.GITHUB_ACTIONS = "1";
export default {
entry: [
"src/index.ts",
@@ -28,10 +31,6 @@ export default {
"husky",
// Used in script which only runs in environment with `@octokit/rest` installed
"@octokit/rest",
// Used by `vitest`
"vitest-sonar-reporter",
// Used by `@babel/plugin-transform-runtime`
"@babel/runtime",
],
ignoreBinaries: [
// Used when available by reusable workflow `.github/workflows/release-make.yml`
+34 -22
View File
@@ -1,27 +1,26 @@
{
"name": "matrix-js-sdk",
"version": "40.2.0",
"version": "41.3.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"prepare": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"clean": "rimraf lib",
"build": "yarn clean && yarn build:compile && yarn build:types",
"prepare": "pnpm build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel --delete-dir-on-start src -w -s -d lib --verbose --extensions \".ts,.js\"",
"build": "pnpm build:compile && pnpm build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"build:compile": "babel --delete-dir-on-start -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
"lint": "pnpm lint:types && pnpm lint:js && pnpm lint:workflows",
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"lint:knip": "knip",
"test": "vitest",
"test:watch": "vitest --watch",
"coverage": "yarn test --coverage"
"test": "vitest run",
"test:watch": "vitest watch",
"coverage": "pnpm test --coverage"
},
"repository": {
"type": "git",
@@ -49,7 +48,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^17.0.0",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.1.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -58,10 +57,9 @@
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.16.1",
"oidc-client-ts": "^3.0.1",
"p-retry": "7",
"p-retry": "8",
"sdp-transform": "^3.0.0",
"unhomoglyph": "^1.0.6",
"uuid": "13"
"unhomoglyph": "^1.0.6"
},
"devDependencies": {
"@action-validator/cli": "^0.6.0",
@@ -84,7 +82,7 @@
"@stylistic/eslint-plugin": "^5.0.0",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
"@types/node": "18",
"@types/node": "22",
"@types/sdp-transform": "^2.4.5",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
@@ -107,20 +105,34 @@
"fetch-mock": "^12.6.0",
"happy-dom": "^20.1.0",
"husky": "^9.0.0",
"knip": "^5.0.0",
"knip": "^6.0.0",
"lint-staged": "^16.0.0",
"matrix-mock-request": "^2.5.0",
"prettier": "3.8.0",
"rimraf": "^6.0.0",
"prettier": "3.8.3",
"typedoc": "^0.28.1",
"typedoc-plugin-coverage": "^4.0.0",
"typedoc-plugin-mdn-links": "^5.0.0",
"typedoc-plugin-missing-exports": "^4.0.0",
"typescript": "^5.4.2",
"typescript": "^6.0.0",
"vitest": "^4.0.17",
"vitest-sonar-reporter": "^3.0.0"
},
"resolutions": {
"expect": "30.2.0"
}
"pnpm": {
"peerDependencyRules": {
"allowedVersions": {
"eslint": "8"
}
},
"allowedDeprecatedVersions": {
"eslint": "8"
},
"overrides": {
"expect": "30.3.0",
"flatted@<=3.4.1": "^3.4.2",
"picomatch@>=4.0.0 <4.0.4": "^4.0.4",
"yaml@>=2.0.0 <2.8.3": "^2.8.3",
"vite": "8.0.8"
}
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
+8353
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
nodeLinker: hoisted
+110 -2
View File
@@ -86,6 +86,7 @@ import {
encryptGroupSessionKey,
encryptMegolmEvent,
encryptMegolmEventRawPlainText,
encryptOlmEvent,
establishOlmSession,
getTestOlmAccountKeys,
expectSendRoomKey,
@@ -2064,6 +2065,7 @@ describe("crypto", () => {
expect(hasCrossSigningKeysForUser).toBe(true);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
expect(verificationStatus.known).toBe(false); // We haven't actually stashed a copy of Alice's identity
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2078,7 +2080,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys(TEST_USER_ID);
expect(hasCrossSigningKeysForUser).toBe(true);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(TEST_USER_ID);
expect(verificationStatus.known).toBe(true);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2089,7 +2092,8 @@ describe("crypto", () => {
const hasCrossSigningKeysForUser = await aliceClient.getCrypto()!.userHasCrossSigningKeys("@unknown:xyz");
expect(hasCrossSigningKeysForUser).toBe(false);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus("@unknown:xyz");
expect(verificationStatus.known).toBe(false);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2119,6 +2123,7 @@ describe("crypto", () => {
{
const verificationStatus = await aliceClient.getCrypto()!.getUserVerificationStatus(BOB_TEST_USER_ID);
expect(verificationStatus.known).toBe(true);
expect(verificationStatus.isVerified()).toBe(false);
expect(verificationStatus.isCrossSigningVerified()).toBe(false);
expect(verificationStatus.wasCrossSigningVerified()).toBe(false);
@@ -2311,4 +2316,107 @@ describe("crypto", () => {
);
}
});
describe("secret pushing", () => {
it("should push a new backup key when a new backup key is set", async () => {
// setup: alice has another device, DEVICE_ID, which is verified
const crypto = aliceClient.getCrypto()!;
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
await startClientAndAwaitFirstSync();
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
// when we set a new backup key
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
fetchMock.post("path:/_matrix/client/v3/room_keys/version", {
status: 200,
body: { version: "1" },
});
const secretPushPromise = new Promise<any>((resolve) => {
fetchMock.putOnce(new RegExp("/sendToDevice/m.room.encrypted/"), (callLog): RouteResponse => {
const content = JSON.parse(callLog.options.body as string);
resolve(content);
return {};
});
});
await crypto.resetKeyBackup();
// we expect the other device to get a secret push
const content = await secretPushPromise;
const curve25519key = JSON.parse(testOlmAccount.identity_keys()).curve25519;
const ciphertext = content.messages["@alice:localhost"].DEVICE_ID.ciphertext[curve25519key];
const olmSession = new Olm.Session();
olmSession.create_inbound(testOlmAccount, ciphertext.body);
const decrypted = JSON.parse(olmSession.decrypt(0, ciphertext.body));
expect(decrypted.type).toBe("io.element.msc4385.secret.push");
expect(decrypted.content.name).toBe("m.megolm_backup.v1");
});
it("should receive pushed backup key", async () => {
// setup: alice has another device, DEVICE_ID, which is verified,
// and has a key backup set up and signed by DEVICE_ID
const crypto = aliceClient.getCrypto()!;
expectAliceKeyQuery(getTestKeysQueryResponse("@alice:localhost"));
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
await startClientAndAwaitFirstSync();
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
expect(devices.get("@alice:localhost")!.keys()).toContain("DEVICE_ID");
await crypto.setDeviceVerified("@alice:localhost", "DEVICE_ID");
expectAliceKeyClaim(getTestKeysClaimResponse("@alice:localhost"));
// after we push the backup key to alice...
const senderIdentityKeys = JSON.parse(testOlmAccount.identity_keys());
const aliceDeviceKeys = await crypto.getOwnDeviceKeys();
const p2pSession = await createOlmSession(testOlmAccount, keyReceiver);
const secretPush = encryptOlmEvent({
sender: "@alice:localhost",
senderKey: senderIdentityKeys.curve25519,
senderSigningKey: senderIdentityKeys.ed25519,
p2pSession,
recipient: "@alice:localhost",
recipientCurve25519Key: aliceDeviceKeys.curve25519,
recipientEd25519Key: aliceDeviceKeys.ed25519,
plaincontent: {
secret: testData.BACKUP_DECRYPTION_KEY_BASE64,
name: "m.megolm_backup.v1",
},
plaintype: "io.element.msc4385.secret.push",
});
const syncResponse = {
next_batch: 1,
to_device: {
events: [secretPush],
},
};
const backupKeyReceivedPromise = new Promise<string>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupDecryptionKeyCached, resolve);
});
const keyBackupEnabledPromise = new Promise<void>((resolve) => {
aliceClient.on(CryptoEvent.KeyBackupStatus, (enabled) => {
if (enabled) {
resolve();
}
});
});
syncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// alice should be using backup now
expect(await backupKeyReceivedPromise).toBe(testData.SIGNED_BACKUP_DATA.version);
await keyBackupEnabledPromise;
expect(await crypto.getActiveSessionBackupVersion()).toBe(testData.SIGNED_BACKUP_DATA.version);
});
});
});
File diff suppressed because it is too large Load Diff
+40 -10
View File
@@ -37,7 +37,13 @@ import { advanceTimersUntil, awaitDecryption, syncPromise } from "../../test-uti
import * as testData from "../../test-utils/test-data";
import { type KeyBackupInfo, type KeyBackupSession } from "../../../src/crypto-api/keybackup";
import { flushPromises } from "../../test-utils/flushPromises";
import { decodeRecoveryKey, DecryptionFailureCode, CryptoEvent, type CryptoApi } from "../../../src/crypto-api";
import {
decodeRecoveryKey,
DecryptionFailureCode,
CryptoEvent,
type CryptoApi,
DecryptionKeyDoesNotMatchError,
} from "../../../src/crypto-api";
import { type KeyBackup } from "../../../src/rust-crypto/backup.ts";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -502,15 +508,10 @@ describe("megolm-keys backup", () => {
// @ts-ignore - mock a private method for testing purpose
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64);
const fullBackup = {
rooms: {
[ROOM_ID]: {
sessions: {
[testData.MEGOLM_SESSION_DATA.session_id]: testData.CURVE25519_KEY_BACKUP_DATA,
},
},
},
};
const fullBackup = createFullBackup(
testData.MEGOLM_SESSION_DATA.session_id,
testData.CURVE25519_KEY_BACKUP_DATA,
);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
await aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage();
@@ -521,9 +522,38 @@ describe("megolm-keys backup", () => {
expect(result.imported).toStrictEqual(1);
});
it("Should throw an error if the decryption key does not match the backup", async function () {
// Given the stored backup decryption key does not match the public backup info
// @ts-ignore - mock a private method for testing purpose
vi.spyOn(aliceCrypto.secretStorage, "get").mockResolvedValue(testData.BACKUP_DECRYPTION_KEY_BASE64_ALT);
const fullBackup = createFullBackup(
testData.MEGOLM_SESSION_DATA.session_id,
testData.CURVE25519_KEY_BACKUP_DATA,
);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", fullBackup);
// When we load that key, we throw because the keys don't match
await expect(aliceCrypto.loadSessionBackupPrivateKeyFromSecretStorage()).rejects.toThrow(
DecryptionKeyDoesNotMatchError,
);
});
it("Should throw an error if the decryption key is not found in cache", async () => {
await expect(aliceCrypto.restoreKeyBackup()).rejects.toThrow("No decryption key found in crypto store");
});
function createFullBackup(sessionId: string, data: KeyBackupSession) {
return {
rooms: {
[ROOM_ID]: {
sessions: {
[sessionId]: data,
},
},
},
};
}
});
describe("backupLoop", () => {
+8 -2
View File
@@ -23,7 +23,13 @@ import * as testUtils from "../../test-utils/test-utils";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data";
import { logger } from "../../../src/logger";
import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix";
import {
createClient,
HistoryVisibility,
PendingEventOrdering,
type IStartClientOpts,
type MatrixClient,
} from "../../../src/matrix";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder";
@@ -197,7 +203,7 @@ describe("Encrypted State Events", () => {
await startClientAndAwaitFirstSync();
// Alice shares a room with Bob
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true));
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], HistoryVisibility.Joined, ROOM_ID, true));
await syncPromise(aliceClient);
// ... and claim one of Bob's OTKs ...
@@ -14,7 +14,7 @@ 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 { QrCodeData, QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "@fetch-mock/vitest";
import {
@@ -146,7 +146,7 @@ describe("MSC4108SignInWithQR", () => {
const ourChannel = new MSC4108SecureChannel(ourMockSession);
const qrCodeData = QrCodeData.fromBytes(
await ourChannel.generateCode(QrCodeMode.Reciprocate, client.getDomain()!),
await ourChannel.generateCode(QrCodeIntent.Reciprocate, client.getDomain()!),
);
const opponentChannel = new MSC4108SecureChannel(opponentMockSession, qrCodeData.publicKey);
+26 -17
View File
@@ -84,9 +84,9 @@ def main() -> None:
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import {{ IDownloadKeyResult, IEvent }} from "../../../src";
import {{ KeyBackupSession, KeyBackupInfo }} from "../../../src/crypto-api/keybackup";
import type {{ IDeviceKeys, IMegolmSessionData }} from "../../../src/@types/crypto";
import type {{ IDownloadKeyResult, IEvent }} from "../../../src";
import type {{ KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions }} from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -246,15 +246,6 @@ export const {prefix}SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult>
/** Signed OTKs, returned by `POST /keys/claim` */
export const {prefix}ONE_TIME_KEYS = { json.dumps(otks, indent=4) };
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const {prefix}MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = {
json.dumps(set_of_exported_room_keys, indent=4)
@@ -278,6 +269,23 @@ export const {prefix}CLEAR_EVENT: Partial<IEvent> = {json.dumps(clear_event, ind
/** The encrypted CLEAR_EVENT by MEGOLM_SESSION_DATA */
export const {prefix}ENCRYPTED_EVENT: Partial<IEvent> = {json.dumps(encrypted_event, indent=4)};
/** base64-encoded backup decryption (private) key */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE64 = "{ user_data['B64_BACKUP_DECRYPTION_KEY'] }";
/** Backup decryption key in export format */
export const {prefix}BACKUP_DECRYPTION_KEY_BASE58 = "{ backup_recovery_key }";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}/{{sessionId}}` */
export const {prefix}SIGNED_BACKUP_DATA: KeyBackupInfo = { json.dumps(backup_data, indent=4) };
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{{roomId}}`.
* Contains the key from {prefix}MEGOLM_SESSION_DATA.
*/
export const {prefix}PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {{
[{prefix}MEGOLM_SESSION_DATA.session_id]: {prefix}CURVE25519_KEY_BACKUP_DATA
}};
"""
alt_master_key = user_data.get("ALT_MASTER_CROSS_SIGNING_PRIVATE_KEY_BYTES")
@@ -385,7 +393,7 @@ def sign_json(json_object: dict, private_key: ed25519.Ed25519PrivateKey) -> str:
def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tuple[dict, ed25519.Ed25519PrivateKey]:
"""
Creates an exported megolm room key, as per https://gitlab.matrix.org/matrix-org/olm/blob/master/docs/megolm.md#session-export-format
that can be imported via importRoomKeys API.
that can be imported via importRoomKeys API, or shared via MSC4268 room history sharing.
Returns the exported key, the matching privat edKey (needed to encrypt)
"""
index = 0
@@ -409,11 +417,12 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
"session_id": encode_base64(
private_key.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
),
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": {
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
},
"forwarding_curve25519_key_chain": [],
"m.shared_history": True,
}
return megolm_export, private_key
@@ -458,7 +467,7 @@ def symetric_ratchet_step_of_megolm_key(previous: dict , megolm_private_key: ed2
"room_id": "!room:id",
"sender_key": previous["sender_key"],
"session_id": previous["session_id"],
"session_key": encode_base64(exported_key),
"session_key": encode_base64(bytes(exported_key)),
"sender_claimed_keys": previous["sender_claimed_keys"],
"forwarding_curve25519_key_chain": [],
}
@@ -609,7 +618,7 @@ def generate_encrypted_event_content(exported_key: dict, ed_key: ed25519.Ed25519
message += signature
cipher_text = encode_base64(message)
cipher_text = encode_base64(bytes(message))
encrypted_payload = {
"algorithm" : "m.megolm.v1.aes-sha2",
@@ -653,7 +662,7 @@ def export_recovery_key(key_b64: str) -> str:
export_bytes += parity_byte.to_bytes(1, 'big')
# The byte string is encoded using base58
recovery_key = base58.b58encode(export_bytes).decode('utf-8')
recovery_key = base58.b58encode(bytes(export_bytes)).decode('utf-8')
split = [recovery_key[i:i + 4] for i in range(0, len(recovery_key), 4)]
return ' '.join(split)
+76 -51
View File
@@ -3,9 +3,9 @@
* Do not edit by hand! This file is generated by `./generate-test-data.py`
*/
import { type IDeviceKeys, type IMegolmSessionData } from "../../../src/@types/crypto";
import { type IDownloadKeyResult, type IEvent } from "../../../src";
import { type KeyBackupSession, type KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import type { IDeviceKeys, IMegolmSessionData } from "../../../src/@types/crypto";
import type { IDownloadKeyResult, IEvent } from "../../../src";
import type { KeyBackupSession, KeyBackupInfo, KeyBackupRoomSessions } from "../../../src/crypto-api/keybackup";
/* eslint-disable comma-dangle */
@@ -118,26 +118,6 @@ export const ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -149,7 +129,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -160,7 +141,8 @@ export const MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "IrkbT6H+0urDf6wKDSyVC1fh1t84Vz6T62snni86Cog"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -174,7 +156,8 @@ export const MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "Bhbpt6hqMZlSH4sJV7xiEEEiPVeTWz4Vkujl1EMdIPI"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of MEGOLM_SESSION_DATA */
@@ -196,7 +179,7 @@ export const CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d1BPGxhDl8AhPcBu3//Kzlq2o5CssPsw+88gRehkAsPg9Zp5G9sL9to6giltvTWTbsaQpmvv3HLmBOYSFIxvyZrOT/Ffqu325f0IEsKcyV2BdIkw8Ob9Xt+VWoe4MYEGG6y1T8W125zeFgKWI4Ow76uput64H9zZjIo+Cc+hCTO9Ea4EnosSjizCotevkNck7C/zGgfhBikiohROb6SbaZgxicSsEDZ+f7brnri9yP3iXS3PMDHHpa1+XzG2VOG/Y9OQZpkPq+pbLrCC+NWJeJPslDAK5i+RURwzjnPmaHKCRHTq86CwhFyiCDf61MGwCY3xjrmBJg44BCdxWqCx0YJvwsvVqqnl4vTieUfrwThNPsQ81aVkDHvlmrgrTt8icDa8jTJhu34jem+pbRSEM5aJikV4B+zYiLz+dH/v6UpYA2eG8ReOvwpPXp6CAcIlplRPpWbMBeLFVcPkT4KAXTp9exFpB4on4pf8OsaDomlt4qAA0rhAZmhPWPKcU/A0Tz4gyMu54OivVtw1SPj+5Iq+YDQ8jB6Po3ApzMf6fwF9x/FjevbboFB05X2Jr0NrbFqXMOUwXHMgDAGiIWX8+gkmmbaiNWqg2etjN94pobQSGZelb18XGN7kuwMk+Zwk7A",
"ciphertext": "r6HRk2/Im2yJe5cLP8R81aVjFWjYWPHpw7TVxphiSK1cdIDZTTK57r6MfU+0i/mTPn+/PosT74OvYwCnehy2d/r0NTff1SQt+1GopZkT0nq6jF5Wh/oX+8iwtYjHvTxMpN1UQoXAvRF40O+EVg+Q3efJXh1t45cMco8EWU64VerOir+k7cQ3C9FtcgQw3kmz3s3HeVY10o13X/w6+rc8n6vXqxuIxYHnFxanxX8B6TgTMZNajNfVsmJV0aC1aezim7E2gsftc+6+zW5G+rCFaEsWV/IuSOUz0+Hh0U+7hzSrz9/4qXPEVmPy1f6Ll4hhquPAlXPVDwddqlJDYj7kmvzr1g3bKVpk+TtKDbWlVQDPaJx2DEI2jGkPYjhYb7okpTFKpUny94dZmFIQqCeSGPIniaq8Y+/CanugQ1ZRVQcThuXrTewqWhXcpVvkVHT9i4ImcpBl95HzCBXuiwSUv6FKvO25fp++w555rbn2piFtilrUwnkrZPW32jFuaQcKZF4mZwcLeH7POL5UCuS4TWyaKyArp7bRzXwWuIq1wPET2nAMUmUVL7ge2+tAevk1WOIsjLgSaz/g55wO3Yma7yhXRFKcnzTjS0hUQOZ3GfTNwCM4pjzAtIPzvVd4Fp0b1emWZS5WyOYdXsceEDi3c6WtkoHWOKhPU0zBzn8hA9TdlFFqKzf2QFbN5Zgg0gprDLnLWgpc3/ieI4C7ndEQ7ZeTNMXbT/Y10APFk3qO+IGkLXJ97/qTF41EXFDhlsL0",
"ephemeral": "q+P1WdRtEiPIEtNuuGrRcueZxUbLnSKdsuTAkxewXgU",
"mac": "OibmACbORhI"
}
@@ -229,6 +212,37 @@ export const ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key that matches the public key in CURVE25519_KEY_BACKUP_DATA */
export const BACKUP_DECRYPTION_KEY_BASE64 = "dwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** base64-encoded backup decryption (private) key that does not match the public key in CURVE25519_KEY_BACKUP_DATA */
export const BACKUP_DECRYPTION_KEY_BASE64_ALT = "dh4fP2LITyJusgnb0dEq/SQK253WGObvLxXF5FEX6qc";
/** Backup decryption key in export format */
export const BACKUP_DECRYPTION_KEY_BASE58 = "EsTc LW2K PGiF wKEA 3As5 g5c4 BXwk qeeJ ZJV8 Q9fu gUMN UE4d";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "hSDwCYkwp1R0i33ctD73Wg2/Og0mOBr066SpjqqbTmo",
"signatures": {
"@alice:localhost": {
"ed25519:test_device": "KDSNeumirTsd8piI0oVfv/wzg4J4HlEc7rs5XhODFcJ/YAcUdg65ajsZG+rLI0TQOSSGjorJqcrSiSB1HRSCAA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from MEGOLM_SESSION_DATA.
*/
export const PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[MEGOLM_SESSION_DATA.session_id]: CURVE25519_KEY_BACKUP_DATA
};
// Bob data
export const BOB_TEST_USER_ID = "@bob:xyz";
@@ -338,26 +352,6 @@ export const BOB_ONE_TIME_KEYS = {
}
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/** A set of megolm keys that can be imported via CryptoAPI#importRoomKeys */
export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
{
@@ -369,7 +363,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "F4P7f1Z0RjbiZMgHk1xBCG3KC4/Ng9PmxLJ4hQ13sHA"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
},
{
"algorithm": "m.megolm.v1.aes-sha2",
@@ -380,7 +375,8 @@ export const BOB_MEGOLM_SESSION_DATA_ARRAY: IMegolmSessionData[] = [
"sender_claimed_keys": {
"ed25519": "OsZMdC1gQ5nPr+L9tuT6xXsaFJkVPkgxP2FexHF1/QM"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
}
];
@@ -394,7 +390,8 @@ export const BOB_MEGOLM_SESSION_DATA: IMegolmSessionData = {
"sender_claimed_keys": {
"ed25519": "zBdpQwWYyz1MkZuEUhXqcdMfUNN/B9psLFDDDTJOg64"
},
"forwarding_curve25519_key_chain": []
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": true
};
/** A ratcheted version of BOB_MEGOLM_SESSION_DATA */
@@ -416,7 +413,7 @@ export const BOB_CURVE25519_KEY_BACKUP_DATA: KeyBackupSession = {
"forwarded_count": 0,
"is_verified": false,
"session_data": {
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAMW+71BJqIPQW910vV7SX3IcGylnoUcS3doVkJZiprXytXMP89AKcgv5Dj7mS2ZdvNGE+Atro74bzZ5yot5BrE0ZE5SjoUBPLaLMMu9HopLIV+qx01Rc3F0wmkocSPo51N0nv6wvO5Cst0FiOGHDK6r1pFlgDEJLmBkOyC4e8oMVbKTJzsSQVbJ8tJ37xuhI+T5P0ZlmiqKDqYRp8uh50w+txLEixYhEUunFgCTt1DAmiS9pLNYhLyl1ggwuQjzZe+AV6timbRxNJy18/AEcPomJw7z/pxYIiNLHRKOC13Wp8kGWx9cOgfMQ5KmBuLS8psGiLTBkfWPLOfNYqjbeqAR+OGZQoS6hUjbBYU7QuFa4FOYBHkNB2UqNsdsMb9qB/qs7QGTSb8Lok5YjW1c81BUpmIyKvuqnKma0MZskrpTYGQD2eJDABFCZwLFm+LgDyUTeSiV5xguYztLrHOk8LHKo9M8dIZgoBjeFVJxyjbcXKsVS3aQkMXKCrRlKLqhZTws/ZJwVfW9DbktZ9dT+tRZQvI7tjJofojcLX61AGJDnqUf5+2Gv1tEnmUI953gIzc8NlcFabPOsDsZEODt7MdOCTPT3w29umyhKbCsslpb64LoS/AB2QRPRCgkJS7snRA",
"ciphertext": "d7UVOK17WEVky/8hK0h3HsTQrFMEbKbfqMcl2KtyTWcI9S5gGFWK9Git5BzVRxRggvxQ0c8PDfqL+dr3zHytAA7TEpHlx8Ks23hCqXmVW710VjqK2K9xnWCyJvkHfE8x0w6AYvffDj+tRVP8C8M7t4849rD2itn0uma+YMkvjG/nANUTxG1dBf3oUOZ673vflCPoaz7s7x9ZNhYDVSVH5JTdMgNwwN42R5dqqxnGTu516tJzJh/9BWvyD9oIPWJ8X0rt1sbzEJ3PZeBXcSy8GTlZ1SgSFjeiXlwYxOZCaX2sxprk4N1oI1db6g+wCDBhbCGGucJIlTDJna/h9/C5J4drGd/fkisG3SidUmJXXCyInhs/BhwjGAtTGeQS8j7R8UnJxhMulYBHSckzj0Kas71LElPp8W8M4Jq81APA03n5UfYB+U6jbxjDgf8OJnxGQyrteq9F2+SEvS/TwHe1pE3t6EM2mDYRoYDTpU5pTNYSJkGIQMfWJKRxxuWUGs29o1twewJ6dhHgm+SlCII0M7ESoVdV54vxZCvHZnPcR0NXDzal7ils7zBKJmamHfPQBuaqNPU3KmSo+5R8ngFPaWU5LbWqYp/WxSBfNCoLZ7Jf8Io5uitjXTATR2qy2r6l/RJmk3RlfP51kliQqI2TWqRF96oaB96IGgUGSFCX/2pv0psOBGc1SjfmMB3d7gYis+2iBYVbG3xmnpeXbqvlD0Lw9TiTIPkjhJkTW1+lXyhy1xVH9ZmcFamcL7bX15Jx",
"ephemeral": "oO0VX84OUIzm2i/12zAhTWOZT5IFRH5mXaKZ8fXkCgU",
"mac": "lEfHlqfJQwU"
}
@@ -449,6 +446,34 @@ export const BOB_ENCRYPTED_EVENT: Partial<IEvent> = {
"origin_server_ts": 1507753886000
};
/** base64-encoded backup decryption (private) key */
export const BOB_BACKUP_DECRYPTION_KEY_BASE64 = "DwdtCnMYpX08FsFyUbJmRd9ML4frwJkqsXf7pR25LCo=";
/** Backup decryption key in export format */
export const BOB_BACKUP_DECRYPTION_KEY_BASE58 = "EsT5 Sd5m mEXs NQYE ibRe 3q9E 4aXW rHih 5f9J 6rU6 AfwY mASR";
/** Signed backup data, suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}/{sessionId}` */
export const BOB_SIGNED_BACKUP_DATA: KeyBackupInfo = {
"algorithm": "m.megolm_backup.v1.curve25519-aes-sha2",
"version": "1",
"auth_data": {
"public_key": "ZRuVWcWlDuvOwZRygccUCD4Avtnt130800I+WQNwwRY",
"signatures": {
"@bob:xyz": {
"ed25519:bob_device": "lDIMj3VC0WazE2FamGHpmbiqKf9Z4pO4qapZ5TL5BnD3c+dvb+2waOEd6pgay/pmrQ6MW4Eu2KDEpe1fnHc3BA"
}
}
}
};
/**
* Per-room backup data, (supposedly) suitable for return from `GET /_matrix/client/v3/room_keys/keys/{roomId}`.
* Contains the key from BOB_MEGOLM_SESSION_DATA.
*/
export const BOB_PER_ROOM_CURVE25519_KEY_BACKUP_DATA: KeyBackupRoomSessions = {
[BOB_MEGOLM_SESSION_DATA.session_id]: BOB_CURVE25519_KEY_BACKUP_DATA
};
/** A second set of signed cross-signing keys data, also suitable for returning from a `/keys/query` call */
export const BOB_ALT_SIGNED_CROSS_SIGNING_KEYS_DATA: Partial<IDownloadKeyResult> = {
"master_keys": {
+18 -4
View File
@@ -13,6 +13,7 @@ import {
import {
ClientEvent,
EventType,
HistoryVisibility,
type IJoinedRoom,
type IPusher,
type ISyncResponse,
@@ -57,14 +58,19 @@ export function syncPromise(client: MatrixClient, count = 1): Promise<void> {
}
/**
* Return a sync response which contains a single room (by default TEST_ROOM_ID), with the members given
* @param roomMembers
* @param roomId
* Return a sync response which contains a single room (by default `TEST_ROOM_ID`), with the members given
* and history visibility set to `shared`.
*
* @returns the sync response
* @param roomMembers - An array of user IDs representing the members of the room.
* @param roomHistoryVisibility - The history visibility setting for the room. Defaults to `shared`.
* @param roomId - The ID of the room. Defaults to `TEST_ROOM_ID`.
* @param encryptStateEvents - A boolean indicating whether state events should be encrypted. Defaults to `false`.
*
* @returns The sync response object containing the room data.
*/
export function getSyncResponse(
roomMembers: string[],
roomHistoryVisibility: HistoryVisibility = HistoryVisibility.Shared,
roomId = TEST_ROOM_ID,
encryptStateEvents = false,
): ISyncResponse {
@@ -85,6 +91,14 @@ export function getSyncResponse(
"io.element.msc4362.encrypt_state_events": encryptStateEvents,
},
}),
mkEventCustom({
sender: roomMembers[0],
type: "m.room.history_visibility",
state_key: "",
content: {
history_visibility: roomHistoryVisibility,
},
}),
],
},
timeline: {
+1 -1
View File
@@ -32,7 +32,7 @@ describe("NamespacedValue", () => {
});
it("should have a falsey unstable if needed", () => {
const ns = new NamespacedValue("stable");
const ns = new NamespacedValue("stable", null);
expect(ns.name).toBe(ns.stable);
expect(ns.altName).toBeFalsy();
expect(ns.names).toEqual([ns.stable]);
+58
View File
@@ -3940,6 +3940,31 @@ describe("MatrixClient", function () {
});
expect(httpLookups.length).toEqual(0);
});
it("should handle no jwks_uri", async () => {
const { jwks_uri: _, ...metadata } = mockOpenIdConfiguration();
httpLookups = [
{
method: "GET",
path: `/auth_metadata`,
error: new MatrixError({ errcode: "M_UNRECOGNIZED" }, 404),
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
{
method: "GET",
path: `/auth_issuer`,
data: { issuer: metadata.issuer },
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
fetchMock.get("https://auth.org/.well-known/openid-configuration", metadata);
await expect(client.getAuthMetadata()).resolves.toEqual({
...metadata,
signingKeys: null,
});
expect(httpLookups.length).toEqual(0);
});
});
describe("identityHashedLookup", () => {
@@ -4001,4 +4026,37 @@ describe("MatrixClient", function () {
]);
});
});
describe("getUrlPreview", () => {
it("makes a well-formed request to the new endpoint", async () => {
client.getVersions = vi.fn().mockResolvedValue({
versions: ["v1.11"],
});
httpLookups = [
{
method: "GET",
path: `/media/preview_url`,
expectQueryParams: { url: "https://example.org/", ts: "60000" },
data: { "og:title": "Test" },
prefix: "/_matrix/client/v1",
},
];
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
"og:title": "Test",
});
});
it("makes a well-formed request to the old endpoint", async () => {
httpLookups = [
{
method: "GET",
path: `/preview_url`,
expectQueryParams: { url: "https://example.org/", ts: "60000" },
data: { "og:title": "Test" },
prefix: "/_matrix/media/v3",
},
];
expect(await client.getUrlPreview("https://example.org", 60000)).toEqual({
"og:title": "Test",
});
});
});
});
+81 -63
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,23 +14,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IContent, type MatrixEvent } from "../../../src";
import {
CallMembership,
type SessionMembershipData,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
} from "../../../src/matrixrtc/CallMembership";
import { membershipTemplate } from "./mocks";
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
} as unknown as MatrixEvent;
}
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
import { type IContent, type MatrixEvent } from "../../../src/models/event.ts";
import { EventType } from "../../../src/@types/event.ts";
import { CallMembership, DEFAULT_EXPIRE_DURATION } from "../../../src/matrixrtc/CallMembership.ts";
function createCallMembership(ev: MatrixEvent, content: IContent): CallMembership {
vi.mocked(ev.getContent).mockReturnValue(content);
@@ -40,6 +27,15 @@ function createCallMembership(ev: MatrixEvent, content: IContent): CallMembershi
describe("CallMembership", () => {
describe("SessionMembershipData", () => {
function makeMockEvent(originTs = 0): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue({}),
getType: vi.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
} as unknown as MatrixEvent;
}
beforeEach(() => {
vi.useFakeTimers();
});
@@ -158,10 +154,35 @@ describe("CallMembership", () => {
expect(membership.eventId).toBe("$eventid");
});
it("returns correct slot_id", () => {
// slot_id is application and call_id dependent. So we create
// a membership for each possible combination
// non call application (should not alter call_id even with empty string)
const nonCallMembership = createCallMembership(makeMockEvent(), {
...membershipTemplate,
application: "m.not.a.call",
call_id: "",
});
// non "" call id should not be altered
const callMembershipCustomId = createCallMembership(makeMockEvent(), {
...membershipTemplate,
call_id: "customCallId",
});
// for membership (application = m.call and call_id = "") we expect "" -> ROOM
// for legacy events we expect the room to be added automagically
// See INFO_SLOT_ID_LEGACY_CASE comments
expect(membership.slotId).toBe("m.call#ROOM");
expect(membership.slotDescription).toStrictEqual({ id: "ROOM", application: "m.call" });
expect(nonCallMembership.slotId).toBe("m.not.a.call#");
expect(nonCallMembership.slotDescription).toStrictEqual({ id: "", application: "m.not.a.call" });
expect(callMembershipCustomId.slotId).toBe("m.call#customCallId");
expect(callMembershipCustomId.slotDescription).toStrictEqual({
id: "customCallId",
application: "m.call",
});
});
it("returns correct deviceId", () => {
expect(membership.deviceId).toBe("AAAAAAA");
@@ -187,9 +208,40 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(true);
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
});
afterEach(() => {
vi.useFakeTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
});
describe("RtcMembershipData", () => {
function makeMockEvent(originTs = 0, content: IContent = {}): MatrixEvent {
return {
getTs: vi.fn().mockReturnValue(originTs),
getSender: vi.fn().mockReturnValue("@alice:example.org"),
getId: vi.fn().mockReturnValue("$eventid"),
getContent: vi.fn().mockReturnValue(content),
getType: vi.fn().mockReturnValue(EventType.RTCMembership),
} as unknown as MatrixEvent;
}
const membershipTemplate: RtcMembershipData = {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
@@ -209,6 +261,11 @@ describe("CallMembership", () => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "invalid_slot_id" });
}).toThrow();
});
it("rejects membership with slot_id that contains extra #", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#mycall#extra" });
}).toThrow();
});
it("accepts membership with valid slot_id", () => {
expect(() => {
createCallMembership(makeMockEvent(), { ...membershipTemplate, slot_id: "m.call#" });
@@ -309,13 +366,9 @@ describe("CallMembership", () => {
}).toThrow();
});
it.skip("considers memberships unexpired if local age low enough", () => {
// TODO link prev event
});
it.skip("considers memberships expired if local age large enough", () => {
// TODO link prev event
});
// TODO link prev event
it.todo("considers memberships unexpired if local age low enough");
it.todo("considers memberships expired if local age large enough");
describe("getTransport", () => {
it("gets the correct active transport with oldest_membership", () => {
@@ -372,44 +425,9 @@ describe("CallMembership", () => {
expect(membership.isExpired()).toBe(false);
});
});
});
describe("expiry calculation", () => {
let fakeEvent: MatrixEvent;
let membership: CallMembership;
beforeEach(() => {
// server origin timestamp for this event is 1000
fakeEvent = makeMockEvent(1000);
membership = createCallMembership(fakeEvent!, membershipTemplate);
vi.useFakeTimers();
it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
});
afterEach(() => {
vi.useRealTimers();
});
it("calculates time until expiry", () => {
vi.setSystemTime(2000);
// should be using absolute expiry time
expect(membership.getMsUntilExpiry()).toEqual(DEFAULT_EXPIRE_DURATION - 1000);
});
});
it("uses unpadded base64 for RTC backend identities", async () => {
expect(
await CallMembership.computeRtcBackendIdentity(makeMockEvent(), {
kind: "rtc",
data: {
slot_id: "m.call#",
application: { "type": "m.call", "m.call.id": "", "m.call.intent": "voice" },
member: { user_id: "@alice:example.org", device_id: "AAAAAAA", id: "xyzRANDOMxyz" },
rtc_transports: [{ type: "livekit" }],
versions: [],
msc4354_sticky_key: "abc123",
},
}),
).toBe("2+h2ELE1XY/NsuveToZOekORCoyQMO6V0W7XZUWk5Q4");
});
});
File diff suppressed because it is too large Load Diff
@@ -16,23 +16,50 @@ limitations under the License.
import { ClientEvent, EventTimeline, MatrixClient, type Room, RoomStateEvent } from "../../../src";
import { MatrixRTCSessionManager, MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc";
import { makeMockRoom, type MembershipData, membershipTemplate, mockRoomState, mockRTCEvent } from "./mocks";
import {
makeMockRoom,
type MembershipData,
sessionMembershipTemplate,
mockRoomState,
mockRTCEvent,
rtcMembershipTemplate,
} from "./mocks.ts";
import { logger } from "../../../src/logger";
import { flushPromises } from "../../test-utils/flushPromises";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
"MatrixRTCSessionManager ($eventKind)",
({ eventKind }) => {
let client: MatrixClient;
function generateMembership(opts: { type: string; callId?: string } = { type: "m.call" }): MembershipData {
if (eventKind === "sticky") {
return {
...rtcMembershipTemplate,
slot_id: opts.callId ? `${opts.type}#${opts.callId}` : rtcMembershipTemplate.slot_id,
application: {
...rtcMembershipTemplate.application,
type: opts.type,
},
} satisfies RtcMembershipData & { user_id: string };
}
return {
...sessionMembershipTemplate,
application: opts.type,
call_id: opts.callId ?? sessionMembershipTemplate.call_id, // approximate version.
} satisfies SessionMembershipData & { user_id: string };
}
async function sendLeaveMembership(room: Room, membershipData: MembershipData[]): Promise<void> {
if (eventKind === "memberState") {
mockRoomState(room, [{ user_id: membershipTemplate.user_id }]);
mockRoomState(room, [{ user_id: sessionMembershipTemplate.user_id }]);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
const membEvent = roomState.getStateEvents("org.matrix.msc3401.call.member")[0];
client.emit(RoomStateEvent.Events, membEvent, roomState, null);
} else {
membershipData.splice(0, 1, { user_id: membershipTemplate.user_id });
membershipData.splice(0, 1, { user_id: sessionMembershipTemplate.user_id });
client.emit(ClientEvent.Event, mockRTCEvent(membershipData[0], room.roomId, 10000));
}
await flushPromises();
@@ -46,22 +73,17 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
vi.resetAllMocks();
});
it("Fires event when session starts", async () => {
const onStarted = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([membershipTemplate], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
}
const room1 = makeMockRoom([generateMembership({ type: "m.call" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
client.emit(ClientEvent.Room, room1);
await expect(sessionStartedPromise).resolves.toBeTruthy();
});
it("Doesn't fire event if unrelated sessions starts", () => {
@@ -69,7 +91,7 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
try {
const room1 = makeMockRoom([{ ...membershipTemplate, application: "m.other" }], eventKind === "sticky");
const room1 = makeMockRoom([generateMembership({ type: "m.other" })], eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
@@ -80,17 +102,24 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
});
it("Fires event when session ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membershipData: MembershipData[] = [membershipTemplate];
const sessionStartedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
client.matrixRTC.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
const membershipData: MembershipData[] = [generateMembership()];
const room1 = makeMockRoom(membershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
client.emit(ClientEvent.Room, room1);
await flushPromises();
await sessionStartedPromise;
await sendLeaveMembership(room1, membershipData);
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
await expect(sessionEndedPromise).resolves.toStrictEqual([
room1.roomId,
client.matrixRTC.getActiveRoomSession(room1),
]);
});
it("Fires correctly with custom sessionDescription", async () => {
@@ -106,48 +135,44 @@ describe.each([{ eventKind: "sticky" }, { eventKind: "memberState" }])(
sessionManager.start();
sessionManager.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
sessionManager.on(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
const sessionStartedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionStarted, resolve),
);
const sessionEndedPromise = new Promise((resolve) =>
sessionManager.once(MatrixRTCSessionManagerEvents.SessionEnded, (...params) => resolve(params)),
);
try {
// Create a session for applicaation m.other, we ignore this session ecause it lacks a call_id
const room1MembershipData: MembershipData[] = [{ ...membershipTemplate, application: "m.other" }];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.other, we ignore this session because it lacks a call_id
const room1MembershipData: MembershipData[] = [generateMembership({ type: "m.other" })];
const room1 = makeMockRoom(room1MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
client.emit(ClientEvent.Room, room1);
await flushPromises();
expect(onStarted).not.toHaveBeenCalled();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has a call_id
const room2MembershipData: MembershipData[] = [
{ ...membershipTemplate, application: "m.notCall", call_id: "test" },
];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1, room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
expect(onStarted).toHaveBeenCalled();
onStarted.mockClear();
// Create a session for applicaation m.notCall. We expect this call to be tracked because it has matching call_id
const room2MembershipData: MembershipData[] = [generateMembership({ type: "m.notCall", callId: "test" })];
const room2 = makeMockRoom(room2MembershipData, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room2]);
client.emit(ClientEvent.Room, room2);
await flushPromises();
await sessionStartedPromise;
// Stop room1's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
expect(onEnded).toHaveBeenCalled();
onEnded.mockClear();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
// Stop room1's RTC session. Not tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room1);
await sendLeaveMembership(room1, room1MembershipData);
expect(onEnded).not.toHaveBeenCalled();
} finally {
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionStarted, onStarted);
client.matrixRTC.off(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
}
// Stop room2's RTC session. Tracked.
vi.spyOn(client, "getRoom").mockReturnValue(room2);
await sendLeaveMembership(room2, room2MembershipData);
await sessionEndedPromise;
});
it("Doesn't fire event if unrelated sessions ends", async () => {
const onEnded = vi.fn();
client.matrixRTC.on(MatrixRTCSessionManagerEvents.SessionEnded, onEnded);
const membership: MembershipData[] = [{ ...membershipTemplate, application: "m.other_app" }];
const membership: MembershipData[] = [generateMembership({ type: "m.other_app" })];
const room1 = makeMockRoom(membership, eventKind === "sticky");
vi.spyOn(client, "getRooms").mockReturnValue([room1]);
vi.spyOn(client, "getRoom").mockReturnValue(room1);
@@ -0,0 +1,27 @@
/*
Copyright 2026 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 { computeRtcIdentityRaw } from "../../../src/matrixrtc/membershipData/index.ts";
describe("computeRtcIdentityRaw", () => {
it("should compute the correct identity hash", async () => {
// Test vector taken from the spec, with the expected output updated to match the unpadded base64 encoding
// https://github.com/hughns/matrix-spec-proposals/blob/hughns/matrixrtc-livekit/proposals/4195-matrixrtc-livekit.md#appendix-hash-derivation-test-vectors
const result = await computeRtcIdentityRaw("@alice:example.com", "DEVICE123", "memberABC");
// Add assertions based on expected hash output
expect(result).toBe("J+T45tGruxc+HrUOqJJlyQSV33m728Cme4+vt8/SWrU");
});
});
+62 -17
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 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.
@@ -25,15 +25,16 @@ import {
type Room,
MAX_STICKY_DURATION_MS,
} from "../../../src";
import { MembershipManagerEvent, Status, type Transport, type LivekitFocusSelection } from "../../../src/matrixrtc";
import {
MembershipManagerEvent,
Status,
type Transport,
type SessionMembershipData,
type LivekitFocusSelection,
} from "../../../src/matrixrtc";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
makeMockClient,
makeMockRoom,
sessionMembershipTemplate,
mockCallMembership,
type MockClient,
} from "./mocks.ts";
import { MembershipManager, StickyEventMembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
import { type SessionMembershipData } from "../../../src/matrixrtc/membershipData/index.ts";
/**
* Create a promise that will resolve once a mocked method is called.
@@ -90,7 +91,7 @@ describe("MembershipManager", () => {
// Default to fake timers.
vi.useFakeTimers();
client = makeMockClient("@alice:example.org", "AAAAAAA");
room = makeMockRoom([membershipTemplate]);
room = makeMockRoom([sessionMembershipTemplate]);
// Provide a default mock that is like the default "non error" server behaviour.
vi.mocked(client._unstable_sendDelayedStateEvent).mockResolvedValue({ delay_id: "id" });
vi.mocked(client._unstable_updateDelayedEvent).mockResolvedValue({});
@@ -139,6 +140,7 @@ describe("MembershipManager", () => {
"org.matrix.msc3401.call.member",
{
application: "m.call",
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
call_id: "",
device_id: "AAAAAAA",
expires: 14400000,
@@ -147,6 +149,7 @@ describe("MembershipManager", () => {
focus_active: focusActive,
scope: "m.room",
},
// This tests INFO_SLOT_ID_LEGACY_CASE because it is using callSession = { id: "ROOM", application: "m.call" }
"_@alice:example.org_AAAAAAA_m.call",
);
restartScheduledDelayedEventHandle.resolve?.();
@@ -160,6 +163,45 @@ describe("MembershipManager", () => {
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("sends correct call_id and state key when using non empty string. Not using empty string -> ROOM hack. See: INFO_SLOT_ID_LEGACY_CASE", async () => {
// Spys/Mocks
const customCallSession = { id: "custom", application: "m.call" };
const restartScheduledDelayedEventHandle = createAsyncHandle<void>(
client._unstable_restartScheduledDelayedEvent,
);
// Test
const memberManager = new MembershipManager(undefined, room, client, customCallSession);
memberManager.join([focus], undefined);
// expects
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
{
application: "m.call",
call_id: "custom",
device_id: "AAAAAAA",
expires: 14400000,
foci_preferred: [focus],
membershipID: "@alice:example.org:AAAAAAA",
focus_active: focusActive,
scope: "m.room",
},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
restartScheduledDelayedEventHandle.resolve?.();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledWith(
room.roomId,
{ delay: 8000 },
"org.matrix.msc3401.call.member",
{},
"_@alice:example.org_AAAAAAA_m.callcustom",
);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new MembershipManager(undefined, room, client, callSession);
const waitForSendState = waitForMockCall(client.sendStateEvent);
@@ -359,7 +401,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(RESTART_DELAY);
// first simulate the sync, then resolve sending the delayed event.
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
// Let the scheduler run one iteration so that the new join gets sent
await vi.runOnlyPendingTimersAsync();
@@ -465,7 +507,7 @@ describe("MembershipManager", () => {
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new MembershipManager({}, room, client, callSession);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
@@ -488,7 +530,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
await manager.onRTCSessionMemberUpdate([
mockCallMembership(membershipTemplate, room.roomId),
mockCallMembership(sessionMembershipTemplate, room.roomId),
mockCallMembership(
{ ...(myMembership as SessionMembershipData), user_id: client.getUserId()! },
room.roomId,
@@ -514,7 +556,7 @@ describe("MembershipManager", () => {
vi.mocked(client._unstable_sendDelayedStateEvent).mockClear();
// Our own membership is removed:
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
await vi.advanceTimersByTimeAsync(1);
expect(client.sendStateEvent).toHaveBeenCalled();
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalled();
@@ -537,7 +579,7 @@ describe("MembershipManager", () => {
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await vi.advanceTimersByTimeAsync(10_000);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await manager.onRTCSessionMemberUpdate([mockCallMembership(sessionMembershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
await vi.advanceTimersByTimeAsync(10_000);
@@ -909,7 +951,10 @@ describe("MembershipManager", () => {
const manager = new MembershipManager({}, room, client, callSession);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership({ ...membershipTemplate, user_id: client.getUserId()! }, room.roomId);
const membership = mockCallMembership(
{ ...sessionMembershipTemplate, user_id: client.getUserId()! },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
await manager.updateCallIntent("video");
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
@@ -923,7 +968,7 @@ describe("MembershipManager", () => {
manager.join([]);
expect(manager.isActivated()).toEqual(true);
const membership = mockCallMembership(
{ ...membershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
{ ...sessionMembershipTemplate, "user_id": client.getUserId()!, "m.call.intent": "video" },
room.roomId,
);
await manager.onRTCSessionMemberUpdate([membership]);
@@ -998,7 +1043,7 @@ describe("MembershipManager", () => {
it("Should prefix log with MembershipManager used", () => {
const client = makeMockClient("@alice:example.org", "AAAAAAA");
const room = makeMockRoom([membershipTemplate]);
const room = makeMockRoom([sessionMembershipTemplate]);
const membershipManager = new MembershipManager(undefined, room, client, callSession);
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 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.
@@ -17,10 +17,10 @@ limitations under the License.
import { type Mock, type Mocked } from "vitest";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type CallMembership } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { sessionMembershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { logger } from "../../../src/logger.ts";
import { getEncryptionKeyMapKey } from "../../../src/matrixrtc/EncryptionManager.ts";
@@ -31,20 +31,10 @@ describe("RTCEncryptionManager", () => {
let encryptionManager: RTCEncryptionManager;
let getMembershipMock: Mock;
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: Mock;
let rtcIdentifierProvider: Mock;
beforeEach(() => {
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
getMembershipMock = vi.fn().mockReturnValue([]);
onEncryptionKeysChanged = vi.fn();
mockTransport = {
@@ -63,7 +53,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
@@ -223,8 +212,6 @@ describe("RTCEncryptionManager", () => {
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
// Test an edge case where the use key delay is higher than the grace period.
@@ -322,8 +309,6 @@ describe("RTCEncryptionManager", () => {
await vi.advanceTimersByTimeAsync(5000);
expect(onEncryptionKeysChanged).toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not rotate key when several users join within the rotation grace period", async () => {
@@ -464,8 +449,6 @@ describe("RTCEncryptionManager", () => {
},
"@alice:example.org:DEVICE01",
);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not distribute keys if encryption is disabled", async () => {
@@ -501,7 +484,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
@@ -525,7 +507,6 @@ describe("RTCEncryptionManager", () => {
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
});
it("should accept keys from transport", async () => {
@@ -597,8 +578,6 @@ describe("RTCEncryptionManager", () => {
},
"rtcIDCARL1",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
});
it("Should support quick re-joiner if keys received out of order", async () => {
@@ -914,7 +893,6 @@ describe("RTCEncryptionManager", () => {
{ userId: "@alice:example.org", deviceId: "DEVICE01", memberId: "@alice:example.org:DEVICE01" },
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
logger,
rtcIdentifierProvider,
@@ -983,7 +961,7 @@ describe("RTCEncryptionManager", () => {
rtcBackendIdentity: string,
): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
rtcBackendIdentity,
);
@@ -998,7 +976,7 @@ describe("RTCEncryptionManager", () => {
*/
function aStateBaseMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
{ ...membershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
{ ...sessionMembershipTemplate, user_id: userId, device_id: deviceId, created_ts: ts },
"!room:id",
`${userId}|${deviceId}`,
);
@@ -1,228 +0,0 @@
/*
Copyright 2025 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 Mocked } from "vitest";
import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
import { EventType, MatrixClient, RoomEvent } from "../../../src";
import { type IRoomTimelineData, MatrixEvent, type Room } from "../../../src";
import type { Logger } from "../../../src/logger.ts";
describe("RoomKeyTransport", () => {
let client: MatrixClient;
let room: Room & {
emitTimelineEvent: (event: MatrixEvent) => void;
};
let transport: RoomKeyTransport;
let mockLogger: Mocked<Logger>;
const onCallEncryptionMock = vi.fn();
beforeEach(() => {
onCallEncryptionMock.mockReset();
mockLogger = {
debug: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
} as unknown as Mocked<Logger>;
const statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
room = makeMockRoom([membershipTemplate]);
client = new MatrixClient({ baseUrl: "base_url" });
client.matrixRTC.start();
transport = new RoomKeyTransport(room, client, statistics, {
getChild: vi.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
onCallEncryptionMock(...p);
});
transport.start();
});
afterEach(() => {
client.stopClient();
client.matrixRTC.stop();
transport.stop();
});
it("Calls onCallEncryption on encryption keys event", async () => {
client.decryptEventIfNeeded = () => Promise.resolve();
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
});
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
await new Promise(process.nextTick);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
describe("event decryption", () => {
it("Retries decryption and processes success", async () => {
vi.useFakeTimers();
let isDecryptionFailure = true;
client.decryptEventIfNeeded = vi
.fn()
.mockReturnValueOnce(Promise.resolve())
.mockImplementation(() => {
isDecryptionFailure = false;
return Promise.resolve();
});
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: vi.fn().mockImplementation(() => isDecryptionFailure) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
vi.useRealTimers();
});
it("Retries decryption and processes failure", async () => {
try {
vi.useFakeTimers();
const onCallEncryptionMock = vi.fn();
client.decryptEventIfNeeded = vi.fn().mockReturnValue(Promise.resolve());
const timelineEvent = Object.assign(
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
}),
{ isDecryptionFailure: vi.fn().mockReturnValue(true) },
);
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// should retry after one second:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
// doesn't retry again:
await vi.advanceTimersByTimeAsync(1500);
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
} finally {
vi.useRealTimers();
}
});
});
describe("malformed events", () => {
const MALFORMED_EVENT = [
// empty content
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {},
}),
// no sender
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
content: {
call_id: "",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
// Call_id not empty string
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "FOO",
keys: [makeKey(0, "testKey")],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
// Various Malformed keys
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: "FOO",
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: [{ index: 0 }],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
new MatrixEvent({
type: EventType.CallEncryptionKeysPrefix,
sender: "@alice:example.com",
content: {
call_id: "",
keys: [
{
key: "BASE64KEY",
index: "mcall",
},
],
sent_ts: Date.now(),
device_id: "AAAAAAA",
},
}),
];
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
transport.onEncryptionEvent(event);
expect(mockLogger.warn).toHaveBeenCalled();
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
});
});
});
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "vitest";
import { makeMockEvent } from "./mocks";
import { makeMockEvent } from "./mocks.ts";
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
+39 -6
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 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.
@@ -18,12 +18,12 @@ import { EventEmitter } from "stream";
import { type Mocked, type MockedObject } from "vitest";
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc";
import { CallMembership } from "../../../src/matrixrtc";
import { secureRandomString } from "../../../src/randomstring";
import { type RtcMembershipData, type SessionMembershipData } from "../../../src/matrixrtc/membershipData";
import { type CallMembershipIdentityParts } from "../../../src/matrixrtc/EncryptionManager";
import { logger } from "../../../src/logger.ts";
export type MembershipData = (SessionMembershipData | {}) & { user_id: string };
export type MembershipData = (SessionMembershipData | RtcMembershipData | {}) & { user_id: string };
export const owmMemberIdentity: CallMembershipIdentityParts = {
deviceId: "AAAAAAA",
@@ -31,7 +31,7 @@ export const owmMemberIdentity: CallMembershipIdentityParts = {
userId: "@alice:example.org",
};
export const membershipTemplate: SessionMembershipData & { user_id: string } = {
export const sessionMembershipTemplate: SessionMembershipData & { user_id: string } = {
application: "m.call",
call_id: "",
user_id: "@mock:user.example",
@@ -52,6 +52,39 @@ export const membershipTemplate: SessionMembershipData & { user_id: string } = {
],
};
export const rtcMembershipTemplate: RtcMembershipData & { user_id: string } = {
user_id: "@mock:user.example",
application: {
type: "m.call",
},
member: {
id: "IDIDID",
user_id: "@mock:user.example",
device_id: "AAAAAAA",
},
slot_id: "m.call#ROOM",
versions: [],
rtc_transports: [
{
type: "livekit",
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: [
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.io",
type: "livekit",
},
{
livekit_alias: "!alias:something.org",
livekit_service_url: "https://livekit-jwt.something.dev",
type: "livekit",
},
],
},
],
msc4354_sticky_key: "m.call#",
};
export type MockClient = MockedObject<
Pick<
MatrixClient,
@@ -198,7 +231,7 @@ export function mockCallMembership(
const ev = mockRTCEvent(membershipData, roomId);
vi.mocked(ev.getContent).mockReturnValue(membershipData);
const data = CallMembership.membershipDataFromMatrixEvent(ev);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx", logger);
return new CallMembership(ev, data, rtcBackendIdentity ?? "xx");
}
export function makeKey(id: number, key: string): { key: string; index: number } {
+44 -1
View File
@@ -175,12 +175,29 @@ describe("oidc authorization", () => {
expect(authUrl.searchParams.get("login_hint")).toEqual("login1234");
});
it("should generate url with response_mode=fragment", async () => {
const nonce = "abc123";
const authUrl = new URL(
await generateOidcAuthorizationUrl({
metadata: delegatedAuthConfig,
homeserverUrl: baseUrl,
clientId,
redirectUri: baseUrl,
nonce,
responseMode: "fragment",
}),
);
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
});
});
describe("completeAuthorizationCodeGrant", () => {
const homeserverUrl = "https://server.org/";
const identityServerUrl = "https://id.org/";
const nonce = "test-nonce";
const nonce = "hRpB6pkE06";
const redirectUri = baseUrl;
const code = "auth_code_xyz";
const validBearerTokenResponse = {
@@ -290,6 +307,32 @@ describe("oidc authorization", () => {
expect(queryParams.get("code")).toEqual(code);
});
it("should make correct request to the token endpoint with response_mode=fragment", async () => {
const state = await setupState({ responseMode: "fragment" });
const codeVerifier = getValueFromStorage(state, "code_verifier");
await completeAuthorizationCodeGrant(code, state, "fragment");
expect(fetchMock.callHistory.lastCall(metadata.token_endpoint)?.options).toStrictEqual(
expect.objectContaining({
method: "post",
credentials: "same-origin",
headers: {
"accept": "application/json",
"content-type": "application/x-www-form-urlencoded",
},
}),
);
// check body is correctly formed
const queryParams = fetchMock.callHistory.lastCall(metadata.token_endpoint)!.options
.body as URLSearchParams;
expect(queryParams.get("grant_type")).toEqual("authorization_code");
expect(queryParams.get("client_id")).toEqual(clientId);
expect(queryParams.get("code_verifier")).toEqual(codeVerifier);
expect(queryParams.get("redirect_uri")).toEqual(redirectUri);
expect(queryParams.get("code")).toEqual(code);
});
it("should return with valid bearer token", async () => {
const state = await setupState();
const scope = getValueFromStorage(state, "scope");
+102
View File
@@ -275,6 +275,108 @@ describe("Relations", function () {
expect(badlyEditedTopic.getContent().topic).toBe("topic");
});
describe("m.replace async ordering", () => {
const userId = "@bob:example.com";
const roomId = "!room:example.com";
const targetEventId = "$target";
function makeEditEvent(eventId: string, ts: number): MatrixEvent {
return new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: eventId,
room_id: roomId,
origin_server_ts: ts,
content: {
"body": `edited ${eventId}`,
"msgtype": "m.text",
"m.new_content": {
body: `edited ${eventId}`,
msgtype: "m.text",
},
"m.relates_to": {
event_id: targetEventId,
rel_type: "m.replace",
},
},
});
}
it("should not let a slow-decrypting older edit overwrite a newer one", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
// Create two edits: edit1 is older (ts=2000), edit2 is newer (ts=3000).
const edit1 = makeEditEvent("$edit1", 2000);
const edit2 = makeEditEvent("$edit2", 3000);
// Simulate edit1 being in the process of decryption: isBeingDecrypted()
// returns true and getDecryptionPromise() returns a deferred promise.
let resolveEdit1Decryption!: () => void;
const edit1DecryptionPromise = new Promise<void>((resolve) => {
resolveEdit1Decryption = resolve;
});
vi.spyOn(edit1, "isBeingDecrypted").mockReturnValue(true);
vi.spyOn(edit1, "getDecryptionPromise").mockReturnValue(edit1DecryptionPromise);
vi.spyOn(edit1, "shouldAttemptDecryption").mockReturnValue(false);
// edit2 is already decrypted.
vi.spyOn(edit2, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit2, "shouldAttemptDecryption").mockReturnValue(false);
// Add edit1 first (it will block on decryption).
const addEdit1Promise = relations.addEvent(edit1);
// While edit1 is still decrypting, add edit2 (resolves immediately).
await relations.addEvent(edit2);
// edit2 should be applied as the replacement (it's newer).
expect(targetEvent.replacingEvent()).toBe(edit2);
// Now resolve edit1's decryption — the stale result must NOT overwrite edit2.
resolveEdit1Decryption();
await addEdit1Promise;
// edit2 must still be the replacing event, not edit1.
expect(targetEvent.replacingEvent()).toBe(edit2);
});
it("should apply an edit correctly when there is no concurrency", async () => {
const room = new Room(roomId, new TestClient(userId).client, userId);
const relations = new Relations("m.replace", "m.room.message", room);
const targetEvent = new MatrixEvent({
sender: userId,
type: "m.room.message",
event_id: targetEventId,
room_id: roomId,
origin_server_ts: 1000,
content: { body: "original", msgtype: "m.text" },
});
await relations.setTargetEvent(targetEvent);
const edit = makeEditEvent("$edit1", 2000);
vi.spyOn(edit, "isBeingDecrypted").mockReturnValue(false);
vi.spyOn(edit, "shouldAttemptDecryption").mockReturnValue(false);
await relations.addEvent(edit);
expect(targetEvent.replacingEvent()).toBe(edit);
});
});
it("getSortedAnnotationsByKey should return null for non-annotation relations", async () => {
const userId = "@user:server";
const room = new Room("room123", new TestClient(userId).client, userId);
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EstablishedEcies, QrCodeData, QrCodeMode, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type EstablishedEcies, QrCodeData, QrCodeIntent, Ecies } from "@matrix-org/matrix-sdk-crypto-wasm";
import { MSC4108RendezvousSession, MSC4108SecureChannel, PayloadType } from "../../../../src/rendezvous";
@@ -28,7 +28,7 @@ describe("MSC4108SecureChannel", () => {
});
const channel = new MSC4108SecureChannel(session);
const code = await channel.generateCode(QrCodeMode.Login);
const code = await channel.generateCode(QrCodeIntent.Login);
expect(code).toHaveLength(71);
const text = new TextDecoder().decode(code);
expect(text.startsWith("MATRIX")).toBeTruthy();
@@ -43,7 +43,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
const channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -64,7 +64,7 @@ describe("MSC4108SecureChannel", () => {
vi.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 qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"NOT_REAL_MATRIX_QR_CODE_LOGIN_INITIATE",
@@ -87,7 +87,7 @@ describe("MSC4108SecureChannel", () => {
} as unknown as MSC4108RendezvousSession;
channel = new MSC4108SecureChannel(mockSession);
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeMode.Reciprocate, baseUrl));
const qrCodeData = QrCodeData.fromBytes(await channel.generateCode(QrCodeIntent.Reciprocate, baseUrl));
const { channel: _opponentChannel, initial_message: ciphertext } = new Ecies().establish_outbound_channel(
qrCodeData.publicKey,
"MATRIX_QR_CODE_LOGIN_INITIATE",
+140
View File
@@ -1308,4 +1308,144 @@ describe("RoomState", function () {
).toBeFalsy();
});
});
describe("reactive display name disambiguation", function () {
it("should disambiguate existing member when another member changes to the same name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
// Bob joins with display name "Bob"
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify no disambiguation needed initially
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(false);
expect(bobBefore?.disambiguate).toBe(false);
expect(aliceBefore?.name).toBe("Alice");
expect(bobBefore?.name).toBe("Bob");
// Bob changes display name to "Alice"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobRenameEvent]);
// Now both should be disambiguated
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(true);
expect(bobAfter?.disambiguate).toBe(true);
expect(aliceAfter?.name).toContain(userA);
expect(bobAfter?.name).toContain(userB);
});
it("should un-disambiguate member when conflicting member changes to different name", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Both Alice and Bob join with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent, bobJoinEvent]);
// Verify both are disambiguated
const aliceBefore = testState.getMember(userA);
const bobBefore = testState.getMember(userB);
expect(aliceBefore?.disambiguate).toBe(true);
expect(bobBefore?.disambiguate).toBe(true);
// Bob changes display name to "Bob"
const bobRenameEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Bob",
});
testState.setStateEvents([bobRenameEvent]);
// Alice should no longer be disambiguated, Bob should not be either
const aliceAfter = testState.getMember(userA);
const bobAfter = testState.getMember(userB);
expect(aliceAfter?.disambiguate).toBe(false);
expect(bobAfter?.disambiguate).toBe(false);
expect(aliceAfter?.name).toBe("Alice");
expect(bobAfter?.name).toBe("Bob");
});
it("should emit RoomState.members for affected members when disambiguation changes", function () {
// Create a fresh state
const testState = new RoomState(roomId);
// Alice joins with display name "Alice"
const aliceJoinEvent = utils.mkMembership({
user: userA,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([aliceJoinEvent]);
// Set up listener for Members event
const membersEmitted: string[] = [];
testState.on(RoomStateEvent.Members, (_ev, _state, member) => {
membersEmitted.push(member.userId);
});
// Bob joins with display name "Alice" - should trigger disambiguation for Alice
const bobJoinEvent = utils.mkMembership({
user: userB,
mship: KnownMembership.Join,
room: roomId,
event: true,
name: "Alice",
});
testState.setStateEvents([bobJoinEvent]);
// Both Alice and Bob should have emitted Members events
expect(membersEmitted).toContain(userA);
expect(membersEmitted).toContain(userB);
});
});
});
@@ -4,7 +4,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
@@ -19,7 +19,7 @@ exports[`RustCrypto > importing and exporting room keys > should import and expo
{
"algorithm": "m.megolm.v1.aes-sha2",
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": false,
"org.matrix.msc3061.shared_history": true,
"room_id": "!room:id",
"sender_claimed_keys": {
"ed25519": "QdgHgdpDgihgovpPzUiThXur1fbErTFh7paFvNKSgN0",
+79 -1
View File
@@ -21,6 +21,7 @@ import {
KeysQueryRequest,
Migration,
OlmMachine,
type OtherUserIdentity,
type PickledInboundGroupSession,
type PickledSession,
StoreHandle,
@@ -109,6 +110,7 @@ describe("initRustCrypto", () => {
getBackupKeys: vi.fn(),
getIdentity: vi.fn().mockResolvedValue(null),
trackedUsers: vi.fn(),
getAllRoomsPendingKeyBundles: vi.fn().mockResolvedValue([]),
} as unknown as Mocked<OlmMachine>;
}
@@ -590,6 +592,72 @@ describe("RustCrypto", () => {
expect(res.length).toEqual(0);
});
it.each(["m.room_key_bundle", "io.element.msc4268.room_key_bundle"])(
"should accept key bundles when we find out about them",
async (type: string) => {
// Given we are faking that the received to-device message is a
// decrypted room key bundle.
// @ts-ignore Overriding a private function
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent(type)]);
// And that there is a pending key bundle
// @ts-ignore Overriding a private function
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
inviteAcceptedAtMillis: Date.now(),
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
});
// When we process to-device messages
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
await rustCrypto.preprocessToDeviceMessages([]);
// Then we accepted the key bundle
expect(rustCrypto.maybeAcceptKeyBundle).toHaveBeenCalledWith("!r:s.co", "@inv:s.co");
},
);
it("should not accept other to-device messages as key bundles when we receive them", async () => {
// Given we are faking that the received to-device message looks
// like a room key bundle, except it has the wrong type.
// @ts-ignore Overriding a private function
rustCrypto.receiveSyncChanges = vi.fn().mockReturnValue([keyBundleEvent("foo.some_other_type")]);
// And that there is a pending key bundle
// @ts-ignore Overriding a private function
rustCrypto.olmMachine.getPendingKeyBundleDetailsForRoom = vi.fn().mockReturnValue({
inviteAcceptedAtMillis: Date.now(),
inviterId: { toString: vi.fn().mockReturnValue("@inv:s.co") },
});
// When we process to-device messages
rustCrypto.maybeAcceptKeyBundle = vi.fn().mockName("maybeAcceptKeyBundle").mockResolvedValue(null);
await rustCrypto.preprocessToDeviceMessages([]);
// Then we do not try to accepted a key bundle
expect(rustCrypto.maybeAcceptKeyBundle).not.toHaveBeenCalledWith();
});
function keyBundleEvent(type: string): RustSdkCryptoJs.ProcessedToDeviceEvent {
return {
rawEvent: JSON.stringify({
content: { room_id: "!r:s.co" },
sender: "",
type,
}),
type: 0,
encryptionInfo: {
sender: "",
senderDevice: null,
senderCurve25519Key: "",
isSenderVerified: vi.fn().mockReturnValue(true),
},
} as any as RustSdkCryptoJs.ProcessedToDeviceEvent;
}
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -788,6 +856,7 @@ describe("RustCrypto", () => {
undefined,
secretStorage,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -835,6 +904,7 @@ describe("RustCrypto", () => {
{} as CryptoCallbacks,
false,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -1524,6 +1594,7 @@ describe("RustCrypto", () => {
it("returns an unverified UserVerificationStatus when there is no UserIdentity", async () => {
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(false);
expect(userVerificationStatus.isVerified()).toBeFalsy();
expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeFalsy();
@@ -1535,9 +1606,10 @@ describe("RustCrypto", () => {
free: vi.fn(),
isVerified: vi.fn().mockReturnValue(true),
wasPreviouslyVerified: vi.fn().mockReturnValue(true),
});
} as unknown as OtherUserIdentity);
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.known).toBe(true);
expect(userVerificationStatus.isVerified()).toBeTruthy();
expect(userVerificationStatus.isTofu()).toBeFalsy();
expect(userVerificationStatus.isCrossSigningVerified()).toBeTruthy();
@@ -2320,6 +2392,7 @@ describe("RustCrypto", () => {
});
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), undefined, undefined, secretStorage);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
// We have a key backup
await waitFor(async () => expect(await rustCrypto.getActiveSessionBackupVersion()).not.toBeNull());
@@ -2388,6 +2461,7 @@ describe("RustCrypto", () => {
queryKeysForUsers: vi.fn().mockReturnValue({}),
getReceivedRoomKeyBundleData: vi.fn(),
receiveRoomKeyBundle: vi.fn(),
clearRoomPendingKeyBundle: vi.fn(),
} as unknown as Mocked<OlmMachine>;
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
@@ -2448,6 +2522,10 @@ describe("RustCrypto", () => {
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
// It should also flag the room as not waiting for a key bundle
expect(mockOlmMachine.clearRoomPendingKeyBundle).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.clearRoomPendingKeyBundle.mock.calls[0][0].toString()).toEqual("!room_id");
});
});
+10 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type OtherUserIdentity, type OutgoingRequest } from "@matrix-org/matrix-sdk-crypto-wasm";
import { type Mocked } from "vitest";
import {
@@ -138,7 +138,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -276,7 +278,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
@@ -394,7 +398,9 @@ describe("VerificationRequest", () => {
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const bobUserIdentity = (await aliceOlmMachine.getIdentity(
new RustSdkCryptoJs.UserId(bobUserId),
)) as OtherUserIdentity;
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [
+35
View File
@@ -79,6 +79,41 @@ describe("IndexedDBStore", () => {
expect(await store.getOutOfBandMembers(roomId)).toHaveLength(2);
});
it("should handle failed queries", async () => {
const store = new IndexedDBStore({
indexedDB: indexedDB,
dbName: "database",
localStorage,
});
await store.startup();
// Simulate a failed query
let txn: IDBRequest;
(store.backend as LocalIndexedDBStoreBackend)["db"]!.transaction = (): IDBTransaction => {
return {
objectStore: (name: string) =>
({
name,
openCursor: (query: unknown) => {
return (txn = {
error: new DOMException("Expected error"),
} as IDBRequest);
},
}) as IDBObjectStore,
} as IDBTransaction;
};
// Call backend directly as otherwise the error is masked.
const promise = store.backend.getClientOptions();
// The function uses a Promise.then(() => trick to delay execution
// so we need to wait before we can call the txn onerror handler.
process.nextTick(() => {
txn!.onerror!(new Event("we-ignore-this"));
});
await expect(() => promise).rejects.toThrow("selectQuery failed for client_options");
});
it("Should load presence events on startup", async () => {
// 1. Create idb database
const indexedDB = new IDBFactory();
+14 -14
View File
@@ -314,7 +314,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -420,7 +420,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -451,7 +451,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -478,7 +478,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {},
[SDPStreamMetadataKey.name]: {},
}),
);
@@ -504,7 +504,7 @@ describe("Call", function () {
call.onSDPStreamMetadataChangedReceived(
makeMockEvent("@test:foo", {
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
remote_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
audio_muted: true,
@@ -849,7 +849,7 @@ describe("Call", function () {
answer: {
sdp: DUMMY_SDP,
},
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
[STREAM_ID]: {
purpose: SDPStreamMetadataPurpose.Usermedia,
},
@@ -959,8 +959,8 @@ describe("Call", function () {
describe("sending sdp_stream_metadata_changed events", () => {
it("should send sdp_stream_metadata_changed when muting audio", async () => {
await call.setMicrophoneMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: true,
@@ -972,8 +972,8 @@ describe("Call", function () {
it("should send sdp_stream_metadata_changed when muting video", async () => {
await call.setLocalVideoMuted(true);
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChangedPrefix, {
[SDPStreamMetadataKey]: {
expect(mockSendVoipEvent).toHaveBeenCalledWith(EventType.CallSDPStreamMetadataChanged, {
[SDPStreamMetadataKey.name]: {
mock_stream_from_media_handler: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: false,
@@ -1001,7 +1001,7 @@ describe("Call", function () {
);
call.onSDPStreamMetadataChangedReceived({
getContent: () => ({
[SDPStreamMetadataKey]: metadata,
[SDPStreamMetadataKey.name]: metadata,
}),
} as MatrixEvent);
return metadata;
@@ -1293,9 +1293,9 @@ describe("Call", function () {
FAKE_ROOM_ID,
EventType.CallNegotiate,
expect.objectContaining({
"version": "1",
"call_id": call.callId,
"org.matrix.msc3077.sdp_stream_metadata": expect.objectContaining({
version: "1",
call_id: call.callId,
sdp_stream_metadata: expect.objectContaining({
[SCREENSHARE_STREAM_ID]: expect.objectContaining({
purpose: SDPStreamMetadataPurpose.Screenshare,
}),
+2 -2
View File
@@ -963,7 +963,7 @@ describe("Group Call", function () {
const getMetadataEvent = (audio: boolean, video: boolean): MatrixEvent =>
({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
stream: {
purpose: SDPStreamMetadataPurpose.Usermedia,
audio_muted: audio,
@@ -1330,7 +1330,7 @@ describe("Group Call", function () {
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
call.onNegotiateReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
[SDPStreamMetadataKey.name]: {
screensharing_stream: {
purpose: SDPStreamMetadataPurpose.Screenshare,
},
+47 -6
View File
@@ -1,5 +1,5 @@
/*
Copyright 2020 The Matrix.org Foundation C.I.C.
Copyright 2020-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,6 +14,8 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type EitherAnd } from "matrix-events-sdk";
import { NamespacedValue, UnstableValue } from "../NamespacedValue.ts";
import {
type PolicyRuleEventContent,
@@ -50,7 +52,6 @@ import {
type MCallReplacesEvent,
type MCallSelectAnswer,
type SDPStreamMetadata,
type SDPStreamMetadataKey,
} from "../webrtc/callEventTypes.ts";
import {
type IRTCNotificationContent,
@@ -59,7 +60,7 @@ import {
type ICallNotifyContent,
} from "../matrixrtc/types.ts";
import { type M_POLL_END, type M_POLL_START, type PollEndEventContent, type PollStartEventContent } from "./polls.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/CallMembership.ts";
import { type RtcMembershipData, type SessionMembershipData } from "../matrixrtc/membershipData/index.ts";
import { type LocalNotificationSettings } from "./local_notifications.ts";
import { type IPushRules } from "./PushRules.ts";
import { type SecretInfo, type SecretStorageKeyDescription } from "../secret-storage.ts";
@@ -133,11 +134,13 @@ export enum EventType {
FullyRead = "m.fully_read",
Tag = "m.tag",
SpaceOrder = "org.matrix.msc3230.space_order", // MSC3230
MarkedUnread = "m.marked_unread",
// User account_data events
PushRules = "m.push_rules",
Direct = "m.direct",
IgnoredUserList = "m.ignored_user_list",
InvitePermissionConfig = "m.invite_permission_config", // MSC4380
// to_device events
RoomKey = "m.room_key",
@@ -334,7 +337,16 @@ export interface TimelineEvents {
[EventType.CallCandidates]: MCallCandidates;
[EventType.CallHangup]: MCallHangupReject;
[EventType.CallReject]: MCallHangupReject;
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase & { [SDPStreamMetadataKey]: SDPStreamMetadata };
[EventType.CallSDPStreamMetadataChangedPrefix]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallSDPStreamMetadataChanged]: MCallBase &
EitherAnd<
{ sdp_stream_metadata: SDPStreamMetadata },
{ "org.matrix.msc3077.sdp_stream_metadata": SDPStreamMetadata }
>;
[EventType.CallEncryptionKeysPrefix]: EncryptionKeysEventContent;
[EventType.CallNotify]: ICallNotifyContent;
[EventType.RTCNotification]: IRTCNotificationContent;
@@ -386,6 +398,16 @@ export interface StateEvents {
[M_BEACON_INFO.name]: MBeaconInfoEventContent;
}
/**
* Mapped type from event type to content type for all specified room-specific account_data events.
*/
export interface RoomAccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.FullyRead]: { event_id: string };
[EventType.Tag]: { tags: { [name: string]: { order?: number } } };
[EventType.SpaceOrder]: { order: string };
[EventType.MarkedUnread]: { unread: boolean };
}
/**
* Mapped type from event type to content type for all specified global account_data events.
*/
@@ -394,9 +416,12 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[EventType.Direct]: { [userId: string]: string[] };
[EventType.IgnoredUserList]: { ignored_users: { [userId: string]: EmptyObject } };
"m.secret_storage.default_key": { key: string };
// Flag set by the rust SDK (Element X) and also used by us to mark that the user opted out of backup
// (I don't know why it's m.org.matrix...)
// MSC4287: Sharing key backup preference between clients - used to mark that the user opted out of key storage
"m.key_backup": { enabled: boolean };
// MSC4287 unstable prefix (note the boolean property has the opposite sense)
"m.org.matrix.custom.backup_disabled": { disabled: boolean };
"m.identity_server": { base_url: string | null };
[key: `${typeof LOCAL_NOTIFICATION_SETTINGS_PREFIX.name}.${string}`]: LocalNotificationSettings;
[key: `m.secret_storage.key.${string}`]: SecretStorageKeyDescription;
@@ -404,8 +429,24 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
// Invites-ignorer events
[POLICIES_ACCOUNT_EVENT_TYPE.name]: { [key: string]: any };
[POLICIES_ACCOUNT_EVENT_TYPE.altName]: { [key: string]: any };
[EventType.InvitePermissionConfig]: { default_action?: string };
// List of recently used reaction emojis
// https://spec.matrix.org/v1.18/client-server-api/#mrecent_emoji
"m.recent_emoji": {
recent_emoji: Array<{
emoji: string;
total: number;
}>;
};
}
/**
* Subset of AccountDataEvents, excluding events specified in https://spec.matrix.org/v1.17/client-server-api/#server-behaviour-12
*/
export type WritableAccountDataEvents = Exclude<AccountDataEvents, "m.fully_read" | "m.push_rules">;
/**
* Mapped type from event type to content type for all specified global events encrypted by secret storage.
*
+3 -1
View File
@@ -58,7 +58,9 @@ export interface InviteOpts {
/**
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
* visibility was `shared`, via the experimental
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). If the room's current
* history visibility setting is neither `shared` nor `world_readable`, history sharing will be disabled to prevent
* exposing keys for messages sent prior to the visibility restriction.
*
* @experimental
*/
+1 -1
View File
@@ -42,7 +42,7 @@ import { type IMessageRendering } from "./extensible_events.ts";
/**
* The event type for an m.topic event (in content)
*/
export const M_TOPIC = new NamespacedValue("m.topic");
export const M_TOPIC = new NamespacedValue("m.topic", null);
/**
* The event content for an m.topic event (in content)
+6 -6
View File
@@ -22,11 +22,11 @@ export class NamespacedValue<S extends string, U extends string> {
// Stable is optional, but one of the two parameters is required, hence the weird-looking types.
// Goal is to to have developers explicitly say there is no stable value (if applicable).
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(stable: S, unstable: U | null);
public constructor(stable: null, unstable: U);
public constructor(
public readonly stable?: S | null,
public readonly unstable?: U,
public readonly stable: S | null,
public readonly unstable: U | null,
) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
@@ -60,8 +60,8 @@ export class NamespacedValue<S extends string, U extends string> {
// this desperately wants https://github.com/microsoft/TypeScript/pull/26349 at the top level of the class
// so we can instantiate `NamespacedValue<string, _, _>` as a default type for that namespace.
public findIn<T>(obj: any): T | undefined {
let val: T | undefined = undefined;
public findIn<V>(obj: Partial<Record<NonNullable<S | U>, V>>): V | undefined {
let val: V | undefined = undefined;
if (this.name) {
val = obj?.[this.name];
}
+28 -14
View File
@@ -101,7 +101,7 @@ import {
type RoomNameState,
} from "./models/room.ts";
import { RoomMemberEvent, type RoomMemberEventHandlerMap } from "./models/room-member.ts";
import { type IPowerLevelsContent, type RoomStateEvent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import { RoomStateEvent, type IPowerLevelsContent, type RoomStateEventHandlerMap } from "./models/room-state.ts";
import {
isSendDelayedEventRequestOpts,
UpdateDelayedEventAction,
@@ -138,6 +138,7 @@ import {
MsgType,
PUSHER_ENABLED,
RelationType,
type RoomAccountDataEvents,
RoomCreateTypeField,
RoomType,
type StateEvents,
@@ -145,6 +146,7 @@ import {
UNSTABLE_MSC3088_ENABLED,
UNSTABLE_MSC3088_PURPOSE,
UNSTABLE_MSC3089_TREE_SUBTYPE,
type WritableAccountDataEvents,
} from "./@types/event.ts";
import {
GuestAccess,
@@ -2025,6 +2027,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// attach the event listeners needed by RustCrypto
this.on(RoomMemberEvent.Membership, rustCrypto.onRoomMembership.bind(rustCrypto));
this.on(RoomStateEvent.Events, rustCrypto.onRoomStateEvent.bind(rustCrypto));
this.on(ClientEvent.Event, (event) => {
rustCrypto.onLiveEventFromSync(event);
});
@@ -2221,7 +2224,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public async setAccountData<K extends keyof AccountDataEvents>(
public async setAccountData<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2273,7 +2276,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param eventType - The event type
* @param content - the contents object for the event
*/
public setAccountDataRaw<K extends keyof AccountDataEvents>(
public setAccountDataRaw<K extends keyof WritableAccountDataEvents>(
eventType: K,
content: AccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
@@ -2328,7 +2331,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
}
public async deleteAccountData(eventType: keyof AccountDataEvents): Promise<void> {
public async deleteAccountData(eventType: keyof WritableAccountDataEvents): Promise<void> {
const msc3391DeleteAccountDataServerSupport = this.canSupport.get(Feature.AccountDataDeletion);
// if deletion is not supported overwrite with empty content
if (msc3391DeleteAccountDataServerSupport === ServerSupport.Unsupported) {
@@ -2426,12 +2429,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const roomId = res.room_id;
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
// Flag upfront that we are waiting for a key bundle, so that if we crash mid-import, we can try again.
await this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
// Try to accept the room key bundle specified in a `m.room_key_bundle` to-device message we (might have) already received.
const bundleDownloaded = await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
// If this fails, i.e. we haven't received this message yet, we need to wait until the to-device message arrives.
if (!bundleDownloaded) {
this.cryptoBackend.markRoomAsPendingKeyBundle(roomId, inviter);
}
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
}
// In case we were originally given an alias, check the room cache again
@@ -2584,12 +2585,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
/**
* @param roomId - the ID of the room this event should be stored within
* @param eventType - event type to be set
* @param content - event content
* @returns Promise which resolves: to an empty object `{}`
* @returns Rejects: with an error response.
*/
public setRoomAccountData(roomId: string, eventType: string, content: Record<string, any>): Promise<EmptyObject> {
public setRoomAccountData<K extends keyof RoomAccountDataEvents>(
roomId: string,
eventType: K,
content: RoomAccountDataEvents[K] | Record<string, never>,
): Promise<EmptyObject> {
const path = utils.encodeUri("/user/$userId/rooms/$roomId/account_data/$type", {
$userId: this.credentials.userId!,
$roomId: roomId,
@@ -3911,7 +3917,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @returns Rejects: with an error response.
* May return synthesized attributes if the URL lacked OG meta.
*/
public getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
public async getUrlPreview(url: string, ts: number): Promise<IPreviewUrlResponse> {
// bucket the timestamp to the nearest minute to prevent excessive spam to the server
// Surely 60-second accuracy is enough for anyone.
ts = Math.floor(ts / 60000) * 60000;
@@ -3927,16 +3933,18 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.urlPreviewCache[key];
}
const supportsNewEndpoint = await this.isVersionSupported("v1.11");
const resp = this.http.authedRequest<IPreviewUrlResponse>(
Method.Get,
"/preview_url",
supportsNewEndpoint ? "/media/preview_url" : "/preview_url",
{
url,
ts: ts.toString(),
},
undefined,
{
prefix: MediaPrefix.V3,
prefix: supportsNewEndpoint ? ClientPrefix.V1 : MediaPrefix.V3,
priority: "low",
},
);
@@ -4079,7 +4087,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
if (opts.shareEncryptedHistory) {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
const historyVisibility = this.getRoom(roomId)?.getHistoryVisibility() ?? HistoryVisibility.Shared;
// We should only share room history if the *current* visibility allows it.
if ([HistoryVisibility.Invited, HistoryVisibility.Joined].includes(historyVisibility)) {
this.logger.debug("Not sharing message history as the room history visibility is currently unshared");
} else {
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
}
}
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
+13 -1
View File
@@ -80,6 +80,18 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
/**
* Having accepted an invite for the given room from the given user, attempt to
* find information about a room key bundle and, if found, download the
@@ -103,7 +115,7 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite.
* @param inviterId - The user who invited us to the room and is expected to send the room key bundle.
*/
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): void;
markRoomAsPendingKeyBundle(roomId: string, inviterId: string): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
+3 -3
View File
@@ -16,7 +16,7 @@ limitations under the License.
import { type MBeaconEventContent, type MBeaconInfoContent, type MBeaconInfoEventContent } from "./@types/beacon.ts";
import { MsgType } from "./@types/event.ts";
import { M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { type IMessageRendering, M_TEXT, REFERENCE_RELATION } from "./@types/extensible_events.ts";
import { isProvided } from "./extensible_events_v1/utilities.ts";
import {
M_ASSET,
@@ -29,7 +29,7 @@ import {
type MAssetContent,
type LegacyLocationEventContent,
} from "./@types/location.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC } from "./@types/topic.ts";
import { type MRoomTopicEventContent, type MTopicContent, M_TOPIC, type MTopicEvent } from "./@types/topic.ts";
import { type RoomMessageEventContent } from "./@types/events.ts";
/**
@@ -206,7 +206,7 @@ export type TopicState = {
};
export const parseTopicContent = (content: MRoomTopicEventContent): TopicState => {
const mtopicParent = M_TOPIC.findIn<MTopicContent>(content);
const mtopicParent = M_TOPIC.findIn<MTopicContent | IMessageRendering[]>(content as MTopicEvent);
const mtopic = Array.isArray(mtopicParent) ? mtopicParent : mtopicParent?.["m.text"];
// TODO remove support for the old malformed m.topic arrays after a few releases (only allow array in m.text)
// https://github.com/matrix-org/matrix-js-sdk/pull/4984#pullrequestreview-3174251065
+5
View File
@@ -51,6 +51,7 @@ function validateMediaId(mediaId: string): boolean {
* for authenticated media will *not* be checked - it is the caller's responsibility
* to do so before calling this function. Note also that `useAuthentication`
* implies `allowRedirects`. Defaults to false (unauthenticated endpoints).
* @param animated - Whether the desired thumbnail should be animated.
* @returns The complete URL to the content, may be an empty string if the provided mxc is not valid.
*/
export function getHttpUriForMxc(
@@ -62,6 +63,7 @@ export function getHttpUriForMxc(
allowDirectLinks = false,
allowRedirects?: boolean,
useAuthentication?: boolean,
animated?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -107,6 +109,9 @@ export function getHttpUriForMxc(
if (resizeMethod) {
url.searchParams.set("method", resizeMethod);
}
if (animated !== undefined) {
url.searchParams.set("animated", String(animated));
}
if (typeof allowRedirects === "boolean") {
// We add this after, so we don't convert everything to a thumbnail request.
+53 -21
View File
@@ -188,7 +188,9 @@ export interface CryptoApi {
/**
* Check if the given user has published cross-signing keys.
*
* - If the user is tracked, a `/keys/query` request is made to update locally the cross signing keys.
* - If the user is this user, a `/keys/query` request is made to update locally the cross signing keys.
* - If the user is tracked, any current `/keys/query` requests are awaited (with a timeout) and then
* the locally cached information is used.
* - If the user is not tracked locally and downloadUncached is set to true,
* a `/keys/query` request is made to the server to retrieve the cross signing keys.
* - Otherwise, return false
@@ -205,7 +207,10 @@ export interface CryptoApi {
* Get the device information for the given list of users.
*
* For any users whose device lists are cached (due to sharing an encrypted room with the user), the
* cached device data is returned.
* cached device data is returned, unless it is stale.
*
* If there are users with stale cached entries, wait (with some timeout) for any in-progress
* `/keys/query` request to complete.
*
* If there are uncached users, and the `downloadUncached` parameter is set to `true`,
* a `/keys/query` request is made to the server to retrieve these devices.
@@ -563,8 +568,11 @@ export interface CryptoApi {
* if they match, stores the key in the crypto store by calling {@link storeSessionBackupPrivateKey},
* which enables automatic restore of individual keys when an Unable-to-decrypt error is encountered.
*
* If we are unable to fetch the key from secret storage, there is no backup on the server, or the key
* does not match, throws an exception.
* If the backup decryption key from secret storage does not match the
* latest backup on the server, we throw a {@link DecryptionKeyDoesNotMatchError}.
*
* If we are unable to fetch the key from secret storage or there is no backup on the server,
* we throw an exception.
*/
loadSessionBackupPrivateKeyFromSecretStorage(): Promise<void>;
@@ -623,7 +631,6 @@ export interface CryptoApi {
* * Disables 4S, deleting the info for the default key, the default key pointer itself and any
* known 4S data (cross-signing keys and the megolm key backup key).
* * Deletes any dehydrated devices.
* * Sets the "m.org.matrix.custom.backup_disabled" account data flag to indicate that the user has disabled backups.
*/
disableKeyStorage(): Promise<void>;
@@ -717,20 +724,6 @@ export interface CryptoApi {
* @param secrets - The secrets bundle received from the other device
*/
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//
// Room key history sharing (MSC4268)
//
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
/**
* Share any shareable E2EE history in the given room with the given recipient,
* as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268)
*
* @experimental
*/
shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void>;
}
/** A reason code for a failure to decrypt an event. */
@@ -805,6 +798,9 @@ export enum DeviceIsolationModeKind {
*
* Events from all senders are always decrypted (and should be decorated with message shields in case
* of authenticity warnings, see {@link EventEncryptionInfo}).
*
* `AllDevicesIsolationMode` is used in the legacy, non-'exclude insecure devices' mode in Element Web. It is not
* recommended (see {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}).
*/
export class AllDevicesIsolationMode {
public readonly kind = DeviceIsolationModeKind.AllDevicesIsolationMode;
@@ -831,6 +827,9 @@ export class AllDevicesIsolationMode {
*
* Events are decrypted only if they come from a cross-signed device. Other events will result in a decryption
* failure. (To access the failure reason, see {@link MatrixEvent.decryptionFailureReason}.)
*
* `OnlySignedDevicesIsolationMode` corresponds to the 'Exclude insecure devices' mode in Element Web, which is
* recommended by {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4153 | MSC4153}.
*/
export class OnlySignedDevicesIsolationMode {
public readonly kind = DeviceIsolationModeKind.OnlySignedDevicesIsolationMode;
@@ -862,6 +861,25 @@ export interface BootstrapCrossSigningOpts {
* Represents the ways in which we trust a user
*/
export class UserVerificationStatus {
/**
* Indicates if we have saved a known identity for this user. Typically, this means that we share a
* room with them (or have done in the past).
*
* If this is `false`, then the other flags ({@link isCrossSigningVerified}, {@link wasCrossSigningVerified},
* {@link needsUserApproval}) will also be `false`. This means that we haven't seen this user before.
*
* If this is `true`, then there are further possibilities:
*
* - If {@link isCrossSigningVerified} returns `true`, then we have cryptographically verified the current
* identity of this user: that is the highest form of trust we have.
*
* - If {@link needsUserApproval} is `true`, that means that the user has changed their identity.
*
* - Otherwise, the user is "TOFU trusted": we have a record of their identity, and, typically, will share
* encrypted content with them as long as they retain that identity.
*/
public readonly known: boolean;
/**
* Indicates if the identity has changed in a way that needs user approval.
*
@@ -877,12 +895,14 @@ export class UserVerificationStatus {
*/
public readonly needsUserApproval: boolean;
/** @internal */
public constructor(
private readonly crossSigningVerified: boolean,
private readonly crossSigningVerifiedBefore: boolean,
private readonly tofu: boolean,
known: boolean,
needsUserApproval: boolean = false,
) {
this.known = known;
this.needsUserApproval = needsUserApproval;
}
@@ -914,7 +934,7 @@ export class UserVerificationStatus {
* @deprecated No longer supported, with the Rust crypto stack.
*/
public isTofu(): boolean {
return this.tofu;
return false;
}
}
@@ -1339,6 +1359,18 @@ export interface OlmEncryptionInfo {
senderVerified: boolean;
}
/**
* An error thrown by loadSessionBackupPrivateKeyFromSecretStorage indicating
* that the decryption key found in secret storage does not match the public key
* of the latest backup.
*/
export class DecryptionKeyDoesNotMatchError extends Error {
public constructor(message: string) {
super(message);
this.name = "DecryptionKeyDoesNotMatchError";
}
}
export * from "./verification.ts";
export type * from "./keybackup.ts";
export * from "./recovery-key.ts";
+1 -1
View File
@@ -806,7 +806,7 @@ export class RoomWidgetClient extends MatrixClient {
// Sliding Sync
await this.syncApi!.injectRoomEvents(this.room!, [event]);
}
logger.info(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
logger.debug(`Updated state entry ${event.getType()} ${event.getStateKey()} to ${event.getId()}`);
} else {
const { event_id: eventId, room_id: roomId } = ev.detail.data;
logger.info(`Received state entry ${eventId} for a different room ${roomId}; discarding`);
+5
View File
@@ -67,6 +67,11 @@ export interface IHttpOpts {
* Optional, only called when a refreshToken is present
*/
tokenRefreshFunction?: TokenRefreshFunction;
/**
* Whether to use the HTTP Authorization header over the `access_token` query parameter
* @deprecated as of v1.11 in https://spec.matrix.org/v1.17/client-server-api/#using-access-tokens
*/
useAuthorizationHeader?: boolean; // defaults to true
/** For historical reasons, must be set to `true`. Will eventually be removed. */
+5 -5
View File
@@ -52,35 +52,35 @@ export interface BaseLogger {
*
* @param msg - Data to log.
*/
trace(...msg: any[]): void;
trace(this: void, ...msg: any[]): void;
/**
* Output debug message to the logger.
*
* @param msg - Data to log.
*/
debug(...msg: any[]): void;
debug(this: void, ...msg: any[]): void;
/**
* Output info message to the logger.
*
* @param msg - Data to log.
*/
info(...msg: any[]): void;
info(this: void, ...msg: any[]): void;
/**
* Output warn message to the logger.
*
* @param msg - Data to log.
*/
warn(...msg: any[]): void;
warn(this: void, ...msg: any[]): void;
/**
* Output error message to the logger.
*
* @param msg - Data to log.
*/
error(...msg: any[]): void;
error(this: void, ...msg: any[]): void;
}
// This is to demonstrate, that you can use any namespace you want.
+1
View File
@@ -82,6 +82,7 @@ export * from "./models/room-summary.ts";
export * from "./models/event-status.ts";
export * from "./models/profile-keys.ts";
export * from "./models/related-relations.ts";
export { type StickyMatrixEvent, RoomStickyEventsEvent } from "./models/room-sticky-events.ts";
export type { RoomSummary } from "./client.ts";
export * as ContentHelpers from "./content-helpers.ts";
export * as SecretStorage from "./secret-storage.ts";
+203 -392
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -14,16 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MXID_PATTERN } from "../models/room-member.ts";
import { deepCompare } from "../utils.ts";
import { type LivekitFocusSelection } from "./LivekitTransport.ts";
import { slotDescriptionToId, slotIdToDescription, type SlotDescription } from "./MatrixRTCSession.ts";
import type { RTCCallIntent, Transport } from "./types.ts";
import { type MatrixEvent, type IContent } from "../models/event.ts";
import { type RelationType } from "../@types/event.ts";
import { sha256 } from "../digest.ts";
import { encodeUnpaddedBase64 } from "../base64.ts";
import { type Logger } from "../logger.ts";
import { type RTCCallIntent, type Transport, type SlotDescription } from "./types.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Logger, logger } from "../logger.ts";
import { computeSlotId, slotIdToDescription } from "./utils.ts";
import {
checkRtcMembershipData,
computeRtcIdentityRaw,
type RtcMembershipData,
checkSessionsMembershipData,
type SessionMembershipData,
MatrixRTCMembershipParseError,
} from "./membershipData/index.ts";
import { EventType } from "../@types/event.ts";
/**
* The default duration in milliseconds that a membership is considered valid for.
@@ -32,331 +36,118 @@ import { type Logger } from "../logger.ts";
*/
export const DEFAULT_EXPIRE_DURATION = 1000 * 60 * 60 * 4;
type CallScope = "m.room" | "m.user";
type Member = {
user_id: string;
device_id: string;
/**
* Describes the source event type that provided the membership data.
*/
enum MembershipKind {
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
* The modern MSC4143 format event.
*/
id: string;
};
export interface RtcMembershipData {
"slot_id": string;
"member": Member;
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": {
type: string;
// other application specific keys
[key: string]: unknown;
};
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
RTC = "rtc",
/**
* The legacy call event type.
*/
Session = "session",
}
const checkRtcMembershipData = (
data: IContent,
errors: string[],
referenceUserId: string,
): data is RtcMembershipData => {
const prefix = " - ";
type MembershipData =
| { kind: MembershipKind.RTC; data: RtcMembershipData }
| { kind: MembershipKind.Session; data: SessionMembershipData };
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else {
if (data.slot_id.split("#").length !== 2) errors.push(prefix + 'slot_id must include exactly one "#"');
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") errors.push(prefix + "member.user_id must be string");
else if (!MXID_PATTERN.test(data.member.user_id)) errors.push(prefix + "member.user_id must be a valid mxid");
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== referenceUserId) errors.push(prefix + "member.user_id must match the sender");
if (typeof data.member.device_id !== "string") errors.push(prefix + "member.device_id must be string");
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
return errors.length === 0;
};
/**
* MSC4143 (MatrixRTC) session membership data.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
*/
"focus_active": LivekitFocusSelection;
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: CallScope;
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The sticky key in case of a sticky event. This string encodes the application + device_id indicating the used slot + device.
*/
"msc4354_sticky_key"?: string;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
const checkSessionsMembershipData = (data: IContent, errors: string[]): data is SessionMembershipData => {
const prefix = " - ";
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 (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && 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 !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
return errors.length === 0;
};
type MembershipData = { kind: "rtc"; data: RtcMembershipData } | { kind: "session"; data: SessionMembershipData };
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership from this file.
type LimitedEvent = Pick<MatrixEvent, "getId" | "getSender" | "getTs" | "getType" | "getContent">;
// TODO: Rename to RtcMembership once we removed the legacy SessionMembership is removed, to avoid confusion.
export class CallMembership {
/**
* Parse the membershipdata from a call membership event.
* @param matrixEvent The Matrix event to read.
* @returns MembershipData in either MembershipKind.RTC or MembershipKind.Session format.
* @throws If the content is neither format.
*/
public static membershipDataFromMatrixEvent(matrixEvent: LimitedEvent): MembershipData {
const sender = matrixEvent.getSender();
const evType = matrixEvent.getType();
const data = matrixEvent.getContent();
if (sender === undefined) throw new Error("matrixEvent is missing sender field");
try {
// Event types are strictly checked here.
if (evType === EventType.RTCMembership && checkRtcMembershipData(data, sender)) {
return { kind: MembershipKind.RTC, data };
} else if (evType === EventType.GroupCallMemberPrefix && checkSessionsMembershipData(data)) {
return { kind: MembershipKind.Session, data };
} else {
throw Error(`'${evType} is not a known call membership type`);
}
} catch (ex) {
if (ex instanceof MatrixRTCMembershipParseError) {
logger.debug("CallMembership.MatrixRTCMembershipParseError provided invalid data", data);
}
throw ex;
}
}
/**
* Parse the contents of a MatrixEvent and create a CallMembership instance.
* @param matrixEvent The Matrix event to read.
*/
public static async parseFromEvent(matrixEvent: LimitedEvent): Promise<CallMembership> {
const membershipData: MembershipData = this.membershipDataFromMatrixEvent(matrixEvent);
const rtcBackendIdentity =
membershipData.kind === MembershipKind.RTC
? await computeRtcIdentityRaw(
membershipData.data.member.user_id,
membershipData.data.member.device_id,
membershipData.data.member.id,
)
: `${matrixEvent.getSender()}:${membershipData.data.device_id}`;
return new CallMembership(matrixEvent, membershipData, rtcBackendIdentity);
}
public static equal(a?: CallMembership, b?: CallMembership): boolean {
return deepCompare(a?.membershipData, b?.membershipData);
}
private logger?: Logger;
private logger: Logger;
/** The parsed data from the Matrix event.
* To access checked eventId and sender from the matrixEvent.
* Class construction will fail if these values cannot get obtained. */
private readonly matrixEventData: { eventId: string; sender: string; ts: number };
public constructor(
/** The required parts of the Matrix event that this membership is based on */
matrixEvent: Pick<MatrixEvent, "getId" | "getSender" | "getTs">,
/**
* The type checked membership data {data: (content of the matrix event), kind: (type hint)}
*
*/
private readonly membershipData: MembershipData,
/**
*
* Anonymized identity to use with the RTC backend.
*
* The rtcBackendIdentity is a hashed version of all the identity parts:
* `sha256(${this.userId}|${this.deviceId}|${this.memberId})`
*
* It is used to anonymize the identity of the user in the RTC backend.
*/
public readonly rtcBackendIdentity: string,
/**
* The constructor will automatically create a properly tagged child logger instance.
*/
logger?: Logger,
) {
const [eventId, sender, ts] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getTs()];
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
this.matrixEventData = { eventId, sender, ts };
this.logger = logger?.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
}
private readonly matrixEventData: { eventId: string; sender: string };
/**
* sha256(`${this.userId}|${this.deviceId}|${this.memberId}`) for sticky events (kind = rtc)
* `${this.userId}:${this.deviceId}` for state events (kind = session)
* Use `parseFromEvent`.
* Constructor should only be used by tests.
* @private
* @param matrixEvent
* @param membershipData
* @param rtcBackendIdentity
*/
public static async computeRtcBackendIdentity(
matrixEvent: Pick<MatrixEvent, "getSender">,
membershipData: MembershipData,
): Promise<string> {
const { kind, data } = membershipData;
switch (kind) {
case "rtc": {
return CallMembership.computeRtcIdentityRaw(data.member.user_id, data.member.device_id, data.member.id);
}
case "session":
return `${matrixEvent.getSender()}:${data.device_id}`;
}
}
public static async computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
return encodeUnpaddedBase64(await sha256(`${userId}|${deviceId}|${memberId}`));
}
public static membershipDataFromMatrixEvent(matrixEvent: MatrixEvent): MembershipData {
const [eventId, sender, content] = [matrixEvent.getId(), matrixEvent.getSender(), matrixEvent.getContent()];
public constructor(
/** The Matrix event that this membership is based on */
private readonly matrixEvent: LimitedEvent,
private readonly membershipData: MembershipData,
public readonly rtcBackendIdentity: string,
) {
const eventId = matrixEvent.getId();
const sender = matrixEvent.getSender();
if (eventId === undefined) throw new Error("parentEvent is missing eventId field");
if (sender === undefined) throw new Error("parentEvent is missing sender field");
const sessionErrors: string[] = [];
const rtcErrors: string[] = [];
if (checkSessionsMembershipData(content, sessionErrors)) {
return { kind: "session", data: content };
} else if (checkRtcMembershipData(content, rtcErrors, sender)) {
return { kind: "rtc", data: content };
} else {
const details =
sessionErrors.length < rtcErrors.length
? `Does not match MSC4143 m.call.member:\n${sessionErrors.join("\n")}\n\n`
: `Does not match MSC4143 m.rtc.member:\n${rtcErrors.join("\n")}\n\n`;
const json = "\nevent:\n" + JSON.stringify(content).replaceAll('"', "'");
throw Error(`unknown CallMembership data.\n` + details + json);
}
this.logger = logger.getChild(`[CallMembership ${sender}:${this.deviceId}]`);
this.matrixEventData = { eventId, sender };
}
/** @deprecated use userId instead */
public get sender(): string {
return this.userId;
}
public get userId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.user_id;
case "session":
case MembershipKind.Session:
default:
return this.matrixEventData.sender;
}
@@ -372,104 +163,122 @@ export class CallMembership {
*/
public get slotId(): string {
const { kind, data } = this.membershipData;
if (data.application === "m.call") {
switch (kind) {
case MembershipKind.RTC:
return data.slot_id;
case MembershipKind.Session:
default: {
const [application, id] = [data.application, data.call_id];
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
// State events still are sent with `""` however. To find other events that should end up in the same call,
// we use the slotId.
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
// "ROOM" slotId/call_id.
// This makes all the remote members work with just this simple trick.
//
// We of course now need to be careful when sending legacy events (state events)
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
// is commited.
//
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
let compatibilityAdaptedId: string;
if (id === "") {
compatibilityAdaptedId = "ROOM";
this.logger?.info("use slotId compat hack emptyString -> ROOM");
} else {
compatibilityAdaptedId = id;
}
return computeSlotId({
application,
id: compatibilityAdaptedId,
});
}
}
}
this.logger?.info("NOT using slotId compat hack emptyString -> ROOM");
// This is what the function should look like for any other application that did not
// go through a `""`=> `"ROOM"` rename
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.slot_id;
case "session":
case MembershipKind.Session:
default:
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// The spec got changed to use `"ROOM"` instead of `""` empyt string for the implicit default call.
// State events still are sent with `""` however. To find other events that should end up in the same call,
// we use the slotId.
// Since the CallMembership is the public representation of a rtc.member event, we just pretend it is a
// "ROOM" slotId/call_id.
// This makes all the remote members work with just this simple trick.
//
// We of course now need to be careful when sending legacy events (state events)
// They get a slotDescription containing "ROOM" since this is what we use starting at the time this comment
// is commited.
//
// See the Other INFO_SLOT_ID_LEGACY_CASE comments to see where we revert back to "" just before sending the event.
return slotDescriptionToId({
application: this.application,
id: data.call_id === "" ? "ROOM" : data.call_id,
});
return computeSlotId({ application: data.application, id: data.call_id });
}
}
public get deviceId(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.member.device_id;
case "session":
case MembershipKind.Session:
default:
return data.device_id;
}
}
public get callIntent(): RTCCallIntent | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc": {
const intent = data.application["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger?.warn("RTC membership has invalid m.call.intent");
return undefined;
}
case "session":
default:
return data["m.call.intent"];
const intent = this.applicationData["m.call.intent"];
if (typeof intent === "string") {
return intent;
}
this.logger.warn("RTC membership has invalid m.call.intent");
return undefined;
}
/**
* Parsed `slot_id` (format `{application}#{id}`) into its components (application and id).
*/
public get slotDescription(): SlotDescription {
const { kind, data } = this.membershipData;
if (kind === MembershipKind.RTC) {
const id = data.slot_id.slice(`${data.application.type}#`.length);
return { application: data.application.type, id };
}
return slotIdToDescription(this.slotId);
}
/**
* The application `type`.
* @deprecated Use @see applicationData
*/
public get application(): string {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
return data.application.type;
case "session":
default:
return data.application;
}
return this.applicationData.type;
}
/**
* Information about the application being used for the RTC session.
* May contain extra keys specific to the application.
*/
public get applicationData(): { type: string; [key: string]: unknown } {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.application;
case "session":
case MembershipKind.Session:
default:
// SessionData does not have application data as such. We return specific
// properties in use by other getters in this class, for compatibility.
return { "type": data.application, "m.call.intent": data["m.call.intent"] };
}
}
/** @deprecated scope is not used and will be removed in future versions. replaced by application specific types.*/
public get scope(): CallScope | undefined {
public get scope(): SessionMembershipData["scope"] | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
return data.scope;
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
/**
* This computes the membership ID for the membership.
@@ -494,25 +303,33 @@ export class CallMembership {
case "rtc":
return data.member.id;
case "session":
default:
return (
// best case we have a client already publishing the right custom membershipId
data.membershipID ??
// alternativly we use the hard coded jwt id defuatl value (used until version 0.16.0)
`${this.matrixEventData.sender}:${data.device_id}`
);
default:
throw Error("Not possible to get memberID without knowing the membership event kind");
}
}
/**
* @deprecated renamed to `memberId`
*/
public get membershipID(): string {
return this.memberId;
}
public createdTs(): number {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
// TODO we need to read the referenced (relation) event if available to get the real created_ts
return this.matrixEventData.ts;
case "session":
return this.matrixEvent.getTs();
case MembershipKind.Session:
default:
return data.created_ts ?? this.matrixEventData.ts;
return data.created_ts ?? this.matrixEvent.getTs();
}
}
@@ -523,9 +340,9 @@ export class CallMembership {
public getAbsoluteExpiry(): number | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return undefined;
case "session":
case MembershipKind.Session:
default:
// TODO: calculate this from the MatrixRTCSession join configuration directly
return this.createdTs() + (data.expires ?? DEFAULT_EXPIRE_DURATION);
@@ -534,19 +351,20 @@ export class CallMembership {
/**
* @returns The number of milliseconds until the membership expires or undefined if applicable
* @deprecated Not used by RTC events.
*/
public getMsUntilExpiry(): number | undefined {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
return undefined;
case "session":
default:
if (kind === MembershipKind.Session) {
const absExpiry = this.getAbsoluteExpiry();
if (absExpiry) {
// Assume that local clock is sufficiently in sync with other clocks in the distributed system.
// We used to try and adjust for the local clock being skewed, but there are cases where this is not accurate.
// The current implementation allows for the local clock to be -infinity to +MatrixRTCSession.MEMBERSHIP_EXPIRY_TIME/2
return this.getAbsoluteExpiry()! - Date.now();
return absExpiry - Date.now();
}
}
return undefined;
}
/**
@@ -555,9 +373,9 @@ export class CallMembership {
public isExpired(): boolean {
const { kind } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return false;
case "session":
case MembershipKind.Session:
default:
return this.getMsUntilExpiry()! <= 0;
}
@@ -583,30 +401,26 @@ export class CallMembership {
public getTransport(oldestMembership: CallMembership): Transport | undefined {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports[0];
case "session":
case MembershipKind.Session:
switch (data.focus_active.focus_selection) {
case "multi_sfu":
return data.foci_preferred[0];
case "oldest_membership":
if (CallMembership.equal(this, oldestMembership)) return data.foci_preferred[0];
if (oldestMembership !== undefined) return oldestMembership.getTransport(oldestMembership);
break;
case "multi_sfu":
return data.foci_preferred[0];
default:
// `focus_selection` not understood.
return undefined;
}
break;
default:
return undefined;
}
return undefined;
}
/**
* The focus_active filed of the session membership (m.call.member).
* @deprecated focus_active is not used and will be removed in future versions.
*/
public getFocusActive(): LivekitFocusSelection | undefined {
const { kind, data } = this.membershipData;
if (kind === "session") return data.focus_active;
return undefined;
}
/**
* The value of the `rtc_transports` field for RTC memberships (m.rtc.member).
* Or the value of the `foci_preferred` field for legacy session memberships (m.call.member).
@@ -614,14 +428,11 @@ export class CallMembership {
public get transports(): Transport[] {
const { kind, data } = this.membershipData;
switch (kind) {
case "rtc":
case MembershipKind.RTC:
return data.rtc_transports;
case "session":
case MembershipKind.Session:
default:
return data.foci_preferred;
}
}
public get kind(): MembershipData["kind"] {
return this.membershipData.kind;
}
}
+1 -417
View File
@@ -1,11 +1,6 @@
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { secureRandomBase64Url } from "../randomstring.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type EncryptionKeyMapKey, type Statistics } from "./types.ts";
import { type EncryptionKeyMapKey } from "./types.ts";
/**
* The string used for the keys in the the encryption key map.
@@ -57,414 +52,3 @@ export interface IEncryptionManager {
}
export type CallMembershipIdentityParts = Pick<CallMembership, "userId" | "deviceId" | "memberId">;
/**
* This class implements the IEncryptionManager interface,
* and takes care of managing the encryption keys of all rtc members:
* - generate new keys for the local user and send them to other participants
* - track all keys of all other members and update livekit.
*
* @internal
*/
export class EncryptionManager implements IEncryptionManager {
private manageMediaKeys = false;
private keysEventUpdateTimeout?: ReturnType<typeof setTimeout>;
private makeNewKeyTimeout?: ReturnType<typeof setTimeout>;
private setNewKeyTimeouts = new Set<ReturnType<typeof setTimeout>>();
private get updateEncryptionKeyThrottle(): number {
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
}
private get makeKeyDelay(): number {
return this.joinConfig?.makeKeyDelay ?? 3_000;
}
private get useKeyDelay(): number {
return this.joinConfig?.useKeyDelay ?? 5_000;
}
private encryptionKeys = new Map<
string,
Array<{ key: Uint8Array<ArrayBuffer>; timestamp: number; membership: CallMembershipIdentityParts }>
>();
private lastEncryptionKeyUpdateRequest?: number;
// We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys
// if it looks like a membership has been updated.
private lastMembershipFingerprints: Set<string> | undefined;
private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined;
private logger: Logger;
public constructor(
private membership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => void,
parentLogger?: Logger,
) {
this.logger = (parentLogger ?? rootLogger).getChild(`[EncryptionManager]`);
}
private rtcBackendIdentityFromMembershipParts(membership: CallMembershipIdentityParts): string {
// Implement logic to construct rtcBackendIdentity from membership parts
return `${membership.userId}:${membership.deviceId}`;
}
public getEncryptionKeys(): ReadonlyMap<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
> {
const keysMap = new Map<
EncryptionKeyMapKey,
ReadonlyArray<{
key: Uint8Array<ArrayBuffer>;
keyIndex: number;
membership: CallMembershipIdentityParts;
rtcBackendIdentity: string;
}>
>();
for (const [userId, userKeyEntry] of this.encryptionKeys) {
const keys = userKeyEntry.map((entry, index) => ({
key: entry.key,
membership: entry.membership,
keyIndex: index,
rtcBackendIdentity: this.rtcBackendIdentityFromMembershipParts(entry.membership),
}));
keysMap.set(userId as EncryptionKeyMapKey, keys);
}
return keysMap;
}
private joined = false;
public join(joinConfig: EncryptionConfig): void {
this.joinConfig = joinConfig;
this.joined = true;
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.start();
if (this.joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
this.requestSendCurrentKey();
}
}
public leave(): void {
// clear our encryption keys as we're done with them now (we'll
// make new keys if we rejoin). We leave keys for other participants
// as they may still be using the same ones.
this.encryptionKeys.set(getEncryptionKeyMapKey(this.membership), []);
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
if (this.makeNewKeyTimeout !== undefined) {
clearTimeout(this.makeNewKeyTimeout);
this.makeNewKeyTimeout = undefined;
}
for (const t of this.setNewKeyTimeouts) {
clearTimeout(t);
}
this.setNewKeyTimeouts.clear();
this.manageMediaKeys = false;
this.joined = false;
}
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
if (this.manageMediaKeys && this.joined) {
const oldMembershipIds = new Set(
oldMemberships
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map(getEncryptionKeyMapKey),
);
const newMembershipIds = new Set(
this.getMemberships()
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map(getEncryptionKeyMapKey),
);
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
// for this once available
const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x));
const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x));
const oldFingerprints = this.lastMembershipFingerprints;
// always store the fingerprints of these latest memberships
this.storeLastMembershipFingerprints();
if (anyLeft) {
if (this.makeNewKeyTimeout) {
// existing rotation in progress, so let it complete
} else {
this.logger.debug(`Member(s) have left: queueing sender key rotation`);
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay);
}
} else if (anyJoined) {
this.logger.debug(`New member(s) have joined: re-sending keys`);
this.requestSendCurrentKey();
} else if (oldFingerprints) {
// does it look like any of the members have updated their memberships?
const newFingerprints = this.lastMembershipFingerprints!;
// We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference
// for this once available
const candidateUpdates =
Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) ||
Array.from(newFingerprints).some((x) => !oldFingerprints.has(x));
if (candidateUpdates) {
this.logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`);
this.requestSendCurrentKey();
}
}
}
}
/**
* Generate a new sender key and add it at the next available index
* @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.
* @returns The index of the new key
*/
private makeNewSenderKey(delayBeforeUse = false): number {
const encryptionKey = secureRandomBase64Url(16);
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
this.logger.info("Generated new key at index " + encryptionKeyIndex);
this.setEncryptionKey(this.membership, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
return encryptionKeyIndex;
}
/**
* Requests that we resend our current keys to the room. May send a keys event immediately
* or queue for alter if one has already been sent recently.
*/
private requestSendCurrentKey(): void {
if (!this.manageMediaKeys) return;
if (
this.lastEncryptionKeyUpdateRequest &&
this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now()
) {
this.logger.info("Last encryption key event sent too recently: postponing");
if (this.keysEventUpdateTimeout === undefined) {
this.keysEventUpdateTimeout = setTimeout(
() => void this.sendEncryptionKeysEvent(),
this.updateEncryptionKeyThrottle,
);
}
return;
}
void this.sendEncryptionKeysEvent();
}
/**
* Get the known encryption keys for a given participant device.
*
* @param membership - The membership identity parts of the participant
* @returns The encryption keys for the given participant, or undefined if they are not known.
*/
private getKeysForParticipant(membership: CallMembershipIdentityParts): Array<Uint8Array<ArrayBuffer>> | undefined {
return this.encryptionKeys.get(getEncryptionKeyMapKey(membership))?.map((entry) => entry.key);
}
/**
* Re-sends the encryption keys room event
*/
private sendEncryptionKeysEvent = async (indexToSend?: number): Promise<void> => {
if (this.keysEventUpdateTimeout !== undefined) {
clearTimeout(this.keysEventUpdateTimeout);
this.keysEventUpdateTimeout = undefined;
}
this.lastEncryptionKeyUpdateRequest = Date.now();
if (!this.joined) return;
const myKeys = this.getKeysForParticipant(this.membership);
if (!myKeys) {
this.logger.warn("Tried to send encryption keys event but no keys found!");
return;
}
if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) {
this.logger.warn("Tried to send encryption keys event but no current key index found!");
return;
}
const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex;
this.logger.info(
`Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`,
);
const keyToSend = myKeys[keyIndexToSend];
try {
this.statistics.counters.roomEventEncryptionKeysSent += 1;
const targets = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.membership.userId}:${this.membership.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
);
} catch (error) {
if (this.keysEventUpdateTimeout === undefined) {
const resendDelay = safeGetRetryAfterMs(error, 5000);
this.logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
} else {
this.logger.info("Not scheduling key resend as another re-send is already pending");
}
}
};
public onNewKeyReceived: KeyTransportEventListener = (membership, keyBase64Encoded, index, timestamp) => {
this.logger.debug(
`Received key over key transport ${membership.userId}:${membership.deviceId} at index ${index}`,
);
this.setEncryptionKey(membership, index, keyBase64Encoded, timestamp);
};
private storeLastMembershipFingerprints(): void {
this.lastMembershipFingerprints = new Set(
this.getMemberships()
.filter((m) => !isMyMembership(m, this.membership.userId, this.membership.deviceId))
.map((m) => `${getEncryptionKeyMapKey(m)}:${m.createdTs()}`),
);
}
private getNewEncryptionKeyIndex(): number {
if (this.latestGeneratedKeyIndex === -1) {
return 0;
}
// maximum key index is 255
return (this.latestGeneratedKeyIndex + 1) % 256;
}
/**
* Sets an encryption key at a specified index for a participant.
* The encryption keys for the local participant are also stored here under the
* user and device ID of the local participant.
* If the key is older than the existing key at the index, it will be ignored.
* @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 representation of the key to set in base64
* @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device.
* @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.
*/
private setEncryptionKey(
membership: CallMembershipIdentityParts,
encryptionKeyIndex: number,
encryptionKeyString: string,
timestamp: number,
delayBeforeUse = false,
): void {
this.logger.debug(
`Setting encryption key for ${membership.userId}:${membership.deviceId} at index ${encryptionKeyIndex}`,
);
const keyBin = decodeBase64(encryptionKeyString);
const mapKey = getEncryptionKeyMapKey(membership);
if (!this.encryptionKeys.has(mapKey)) {
this.encryptionKeys.set(mapKey, []);
}
const participantKeys = this.encryptionKeys.get(mapKey)!;
const existingKeyAtIndex = participantKeys[encryptionKeyIndex];
if (existingKeyAtIndex) {
if (existingKeyAtIndex.timestamp > timestamp) {
this.logger.info(
`Ignoring new key at index ${encryptionKeyIndex} for ${mapKey} as it is older than existing known key`,
);
return;
}
if (keysEqual(existingKeyAtIndex.key, keyBin)) {
existingKeyAtIndex.timestamp = timestamp;
return;
}
}
if (membership.userId === this.membership.userId && membership.deviceId === this.membership.deviceId) {
// It is important to already update the latestGeneratedKeyIndex here
// NOT IN THE `delayBeforeUse` `setTimeout`.
// Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit).
// It needs to happen here because we will send the key before the timeout has passed and sending
// the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback
// it will use the wrong index (index - 1)!
this.latestGeneratedKeyIndex = encryptionKeyIndex;
}
participantKeys[encryptionKeyIndex] = {
key: keyBin,
timestamp,
membership: membership,
};
if (delayBeforeUse) {
const useKeyTimeout = setTimeout(() => {
this.setNewKeyTimeouts.delete(useKeyTimeout);
this.logger.info(`Delayed-emitting key changed event for ${mapKey} index ${encryptionKeyIndex}`);
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}, this.useKeyDelay);
this.setNewKeyTimeouts.add(useKeyTimeout);
} else {
this.onEncryptionKeysChanged(
keyBin,
encryptionKeyIndex,
membership,
this.rtcBackendIdentityFromMembershipParts(membership),
);
}
}
private onRotateKeyTimeout = (): void => {
if (!this.manageMediaKeys) return;
this.makeNewKeyTimeout = undefined;
this.logger.info("Making new sender key for key rotation");
const newKeyIndex = this.makeNewSenderKey(true);
// send immediately: if we're about to start sending with a new key, it's
// important we get it out to others as soon as we can.
void this.sendEncryptionKeysEvent(newKeyIndex);
};
}
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
}
+84 -123
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 - 2024 The Matrix.org Foundation C.I.C.
Copyright 2023 - 2026 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.
@@ -25,7 +25,7 @@ import { type ISendEventResponse } from "../@types/requests.ts";
import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { MembershipManager, StickyEventMembershipManager } from "./MembershipManager.ts";
import { type CallMembershipIdentityParts, EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { type CallMembershipIdentityParts, type IEncryptionManager } from "./EncryptionManager.ts";
import { logDurationSync } from "../utils.ts";
import type {
Statistics,
@@ -34,6 +34,7 @@ import type {
IRTCNotificationContent,
RTCCallIntent,
Transport,
SlotDescription,
} from "./types.ts";
import {
MembershipManagerEvent,
@@ -45,7 +46,7 @@ import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import { type IContent, type MatrixEvent } from "../models/event.ts";
import { RoomStickyEventsEvent, type RoomStickyEventsMap } from "../models/room-sticky-events.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { computeSlotId } from "./utils.ts";
/**
* Events emitted by MatrixRTCSession
@@ -96,21 +97,6 @@ export interface SessionConfig {
callIntent?: RTCCallIntent;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
id: string;
application: string;
}
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id] = slotId.split("#");
return { application, id };
}
export function slotDescriptionToId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
// The names follow these principles:
// - we use the technical term delay if the option is related to delayed events.
// - we use delayedLeaveEvent if the option is related to the delayed leave event.
@@ -165,11 +151,6 @@ export interface MembershipConfig {
*/
networkErrorRetryMs?: number;
/**
* If true, use the new to-device transport for sending encryption keys.
*/
useExperimentalToDeviceTransport?: boolean;
/**
* The time (in milliseconds) after which a we consider a delayed event restart http request to have failed.
* Setting this to a lower value will result in more frequent retries but also a higher chance of failiour.
@@ -275,6 +256,16 @@ export class MatrixRTCSession extends TypedEventEmitter<
public memberships: CallMembership[] = [];
/**
* Resolves when the session has calculated the initial membership of the session.
*/
public readonly initialMembershipCalculated: Promise<void>;
/**
* Does membership need to be recalculated? This is set to false upon
* recalculation.
*/
private membershipNeedsRecalculation = false;
/**
* The statistics for this session.
*/
@@ -315,7 +306,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* The slotId is the property that, per definition, groups memberships into one call.
*/
public get slotId(): string | undefined {
return slotDescriptionToId(this.slotDescription);
return computeSlotId(this.slotDescription);
}
/**
@@ -326,18 +317,20 @@ export class MatrixRTCSession extends TypedEventEmitter<
*/
public static async sessionMembershipsForSlot(
room: Pick<Room, "getLiveTimeline" | "roomId" | "hasMembershipState" | "_unstable_getStickyEvents">,
slotId: string,
slotDescription: SlotDescription,
// default both true this implied we combine sticky and state events for the final call state
// (prefer sticky events in case of a duplicate)
options: SessionMembershipsForSlotOpts = DEFAULT_SESSION_MEMBERSHIPS_FOR_SLOT_OPTS,
): Promise<CallMembership[]> {
const logger = rootLogger.getChild(`[MatrixRTCSession ${room.roomId}]`);
const logger = rootLogger.getChild(
`[MatrixRTCSession ${room.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
const callMemberEvents = collectMembersEvents(room, options, logger);
const callMemberships = await computeBackendIdentityAndVerifyMemberEvents(
room,
callMemberEvents,
slotId,
slotDescription,
logger,
);
@@ -389,6 +382,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* @param slotDescription The slot description is a virtual address where participants are allowed to meet.
* This session will only manage memberships that match this slot description.Sessions are distinct if any of
* those properties are distinct: `roomSubset.roomId`, `slotDescription.application`, `slotDescription.id`.
* @param calculateMembershipsOpts - Options to configure how memberships are calculated for this session.
*/
public constructor(
private readonly client: Pick<
@@ -412,21 +406,27 @@ export class MatrixRTCSession extends TypedEventEmitter<
>,
private roomSubset: Pick<
Room,
"getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off"
| "getLiveTimeline"
| "roomId"
| "getVersion"
| "hasMembershipState"
| "on"
| "off"
| "_unstable_getStickyEvents"
>,
public readonly slotDescription: SlotDescription,
private readonly calculateMembershipsOpts?: SessionMembershipsForSlotOpts,
) {
super();
this.logger = rootLogger.getChild(`[MatrixRTCSession ${roomSubset.roomId}]`);
this.logger = rootLogger.getChild(
`[MatrixRTCSession ${roomSubset.roomId} ${slotDescription.application}#${slotDescription.id}]`,
);
this.roomSubset.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
this.roomSubset.on(RoomStickyEventsEvent.Update, this.onStickyEventUpdate);
// We can ignore this promise because `recalculateSessionMembers` will emit
// `MatrixRTCSessionEvent.MembershipsChanged` once it has completed.
this.ensureRecalculateSessionMembers();
this.initialMembershipCalculated = this.ensureRecalculateSessionMembers();
this.setExpiryTimer();
}
/*
@@ -500,57 +500,28 @@ export class MatrixRTCSession extends TypedEventEmitter<
MembershipManagerEvent.DelayIdChanged,
]);
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
this.logger.info("Using experimental to-device transport for encryption keys");
this.logger.info("Using to-device with room fallback transport for encryption keys");
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
this.encryptionManager = new RTCEncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
this.logger,
);
} else {
// TODO REMOVE ME!
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
this.statistics,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
);
}
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
const transport = new ToDeviceKeyTransport(ownMembershipIdentity, room.roomId, client, statistics);
this.encryptionManager = new RTCEncryptionManager(
ownMembershipIdentity,
() => this.memberships,
transport,
(
keyBin: Uint8Array<ArrayBuffer>,
encryptionKeyIndex: number,
membership: CallMembershipIdentityParts,
rtcBackendIdentity: string,
) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
membership,
rtcBackendIdentity,
);
},
this.logger,
);
}
this.joinConfig = joinConfig;
@@ -620,13 +591,6 @@ export class MatrixRTCSession extends TypedEventEmitter<
return oldestMembership?.getTransport(oldestMembership);
}
/**
* The used focusActive of the oldest membership (to find out the selection type multi-sfu or oldest membership active focus)
* @deprecated does not work with m.rtc.member. Do not rely on it.
*/
public getActiveFocus(): Transport | undefined {
return this.getOldestMembership()?.getFocusActive();
}
public getOldestMembership(): CallMembership | undefined {
return this.memberships[0];
}
@@ -695,7 +659,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
}
if (soonestExpiry != undefined) {
this.expiryTimeout = setTimeout(this.ensureRecalculateSessionMembers.bind(this), soonestExpiry);
this.expiryTimeout = setTimeout(() => void this.ensureRecalculateSessionMembers(), soonestExpiry);
}
}
@@ -747,7 +711,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
* Call this when the Matrix room members have changed.
*/
private readonly onRoomMemberUpdate = (): void => {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
};
/**
@@ -763,7 +727,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
(e) => e.getType() === EventType.RTCMembership,
)
) {
this.ensureRecalculateSessionMembers();
void this.ensureRecalculateSessionMembers();
}
};
@@ -777,22 +741,24 @@ export class MatrixRTCSession extends TypedEventEmitter<
};
// helper variables to make sure we do not have parallel running recalculations.
private recalculateSessionMembersPromise: Promise<void> = Promise.resolve();
private recalculateSessionMembersDirty = false;
private recalculateSessionMembersPromise: Promise<void> | undefined = undefined;
private ensureRecalculateSessionMembers(): void {
if (this.recalculateSessionMembersPromise === undefined) {
this.recalculateSessionMembersPromise = this.recalculateSessionMembers().then(() => {
this.recalculateSessionMembersPromise = undefined;
if (this.recalculateSessionMembersDirty) {
this.ensureRecalculateSessionMembers();
this.recalculateSessionMembersDirty = false;
}
});
} else {
this.recalculateSessionMembersDirty = true;
/**
* Ensures that membership is recalculated when the state of the session may have changed.
* Also ensures that only one recalculation is made at a time.
* @returns A promise resolving when the state has been recalculated.
*/
private ensureRecalculateSessionMembers(): Promise<void> {
if (this.membershipNeedsRecalculation) {
// We have already requested recalcuation, don't attempt a new one.
return this.recalculateSessionMembersPromise;
}
this.membershipNeedsRecalculation = true;
// Chain the recalculation.
this.recalculateSessionMembersPromise = this.recalculateSessionMembersPromise
.finally()
.then(() => this.recalculateSessionMembers());
return this.recalculateSessionMembersPromise;
}
/**
@@ -803,11 +769,13 @@ export class MatrixRTCSession extends TypedEventEmitter<
* This function should be called when the room members or call memberships might have changed.
*/
private readonly recalculateSessionMembers = async (): Promise<void> => {
// Clear the flag.
this.membershipNeedsRecalculation = false;
const oldMemberships = this.memberships;
this.memberships = await MatrixRTCSession.sessionMembershipsForSlot(
this.room,
slotDescriptionToId(this.slotDescription),
this.slotDescription,
this.calculateMembershipsOpts,
);
@@ -857,7 +825,7 @@ export class MatrixRTCSession extends TypedEventEmitter<
async function computeBackendIdentityAndVerifyMemberEvents(
room: Pick<Room, "hasMembershipState">,
callMemberEvents: MatrixEvent[],
slotId: string,
slotDescription: SlotDescription,
logger: Logger,
): Promise<CallMembership[]> {
const callMemberships: CallMembership[] = [];
@@ -866,22 +834,14 @@ async function computeBackendIdentityAndVerifyMemberEvents(
const content = memberEvent.getContent();
// Quick filter to avoid unneeded processing of invalid events or left events.
// A more thorough validation will be done later with CallMembership.membershipDataFromMatrixEvent.
if (!quickFilterNonRelevantContents(content, logger)) {
continue;
}
try {
const membershipData = CallMembership.membershipDataFromMatrixEvent(memberEvent);
const membership = await CallMembership.parseFromEvent(memberEvent);
const membership = new CallMembership(
memberEvent,
membershipData,
await CallMembership.computeRtcBackendIdentity(memberEvent, membershipData),
logger,
);
if (isValidMembership(membership, room, slotId, logger)) {
if (isValidMembership(membership, room, slotDescription, logger)) {
callMemberships.push(membership);
}
} catch (e) {
@@ -903,7 +863,8 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
// We have a MSC4143 event membership event with a proper joined content
return true;
} else if (eventKeysCount === 1 && "memberships" in content) {
logger.warn(`Legacy event found. Those are ignored, they do not contribute to the MatrixRTC session`);
// Events used to have this format in the past, but are now deprecated.
// Given that state events ~cannot be deleted, there can be some remaining events in the room, just ignore them.
return false;
} else {
// Invalid or left content
@@ -914,12 +875,12 @@ function quickFilterNonRelevantContents(content: IContent, logger: Logger): bool
function isValidMembership(
membership: CallMembership,
room: Pick<Room, "hasMembershipState">,
slotId: string,
slotDescription: SlotDescription,
logger: Logger,
): boolean {
if (membership.slotId !== slotId) {
if (membership.slotDescription.id !== slotDescription.id) {
logger.info(
`Ignoring membership of user ${membership.userId} for a different slot: user: ${JSON.stringify(membership.slotDescription)}, slotId: ${slotId})`,
`Ignoring membership of user ${membership.userId} for a different slot. Theirs: ${JSON.stringify(membership.slotDescription)}, Expected: ${JSON.stringify(slotDescription)}`,
);
return false;
}
+5 -3
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,8 +20,10 @@ import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession, type SlotDescription } from "./MatrixRTCSession.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
import { type SlotDescription } from "./types.ts";
import { computeSlotId } from "./utils.ts";
export enum MatrixRTCSessionManagerEvents {
// A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously
@@ -59,7 +61,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
private readonly slotDescription: SlotDescription = { application: "m.call", id: "ROOM" }, // Default to the Matrix Call application
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
this.logger = rootLogger.getChild(`[MatrixRTCSessionManager ${computeSlotId(slotDescription)}]`);
}
public start(): void {
+19 -19
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -22,19 +22,9 @@ import type { MatrixClient } from "../client.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import {
type CallMembership,
DEFAULT_EXPIRE_DURATION,
type RtcMembershipData,
type SessionMembershipData,
} from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status } from "./types.ts";
import {
type SlotDescription,
type MembershipConfig,
type SessionConfig,
slotDescriptionToId,
} from "./MatrixRTCSession.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION } from "./CallMembership.ts";
import { type Transport, isMyMembership, type RTCCallIntent, Status, type SlotDescription } from "./types.ts";
import { type MembershipConfig, type SessionConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
@@ -43,6 +33,8 @@ import {
type IMembershipManager,
type MembershipManagerEventHandlerMap,
} from "./IMembershipManager.ts";
import { type RtcMembershipData, type SessionMembershipData } from "./membershipData/index.ts";
import { computeSlotId } from "./utils.ts";
import { isLivekitTransportConfig } from "./LivekitTransport.ts";
/* MembershipActionTypes:
@@ -777,7 +769,8 @@ export class MembershipManager
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the state key (state keys are always legacy. we use sticky events for non legacy events)
const application = this.slotDescription.application;
const slotId = this.slotDescription.id === "ROOM" ? "" : this.slotDescription.id;
const needsEmptyStringRoomFix = application === "m.call" && this.slotDescription.id === "ROOM";
const slotId = needsEmptyStringRoomFix ? "" : this.slotDescription.id;
const stateKey = `${localUserId}_${localDeviceId}_${application}${slotId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
@@ -788,11 +781,14 @@ export class MembershipManager
/**
* Constructs our own membership
* @returns Only returns `SessionMembershipData`
*/
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
const ownMembership = this.ownMembership;
const needsEmptyStringRoomFix =
this.slotDescription.application === "m.call" && this.slotDescription.id === "ROOM";
const focusObjects =
const focusObjects: Pick<SessionMembershipData, "foci_preferred" | "focus_active"> =
this.rtcTransport === undefined
? {
focus_active: { type: "livekit", focus_selection: "oldest_membership" } as const,
@@ -806,7 +802,7 @@ export class MembershipManager
"application": this.slotDescription.application,
// INFO_SLOT_ID_LEGACY_CASE (search for all occurances of this INFO to get the full picture)
// Revert back to "" just for the sending the event.
"call_id": this.slotDescription.id === "ROOM" ? "" : this.slotDescription.id,
"call_id": needsEmptyStringRoomFix ? "" : this.slotDescription.id,
"scope": "m.room",
"device_id": this.deviceId,
// DO NOT use this.memberId here since that is the state key (using application...)
@@ -1096,7 +1092,11 @@ export class StickyEventMembershipManager extends MembershipManager {
return super.actionUpdateFromErrors(e, t, StickyEventMembershipManager.nameMap.get(m) ?? "unknown");
}
protected makeMyMembership(expires: number): SessionMembershipData | RtcMembershipData {
/**
*
* @returns Only returns `RtcMembershipData`
*/
protected makeMyMembership(): RtcMembershipData {
const ownMembership = this.ownMembership;
const livekitTransport = isLivekitTransportConfig(this.rtcTransport) ? this.rtcTransport : undefined;
@@ -1108,7 +1108,7 @@ export class StickyEventMembershipManager extends MembershipManager {
type: this.slotDescription.application,
...(this.callIntent ? { "m.call.intent": this.callIntent } : {}),
},
slot_id: slotDescriptionToId(this.slotDescription),
slot_id: computeSlotId(this.slotDescription),
// Make sure we do not add the alias to the transport.
// It is not needed in matrix2.0. The additional session information will be used to find the right alias on the sfu.
rtc_transports: livekitTransport
+7 -10
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -20,7 +20,7 @@ import {
type IEncryptionManager,
} from "./EncryptionManager.ts";
import { type EncryptionConfig, type MembershipConfig } from "./MatrixRTCSession.ts";
import { CallMembership } from "./CallMembership.ts";
import type { CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
@@ -30,9 +30,9 @@ import {
type InboundEncryptionSession,
type OutboundEncryptionSession,
type ParticipantDeviceInfo,
type Statistics,
} from "./types.ts";
import { OutdatedKeyFilter } from "./utils.ts";
import { computeRtcIdentityRaw } from "./membershipData/rtc.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
@@ -58,7 +58,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
* The encryption manager stores the keys because the application layer might not be ready yet to handle the keys.
* The keys are stored and can be retrieved later when the application layer is ready {@link RTCEncryptionManager#getEncryptionKeys}.
*/
private participantKeyRings = new Map<
private readonly participantKeyRings = new Map<
EncryptionKeyMapKey,
Array<{
key: Uint8Array<ArrayBuffer>;
@@ -111,7 +111,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
private logger: Logger | undefined = undefined;
private rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
private readonly rtcIdentityProvider: (userId: string, deviceId: string, memberId: string) => Promise<string>;
/**
*
@@ -124,10 +124,9 @@ export class RTCEncryptionManager implements IEncryptionManager {
* @param rtcBackendIdProvider - A function to compute the rtc backend identity, exposed for testing purposes
*/
public constructor(
private ownMembership: CallMembershipIdentityParts,
private readonly ownMembership: CallMembershipIdentityParts,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBuffer>,
@@ -139,7 +138,7 @@ export class RTCEncryptionManager implements IEncryptionManager {
rtcBackendIdProvider?: (userId: string, deviceId: string, memberId: string) => Promise<string>,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
this.rtcIdentityProvider = rtcBackendIdProvider ?? CallMembership.computeRtcIdentityRaw;
this.rtcIdentityProvider = rtcBackendIdProvider ?? computeRtcIdentityRaw;
}
private async getOwnRtcBackendIdentity(): Promise<string> {
@@ -296,7 +295,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
candidateInboundSession.keyIndex,
candidateInboundSession.membership,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(
`Received an out of order key for ${membership.userId}:${membership.deviceId}, dropping it`,
@@ -410,7 +408,6 @@ export class RTCEncryptionManager implements IEncryptionManager {
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
this.statistics.counters.roomEventEncryptionKeysSent += 1;
outboundKey.sharedWith.push(...toDistributeTo);
this.logger?.trace(
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
-189
View File
@@ -1,189 +0,0 @@
/*
Copyright 2025 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 { MatrixClient } from "../client.ts";
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
/**
* @deprecated This is depreacted and not used anymore. use the ToDeviceTransport
*/
export class RoomKeyTransport
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
implements IKeyTransport
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[RoomKeyTransport]`);
}
public constructor(
private room: Pick<Room, "on" | "off" | "roomId">,
private client: Pick<
MatrixClient,
"sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded"
>,
private statistics: Statistics,
parentLogger?: Logger,
) {
super();
this.setParentLogger(parentLogger ?? rootLogger);
}
public start(): void {
this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
public stop(): void {
this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev));
}
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
if (!isRetry) {
this.logger.warn(
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
);
// retry after 1 second. After this we give up.
setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000);
} else {
this.logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
}
return;
} else if (isRetry) {
this.logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
}
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
if (!this.room) {
this.logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return Promise.resolve();
}
this.onEncryptionEvent(event);
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [
{
index: index,
key: keyBase64Encoded,
},
],
device_id: this.client.getDeviceId()!,
call_id: "",
sent_ts: Date.now(),
};
try {
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
} catch (error) {
this.logger.error("Failed to send call encryption keys", error);
const matrixError = error as MatrixError;
if (matrixError.event) {
// cancel the pending event: we'll just generate a new one with our latest
// keys when we resend
this.client.cancelPendingEvent(matrixError.event);
}
throw error;
}
}
public onEncryptionEvent(event: MatrixEvent): void {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
const deviceId = content["device_id"];
const callId = content["call_id"];
if (!userId) {
this.logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
return;
}
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
this.logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
);
return;
}
if (!Array.isArray(content.keys)) {
this.logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
return;
}
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
// We store our own sender key in the same set along with keys from others, so it's
// important we don't allow our own keys to be set by one of these events (apart from
// the fact that we don't need it anyway because we already know our own keys).
this.logger.info("Ignoring our own keys event");
return;
}
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
for (const key of content.keys) {
if (!key) {
this.logger.info("Ignoring false-y key in keys event");
continue;
}
const encryptionKey = key.key;
const encryptionKeyIndex = key.index;
if (
!encryptionKey ||
encryptionKeyIndex === undefined ||
encryptionKeyIndex === null ||
callId === undefined ||
callId === null ||
typeof deviceId !== "string" ||
typeof callId !== "string" ||
typeof encryptionKey !== "string" ||
typeof encryptionKeyIndex !== "number"
) {
this.logger.warn(
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
);
} else {
this.logger.debug(
`onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
);
this.emit(
KeyTransportEvents.ReceivedKeys,
// Using `${userId}:${deviceId}` makes no sense (but works). It does not matter since the RoomKeyTransport is deprecated
{ userId, deviceId, memberId: `${userId}:${deviceId}` },
encryptionKey,
encryptionKeyIndex,
event.getTs(),
);
}
}
}
}
+1
View File
@@ -19,5 +19,6 @@ export * from "./LivekitTransport.ts";
export * from "./MatrixRTCSession.ts";
export * from "./MatrixRTCSessionManager.ts";
export type * from "./types.ts";
export { type SessionMembershipData, type RtcMembershipData } from "./membershipData/index.ts";
export { Status, parseCallNotificationContent, isMyMembership } from "./types.ts";
export { MembershipManagerEvent } from "./IMembershipManager.ts";
+27
View File
@@ -0,0 +1,27 @@
/*
Copyright 2026 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.
*/
/**
* Thrown when an event is not valid for use with MatrixRTC.
*/
export class MatrixRTCMembershipParseError extends AggregateError {
public constructor(
public readonly type: string,
errors: string[],
) {
super(errors, `Does not match ${type}:\n${errors.join("\n")}`);
}
}
+19
View File
@@ -0,0 +1,19 @@
/*
Copyright 2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export { type SessionMembershipData, checkSessionsMembershipData } from "./session.ts";
export { type RtcMembershipData, computeRtcIdentityRaw, checkRtcMembershipData } from "./rtc.ts";
export { MatrixRTCMembershipParseError } from "./common.ts";
+157
View File
@@ -0,0 +1,157 @@
/*
Copyright 2026 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 { MXID_PATTERN } from "../../models/room-member.ts";
import type { IContent } from "../../models/event.ts";
import type { RelationType } from "../../types.ts";
import { type RtcSlotEventContent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
import { sha256 } from "../../digest.ts";
import { encodeUnpaddedBase64 } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts";
/**
* Represents the current form of MSC4143, which uses sticky events to store membership.
*/
export interface RtcMembershipData {
"slot_id": string;
"member": {
user_id: string;
device_id: string;
id: string;
};
"m.relates_to"?: {
event_id: string;
rel_type: RelationType.Reference;
};
"application": RtcSlotEventContent["application"];
"rtc_transports": Transport[];
"versions": string[];
"msc4354_sticky_key"?: string;
"sticky_key"?: string;
}
/**
* Validates that `data` matches the format expected by MSC4143.
* @param data The event content.
* @param sender The sender of the event.
* @returns true if `data` is valid RtcMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkRtcMembershipData = (data: IContent, sender: string): data is RtcMembershipData => {
const errors: string[] = [];
const prefix = " - ";
const expectedSlotPrefix = `${data?.application?.type}#`;
// required fields
if (typeof data.slot_id !== "string") {
errors.push(prefix + "slot_id must be string");
} else if (!data.slot_id.startsWith(expectedSlotPrefix)) {
errors.push(prefix + `slot_id must start with ${expectedSlotPrefix}`);
} else {
try {
slotIdToDescription(data.slot_id);
} catch (ex) {
errors.push(prefix + `slot_id was badly formed${ex instanceof Error ? `: ${ex.message}` : ""}`);
}
}
if (typeof data.member !== "object" || data.member === null) {
errors.push(prefix + "member must be an object");
} else {
if (typeof data.member.user_id !== "string") {
errors.push(prefix + "member.user_id must be string");
} else if (!MXID_PATTERN.test(data.member.user_id)) {
errors.push(prefix + "member.user_id must be a valid mxid");
}
// This is not what the spec enforces but there currently are no rules what power levels are required to
// send a m.rtc.member event for a other user. So we add this check for simplicity and to avoid possible attacks until there
// is a proper definition when this is allowed.
else if (data.member.user_id !== sender) {
errors.push(prefix + "member.user_id must match the sender");
}
if (typeof data.member.device_id !== "string") {
errors.push(prefix + "member.device_id must be string");
}
if (typeof data.member.id !== "string") errors.push(prefix + "member.id must be string");
}
if (typeof data.application !== "object" || data.application === null) {
errors.push(prefix + "application must be an object");
} else {
if (typeof data.application.type !== "string") {
errors.push(prefix + "application.type must be a string");
} else {
if (data.application.type.includes("#")) errors.push(prefix + 'application.type must not include "#"');
}
}
if (data.rtc_transports === undefined || !Array.isArray(data.rtc_transports)) {
errors.push(prefix + "rtc_transports must be an array");
} else {
// validate that each transport has at least a string 'type'
for (const t of data.rtc_transports) {
if (typeof t !== "object" || t === null || typeof (t as any).type !== "string") {
errors.push(prefix + "rtc_transports entries must be objects with a string type");
break;
}
}
}
if (data.versions === undefined || !Array.isArray(data.versions)) {
errors.push(prefix + "versions must be an array");
} else if (!data.versions.every((v) => typeof v === "string")) {
errors.push(prefix + "versions must be an array of strings");
}
// optional fields
if ((data.sticky_key ?? data.msc4354_sticky_key) === undefined) {
errors.push(prefix + "sticky_key or msc4354_sticky_key must be a defined");
}
if (data.sticky_key !== undefined && typeof data.sticky_key !== "string") {
errors.push(prefix + "sticky_key must be a string");
}
if (data.msc4354_sticky_key !== undefined && typeof data.msc4354_sticky_key !== "string") {
errors.push(prefix + "msc4354_sticky_key must be a string");
}
if (
data.sticky_key !== undefined &&
data.msc4354_sticky_key !== undefined &&
data.sticky_key !== data.msc4354_sticky_key
) {
errors.push(prefix + "sticky_key and msc4354_sticky_key must be equal if both are defined");
}
if (data["m.relates_to"] !== undefined) {
const rel = data["m.relates_to"] as RtcMembershipData["m.relates_to"];
if (typeof rel !== "object" || rel === null) {
errors.push(prefix + "m.relates_to must be an object if provided");
} else {
if (typeof rel.event_id !== "string") errors.push(prefix + "m.relates_to.event_id must be a string");
if (rel.rel_type !== "m.reference") errors.push(prefix + "m.relates_to.rel_type must be m.reference");
}
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("RtcMembership", errors);
}
return true;
};
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
// canonical JSON serialization (Matrix canonical JSON for arrays)
const jsonStr = JSON.stringify([userId, deviceId, memberId]);
const hashBuffer = await sha256(jsonStr);
const hashedString = encodeUnpaddedBase64(hashBuffer);
return hashedString;
}
+146
View File
@@ -0,0 +1,146 @@
/*
Copyright 2026 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 IContent } from "../../matrix.ts";
import { type RTCCallIntent, type Transport } from "../types.ts";
import { MatrixRTCMembershipParseError } from "./common.ts";
/**
* (MatrixRTC) session membership data.
* This represents the *OLD* form of MSC4143, which uses state events to store membership.
* Represents the `session` in the memberships section of an m.call.member event as it is on the wire.
**/
export type SessionMembershipData = {
/**
* The RTC application defines the type of the RTC session.
*/
"application": string;
/**
* The id of this session.
* A session can never span over multiple rooms so this id is to distinguish between
* multiple session in one room. A room wide session that is not associated with a user,
* and therefore immune to creation race conflicts, uses the `call_id: ""`.
*/
"call_id": string;
/**
* The Matrix device ID of this session. A single user can have multiple sessions on different devices.
*/
"device_id": string;
/**
* The focus selection system this user/membership is using.
* NOTE: This is still included for legacy reasons, but not consumed by the SDK.
*/
"focus_active": {
type: "livekit" | string;
focus_selection: "oldest_membership" | "multi_sfu" | string;
};
/**
* A list of possible foci this user knows about. One of them might be used based on the focus_active
* selection system.
*/
"foci_preferred": Transport[];
/**
* Optional field that contains the creation of the session. If it is undefined the creation
* is the `origin_server_ts` of the event itself. For updates to the event this property tracks
* the `origin_server_ts` of the initial join event.
* - If it is undefined it can be interpreted as a "Join".
* - If it is defined it can be interpreted as an "Update"
*/
"created_ts"?: number;
// Application specific data
/**
* If the `application` = `"m.call"` this defines if it is a room or user owned call.
* There can always be one room scoped call but multiple user owned calls (breakout sessions)
*/
"scope"?: "m.room" | "m.user";
/**
* Optionally we allow to define a delta to the `created_ts` that defines when the event is expired/invalid.
* This should be set to multiple hours. The only reason it exist is to deal with failed delayed events.
* (for example caused by a homeserver crashes)
**/
"expires"?: number;
/**
* The intent of the call from the perspective of this user. This may be an audio call, video call or
* something else.
*/
"m.call.intent"?: RTCCallIntent;
/**
* The id used on the media backend.
* (With livekit this is the participant identity on the LK SFU)
* This can be a UUID but right now it is `${this.matrixEventData.sender}:${data.device_id}`.
*
* It is compleatly valid to not set this field. Other clients will treat `undefined` as `${this.matrixEventData.sender}:${data.device_id}`
*/
"membershipID"?: string;
};
/**
* Validates that `data` matches the format expected by the legacy form of MSC4143.
* @param data The event content.
* @returns true if `data` is valid SessionMembershipData
* @throws {MatrixRTCMembershipParseError} if the content is not valid
*/
export const checkSessionsMembershipData = (data: IContent): data is SessionMembershipData => {
const prefix = " - ";
const errors: string[] = [];
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 (data.focus_active === undefined) {
errors.push(prefix + "focus_active has an invalid type");
}
if (typeof data.focus_active?.type !== "string") {
errors.push(prefix + "focus_active.type must be a string");
}
if (
data.foci_preferred !== undefined &&
!(
Array.isArray(data.foci_preferred) &&
data.foci_preferred.every(
(f: Transport) => typeof f === "object" && f !== null && typeof f.type === "string",
)
)
) {
errors.push(prefix + "foci_preferred must be an array of transport objects");
}
// optional parameters
if (data.created_ts !== undefined && 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 !== undefined && typeof data.scope !== "string") errors.push(prefix + "scope must be string");
if (data["m.call.intent"] !== undefined && typeof data["m.call.intent"] !== "string") {
errors.push(prefix + "m.call.intent must be a string");
}
if (errors.length) {
throw new MatrixRTCMembershipParseError("SessionMembership", errors);
}
return true;
};
+27 -1
View File
@@ -1,5 +1,5 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Copyright 2023-2026 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.
@@ -199,3 +199,29 @@ export interface Transport {
type: string;
[key: string]: unknown;
}
/**
* Event content for a `m.rtc.slot` state event.
*/
export interface RtcSlotEventContent<T extends string = string> {
application: {
type: T;
// other application specific keys
[key: string]: unknown;
};
slot_id: string;
}
/**
* The session description is used to identify a session. Used in the state event.
*/
export interface SlotDescription {
/**
* The application type. e.g. "m.call".
*/
application: string;
/**
* The application-specific slot ID. e.g. "ROOM".
*/
id: string;
}
+24 -2
View File
@@ -1,5 +1,5 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Copyright 2025-2026 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { getEncryptionKeyMapKey, type CallMembershipIdentityParts } from "./EncryptionManager.ts";
import { type InboundEncryptionSession, type EncryptionKeyMapKey } from "./types.ts";
import type { InboundEncryptionSession, EncryptionKeyMapKey, SlotDescription } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
@@ -47,3 +47,25 @@ export class OutdatedKeyFilter {
return false;
}
}
/**
* Converts a slot ID into it's component application and ID portions.
* @param slotId e.g. `m.call#call_id`
* @throws If the format of `slotId` is invalid.
*/
export function slotIdToDescription(slotId: string): SlotDescription {
const [application, id, ...unexpectedAdditionalValues] = slotId.split("#");
if (unexpectedAdditionalValues.length) {
throw Error(
"MatrixRTC Slot IDs *must* only contain two components seperated by one '#'. Additional '#' characters detected.",
);
}
return { application, id };
}
/**
* Converts a SlotDescription into it's slot ID format.
*/
export function computeSlotId(slotDescription: SlotDescription): string {
return `${slotDescription.application}#${slotDescription.id}`;
}
+3 -1
View File
@@ -76,6 +76,8 @@ export interface IUnsigned {
"m.relations"?: Record<RelationType | string, any>; // No common pattern for aggregated relations
"msc4354_sticky_duration_ttl_ms"?: number;
[UNSIGNED_THREAD_ID_FIELD.name]?: string;
"membership"?: Membership;
"io.element.msc4115.membership"?: Membership;
}
export interface IThreadBundledRelationship {
@@ -786,7 +788,7 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
*/
public getMembershipAtEvent(): Membership | string | undefined {
const unsigned = this.getUnsigned();
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership | string>(unsigned);
return UNSIGNED_MEMBERSHIP_FIELD.findIn<Membership>(unsigned);
}
/**
+38 -16
View File
@@ -53,6 +53,7 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
private sortedAnnotationsByKey: [string, Set<MatrixEvent>][] = [];
private targetEvent: MatrixEvent | null = null;
private creationEmitted = false;
private replacementUpdateId = 0;
private readonly client: MatrixClient;
/**
@@ -106,9 +107,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.addAnnotationToAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
event.on(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -132,9 +132,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
this.removeAnnotationFromAggregation(event);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.emit(RelationsEvent.Remove, event);
@@ -243,9 +242,8 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
if (this.relationType === RelationType.Annotation) {
// Remove the redacted annotation from aggregation by key
this.removeAnnotationFromAggregation(redactedEvent);
} else if (this.relationType === RelationType.Replace && this.targetEvent && !this.targetEvent.isState()) {
const lastReplacement = await this.getLastReplacement();
this.targetEvent.makeReplaced(lastReplacement!);
} else if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
redactedEvent.removeListener(MatrixEventEvent.BeforeRedaction, this.onBeforeRedaction);
@@ -343,18 +341,42 @@ export class Relations extends TypedEventEmitter<RelationsEvent, EventHandlerMap
}
this.targetEvent = event;
if (this.relationType === RelationType.Replace && !this.targetEvent.isState()) {
const replacement = await this.getLastReplacement();
// this is the initial update, so only call it if we already have something
// to not emit Event.replaced needlessly
if (replacement) {
this.targetEvent.makeReplaced(replacement);
}
if (this.relationType === RelationType.Replace) {
await this.updateTargetEventReplacement();
}
this.maybeEmitCreated();
}
/**
* Updates the target event with the latest replacement.
*
* Multiple replacement updates can be triggered concurrently (for example
* while edits are still being decrypted). A monotonic update counter guards
* against older async resolutions overriding newer replacement selections.
*/
private async updateTargetEventReplacement(): Promise<void> {
if (!this.targetEvent || this.targetEvent.isState()) {
return;
}
const targetEvent = this.targetEvent;
const updateId = ++this.replacementUpdateId;
const lastReplacement = await this.getLastReplacement();
// If a newer update started while we were awaiting, discard this stale result.
if (updateId !== this.replacementUpdateId || this.targetEvent !== targetEvent) {
return;
}
// Avoid emitting Event.replaced when there is no replacement and none currently set.
if (!lastReplacement && !targetEvent.replacingEvent()) {
return;
}
targetEvent.makeReplaced(lastReplacement ?? undefined);
}
private maybeEmitCreated(): void {
if (this.creationEmitted) {
return;
+36
View File
@@ -227,6 +227,42 @@ export class RoomMember extends TypedEventEmitter<RoomMemberEvent, RoomMemberEve
}
}
/**
* Recalculate the disambiguation flag for this member based on current room state.
* This should be called when another member's display name changes and may affect
* whether this member needs disambiguation.
*
* @param roomState - The current room state to use for disambiguation check
* @returns true if the member's name changed as a result of the disambiguation update
*
* @remarks
* Fires {@link RoomMemberEvent.Name}
*/
public recalculateDisambiguatedName(roomState: RoomState): boolean {
if (!this.events.member) {
return false;
}
const displayName = this.events.member.getDirectionalContent().displayname ?? "";
const newDisambiguate = shouldDisambiguate(this.userId, displayName, roomState);
if (newDisambiguate === this.disambiguate) {
return false;
}
this.disambiguate = newDisambiguate;
const oldName = this.name;
this.name = calculateDisplayName(this.userId, displayName, this.disambiguate);
if (oldName !== this.name) {
this.updateModifiedTime();
this.emit(RoomMemberEvent.Name, this.events.member, this, oldName);
return true;
}
return false;
}
/**
* Update this room member's power level event. Will fire
* "RoomMember.powerLevel" if the new power level is different
+49 -1
View File
@@ -438,6 +438,11 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
this.updateModifiedTime();
// update the core event dict
// Track display names that change so we can recalculate disambiguation
const affectedDisplayNames = new Set<string>();
// Track userIds whose membership events we process so we don't emit duplicate events
const processedMemberUserIds = new Set<string>();
stateEvents.forEach((event) => {
if (event.getRoomId() !== this.roomId || !event.isState()) return;
@@ -448,7 +453,22 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
const lastStateEvent = this.getStateEventMatching(event);
this.setStateEvent(event);
if (event.getType() === EventType.RoomMember) {
this.updateDisplayNameCache(event.getStateKey()!, event.getContent().displayname ?? "");
const userId = event.getStateKey()!;
processedMemberUserIds.add(userId);
const newDisplayName = event.getContent().displayname ?? "";
const oldDisplayName = this.userIdsToDisplayNames[userId];
// Track both old and new display names for disambiguation recalculation
if (oldDisplayName) {
const strippedOld = removeHiddenChars(oldDisplayName);
if (strippedOld) affectedDisplayNames.add(strippedOld);
}
if (newDisplayName) {
const strippedNew = removeHiddenChars(newDisplayName);
if (strippedNew) affectedDisplayNames.add(strippedNew);
}
this.updateDisplayNameCache(userId, newDisplayName);
this.updateThirdPartyTokenCache(event);
}
this.emit(RoomStateEvent.Events, event, this, lastStateEvent);
@@ -514,6 +534,33 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
}
});
// Recalculate disambiguation for all members whose display names were affected.
// This ensures that when a user changes their name to match (or stop matching)
// another user, all affected users' disambiguation flags are updated correctly.
if (affectedDisplayNames.size > 0) {
// Collect all affected user IDs first to avoid duplicate processing
const affectedUserIds = new Set<string>();
for (const displayName of affectedDisplayNames) {
const userIds = this.displayNameToUserIds.get(displayName) ?? [];
userIds.forEach((id) => affectedUserIds.add(id));
}
// Process each affected member once, excluding those whose membership
// events were already processed (they already got their events emitted)
for (const userId of affectedUserIds) {
if (processedMemberUserIds.has(userId)) {
continue;
}
const member = this.members[userId];
if (member?.events.member) {
const nameChanged = member.recalculateDisambiguatedName(this);
if (nameChanged) {
this.emit(RoomStateEvent.Members, member.events.member, this, member);
}
}
}
}
this.emit(RoomStateEvent.Update, this);
}
@@ -1110,6 +1157,7 @@ export class RoomState extends TypedEventEmitter<EmittedEvents, EventHandlerMap>
private updateDisplayNameCache(userId: string, displayName: string): void {
const oldName = this.userIdsToDisplayNames[userId];
delete this.userIdsToDisplayNames[userId];
if (oldName) {
// Remove the old name from the cache.
+25 -6
View File
@@ -14,7 +14,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IdTokenClaims, Log, OidcClient, SigninResponse, SigninState, WebStorageStateStore } from "oidc-client-ts";
import {
type IdTokenClaims,
Log,
OidcClient,
type SigninRequestCreateArgs,
SigninResponse,
SigninState,
WebStorageStateStore,
} from "oidc-client-ts";
import { logger } from "../logger.ts";
import { secureRandomString } from "../randomstring.ts";
@@ -127,6 +135,8 @@ export const generateAuthorizationUrl = async (
* @param urlState - value to append to the opaque state identifier to uniquely identify the callback
* @param loginHint - value to send as the `login_hint` to the OP, giving a hint about the login identifier the user might use to log in.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
* @param responseMode - value to send as the `response_mode` to the OP, selecting how auth is passed back during redirect.
* See {@link https://openid.net/specs/openid-connect-core-1_0.html#AuthRequest OIDC core 3.1.2.1}.
* @returns a Promise with the url as a string
*/
export const generateOidcAuthorizationUrl = async ({
@@ -139,6 +149,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt,
urlState,
loginHint,
responseMode = "query",
}: {
clientId: string;
metadata: ValidatedAuthMetadata;
@@ -149,6 +160,7 @@ export const generateOidcAuthorizationUrl = async ({
prompt?: string;
urlState?: string;
loginHint?: string;
responseMode?: SigninRequestCreateArgs["response_mode"];
}): Promise<string> => {
const scope = generateScope();
const oidcClient = new OidcClient({
@@ -156,7 +168,7 @@ export const generateOidcAuthorizationUrl = async ({
client_id: clientId,
redirect_uri: redirectUri,
authority: metadata.issuer,
response_mode: "query",
response_mode: responseMode,
response_type: "code",
scope,
stateStore: new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage }),
@@ -200,7 +212,8 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
* request to the Token Endpoint, to obtain the access token, refresh token, etc.
*
* @param code - authorization code as returned by OP during authorization
* @param storedAuthorizationParams - stored params from start of oidc login flow
* @param state - authorization state param as returned by OP during authorization
* @param responseMode - the response mode used for authentication
* @returns valid bearer token response
* @throws An `Error` with `message` set to an entry in {@link OidcError},
* when the request fails, or the returned token response is invalid.
@@ -208,6 +221,7 @@ const normalizeBearerTokenResponseTokenType = (response: SigninResponse): Bearer
export const completeAuthorizationCodeGrant = async (
code: string,
state: string,
responseMode: SigninRequestCreateArgs["response_mode"] = "query",
): Promise<{
oidcClientSettings: { clientId: string; issuer: string };
tokenResponse: BearerTokenResponse;
@@ -221,13 +235,18 @@ export const completeAuthorizationCodeGrant = async (
* so that oidc-client can parse it
*/
const reconstructedUrl = new URL(window.location.origin);
reconstructedUrl.searchParams.append("code", code);
reconstructedUrl.searchParams.append("state", state);
const params = new URLSearchParams({ code, state });
if (responseMode === "query") {
reconstructedUrl.search = params.toString();
} else {
reconstructedUrl.hash = `#${params.toString()}`;
}
// set oidc-client to use our logger
Log.setLogger(logger);
try {
const response = new SigninResponse(reconstructedUrl.searchParams);
const response = new SigninResponse(params);
const stateStore = new WebStorageStateStore({ prefix: "mx_oidc_", store: window.sessionStorage });
+1 -1
View File
@@ -62,6 +62,6 @@ export const validateAuthMetadataAndKeys = async (authMetadata: unknown): Promis
return {
...validatedIssuerConfig,
signingKeys: await metadataService.getSigningKeys(),
signingKeys: validatedIssuerConfig.jwks_uri ? await metadataService.getSigningKeys() : null,
};
};
+6 -6
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { QrCodeMode } from "@matrix-org/matrix-sdk-crypto-wasm";
import { QrCodeIntent } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
ClientRendezvousFailureReason,
@@ -108,7 +108,7 @@ interface SecretsPayload extends MSC4108Payload, Awaited<ReturnType<NonNullable<
* @experimental Note that this is UNSTABLE and may have breaking changes without notice.
*/
export class MSC4108SignInWithQR {
private readonly ourIntent: QrCodeMode;
private readonly ourIntent: QrCodeIntent;
private _code?: Uint8Array;
private expectingNewDeviceId?: string;
@@ -131,7 +131,7 @@ export class MSC4108SignInWithQR {
private readonly client?: MatrixClient,
public onFailure?: RendezvousFailureListener,
) {
this.ourIntent = client ? QrCodeMode.Reciprocate : QrCodeMode.Login;
this.ourIntent = client ? QrCodeIntent.Reciprocate : QrCodeIntent.Login;
}
/**
@@ -149,9 +149,9 @@ export class MSC4108SignInWithQR {
return;
}
if (this.ourIntent === QrCodeMode.Reciprocate && this.client) {
if (this.ourIntent === QrCodeIntent.Reciprocate && this.client) {
this._code = await this.channel.generateCode(this.ourIntent, this.client.getDomain()!);
} else if (this.ourIntent === QrCodeMode.Login) {
} else if (this.ourIntent === QrCodeIntent.Login) {
this._code = await this.channel.generateCode(this.ourIntent);
}
}
@@ -160,7 +160,7 @@ export class MSC4108SignInWithQR {
* 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;
return this.ourIntent === QrCodeIntent.Reciprocate;
}
/**
@@ -19,7 +19,7 @@ import {
Ecies,
type EstablishedEcies,
QrCodeData,
QrCodeMode,
QrCodeIntent,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import {
@@ -56,9 +56,9 @@ export class MSC4108SecureChannel {
* @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> {
public async generateCode(mode: QrCodeIntent.Login): Promise<Uint8Array>;
public async generateCode(mode: QrCodeIntent.Reciprocate, serverName: string): Promise<Uint8Array>;
public async generateCode(mode: QrCodeIntent, serverName?: string): Promise<Uint8Array> {
const { url } = this.rendezvousSession;
if (!url) {
@@ -68,7 +68,7 @@ export class MSC4108SecureChannel {
return new QrCodeData(
this.secureChannel.public_key(),
url,
mode === QrCodeMode.Reciprocate ? serverName : undefined,
mode === QrCodeIntent.Reciprocate ? serverName : undefined,
).toBytes();
}
@@ -262,7 +262,9 @@ export class MSC4108RendezvousSession {
if (!this.url) return;
try {
await this.fetch(this.url, { method: Method.Delete });
const method = Method.Delete;
logger.info(`=> ${method} ${this.url}`);
await this.fetch(this.url, { method });
} catch (e) {
logger.warn(e);
}
+64 -10
View File
@@ -161,7 +161,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/**
* Handles a backup secret received event and store it if it matches the current backup version.
*
* @param secret - The secret as received from a `m.secret.send` event for secret `m.megolm_backup.v1`.
* @param secret - The secret as received from a `m.secret.send` or `io.element.msc4385.secret.push` event for secret `m.megolm_backup.v1`.
* @returns true if the secret is valid and has been stored, false otherwise.
*/
public async handleBackupSecretReceived(secret: string): Promise<boolean> {
@@ -180,28 +180,44 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
// There is no server-side key backup.
// This decryption key is useless to us.
this.logger.warn(
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
"handleBackupSecretReceived: Received a backup decryption key, but there is no server-side key backup",
);
return false;
}
let backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey;
try {
backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
} catch (e) {
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
return false;
}
try {
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
if (!privateKeyMatches) {
this.logger.warn(
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
`handleBackupSecretReceived: Private decryption key does not match the public key of the current server-side backup version (${latestBackupInfo.version})`,
);
// just ignore the secret
return false;
}
this.logger.info(
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
`handleBackupSecretReceived: Valid decryption key for the current server-side backup version (${latestBackupInfo.version}) received`,
);
await this.saveBackupDecryptionKey(backupDecryptionKey, latestBackupInfo.version);
// Check if the backup should be enabled (e.g. if it's properly
// signed), and enable it if it should
if (this.keyBackupCheckInProgress) {
await this.keyBackupCheckInProgress;
}
this.keyBackupCheckInProgress = this.doCheckKeyBackup(latestBackupInfo).finally(() => {
this.keyBackupCheckInProgress = null;
});
await this.keyBackupCheckInProgress;
return true;
} catch (e) {
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
this.logger.warn("handleBackupSecretReceived: Unable to validate backup decryption key", e);
}
return false;
@@ -281,12 +297,17 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
private keyBackupCheckInProgress: Promise<KeyBackupCheck | null> | null = null;
/** Helper for `checkKeyBackup` */
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
/** Helper to check the key backup status, and enable/disable it as appropriate
*
* A KeyBackupInfo can be passed if it was fetched recently, to avoid trying to
* re-fetch it from the server.
*/
private async doCheckKeyBackup(backupInfo?: KeyBackupInfo | null | undefined): Promise<KeyBackupCheck | null> {
this.logger.debug("Checking key backup status...");
let backupInfo: KeyBackupInfo | null | undefined;
try {
backupInfo = await this.requestKeyBackupVersion();
if (!backupInfo) {
backupInfo = await this.requestKeyBackupVersion();
}
} catch (e) {
this.logger.warn("Error checking for active key backup", e);
this.serverBackupInfo = undefined;
@@ -631,6 +652,24 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
return this.importKeyBackup(keyBackup, backupVersion, backupDecryptor, opts);
}
/**
* Download and import the keys for a given room from the current backup version.
*
* @param roomId - The room in question.
*/
public async downloadLatestRoomKeyBackup(roomId: string): Promise<void> {
const { backupVersion, decryptionKey } = await this.olmMachine.getBackupKeys();
if (!backupVersion || !decryptionKey) {
this.logger.warn(
`downloadLatestRoomKeyBackup: Could not download backup (backupVersion=${backupVersion}, hasDecryptionKey=${!!decryptionKey})`,
);
return;
}
const sessions = await this.downloadRoomKeyBackup(backupVersion, roomId);
const backupDecryptor = this.createBackupDecryptor(decryptionKey);
this.importKeyBackup({ rooms: { [roomId]: { sessions } } }, backupVersion, backupDecryptor);
}
/**
* Call `/room_keys/keys` to download the key backup (room keys) for the given backup version.
* https://spec.matrix.org/v1.12/client-server-api/#get_matrixclientv3room_keyskeys
@@ -650,6 +689,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
);
}
/**
* Call `/room/keys/keys/{roomId}` to download the key backup (room keys) for a given backup version and room ID.
* @param backupVersion - The version to download.
* @param roomId - The ID of the room.
* @returns The key backup response.
*/
private downloadRoomKeyBackup(backupVersion: string, roomId: string): Promise<KeyBackupRoomSessions> {
const path = encodeUri("/room_keys/keys/$roomId", {
$roomId: roomId,
});
return this.http.authedRequest<KeyBackupRoomSessions>(Method.Get, path, { version: backupVersion }, undefined, {
prefix: ClientPrefix.V3,
});
}
/**
* Import the room keys from a `/room_keys/keys` call.
* Calls `opts.progressCallback` with the progress of the import.
+17 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { StoreHandle } from "@matrix-org/matrix-sdk-crypto-wasm";
import { RustCrypto } from "./rust-crypto.ts";
import { MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE, RustCrypto } from "./rust-crypto.ts";
import { type IHttpOpts, type MatrixHttpApi } from "../http-api/index.ts";
import { type ServerSideSecretStorage } from "../secret-storage.ts";
import { type Logger } from "../logger.ts";
@@ -247,5 +247,21 @@ async function initOlmMachine(
}
}
// If we have any recently-joined rooms, see if we have a pending key bundle for them.
for (const pendingDetails of await olmMachine.getAllRoomsPendingKeyBundles()) {
const roomId = pendingDetails.roomId.toString();
if (Date.now() - pendingDetails.inviteAcceptedAtMillis <= MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE) {
logger.info(
`Checking for pending key bundle for recently-joined room ${roomId} (joined ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()})`,
);
await rustCrypto.maybeAcceptKeyBundle(roomId, pendingDetails.inviterId.toString());
} else {
logger.info(
`Clearing pending-key-bundle flag for room ${roomId} (too old: joined ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()})`,
);
await olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
}
}
return rustCrypto;
}
+148 -59
View File
@@ -50,6 +50,7 @@ import {
CryptoEvent,
type CryptoEventHandlerMap,
DecryptionFailureCode,
DecryptionKeyDoesNotMatchError,
deriveRecoveryKeyFromPassphrase,
type DeviceIsolationMode,
DeviceIsolationModeKind,
@@ -97,6 +98,7 @@ import { VerificationMethod } from "../types.ts";
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
import { type UIAuthCallback } from "../interactive-auth.ts";
import { getHttpUriForMxc } from "../content-repo.ts";
import { type RoomState } from "../matrix.ts";
const ALL_VERIFICATION_METHODS = [
VerificationMethod.Sas,
@@ -110,6 +112,9 @@ interface ISignableObject {
unsigned?: object;
}
/** The maximum time, in milliseconds, since we accepted an invite, that we should accept a key bundle. */
export const MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE = 24 * 60 * 60 * 1000; // 24 hours
/**
* An implementation of {@link CryptoBackend} using the Rust matrix-sdk-crypto.
*
@@ -130,9 +135,6 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/** mapping of roomId → encryptor class */
private roomEncryptors: Record<string, RoomEncryptor> = {};
/** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */
private readonly roomsPendingKeyBundles: Map<string, string> = new Map();
private eventDecryptor: EventDecryptor;
private keyClaimManager: KeyClaimManager;
private outgoingRequestProcessor: OutgoingRequestProcessor;
@@ -369,10 +371,10 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/* allowRedirects */ true,
/* useAuthentication */ true,
);
let encryptedBundle: Blob;
let encryptedBundle: Uint8Array;
try {
const bundleUrl = new URL(url);
encryptedBundle = await this.http.authedRequest<Blob>(
const encryptedBundleBlob = await this.http.authedRequest<Blob>(
Method.Get,
bundleUrl.pathname + bundleUrl.search,
{},
@@ -382,17 +384,24 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
prefix: "",
},
);
logger.info(`Received blob of length ${encryptedBundleBlob.size}`);
encryptedBundle = new Uint8Array(await encryptedBundleBlob.arrayBuffer());
} catch (err) {
logger.warn(`Error downloading encrypted bundle from ${url}:`, err);
throw err;
}
logger.info(`Received blob of length ${encryptedBundle.size}`);
try {
await this.olmMachine.receiveRoomKeyBundle(bundleData, new Uint8Array(await encryptedBundle.arrayBuffer()));
await this.olmMachine.receiveRoomKeyBundle(bundleData, encryptedBundle);
} catch (err) {
logger.warn(`Error receiving encrypted bundle:`, err);
throw err;
} finally {
// Even if we were unable to import the bundle, we still clear the flag that indicates that we
// are waiting for the bundle to be received. The only reason this can happen is that the bundle was
// malformed somehow, so we don't want to keep retrying it.
await this.olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
}
return true;
@@ -401,8 +410,11 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Implementation of {@link CryptoBackend.markRoomAsPendingKeyBundle}.
*/
public markRoomAsPendingKeyBundle(roomId: string, inviter: string): void {
this.roomsPendingKeyBundles.set(roomId, inviter);
public async markRoomAsPendingKeyBundle(roomId: string, inviter: string): Promise<void> {
await this.olmMachine.storeRoomPendingKeyBundle(
new RustSdkCryptoJs.RoomId(roomId),
new RustSdkCryptoJs.UserId(inviter),
);
}
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@@ -724,7 +736,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
? userIdentity.identityNeedsUserApproval()
: false;
userIdentity.free();
return new UserVerificationStatus(verified, wasVerified, false, needsUserApproval);
return new UserVerificationStatus(verified, wasVerified, true, needsUserApproval);
}
/**
@@ -780,9 +792,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#getCrossSigningKeyId}
*/
public async getCrossSigningKeyId(type: CrossSigningKey = CrossSigningKey.Master): Promise<string | null> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
if (!userIdentity) {
// The public keys are not available on this device
return null;
@@ -1011,9 +1021,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#getCrossSigningStatus}
*/
public async getCrossSigningStatus(): Promise<CrossSigningStatus> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | null = await this.getOlmMachineOrThrow().getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
const publicKeysOnDevice =
Boolean(userIdentity?.masterKey) &&
@@ -1127,9 +1135,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Implementation of {@link CryptoApi#requestVerificationDM}
*/
public async requestVerificationDM(userId: string, roomId: string): Promise<VerificationRequest> {
const userIdentity: RustSdkCryptoJs.OtherUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(userId),
);
const userIdentity = (await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(userId))) as
| RustSdkCryptoJs.OtherUserIdentity
| undefined;
if (!userIdentity) throw new Error(`unknown userId ${userId}`);
@@ -1213,9 +1221,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @returns a VerificationRequest when the request has been sent to the other party.
*/
public async requestOwnUserVerification(): Promise<VerificationRequest> {
const userIdentity: RustSdkCryptoJs.OwnUserIdentity | undefined = await this.olmMachine.getIdentity(
new RustSdkCryptoJs.UserId(this.userId),
);
const userIdentity = await this.getOwnIdentity();
if (userIdentity === undefined) {
throw new Error("cannot request verification for this device when there is no existing cross-signing key");
}
@@ -1315,7 +1321,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(backupKey);
if (!decryptionKeyMatchesKeyBackupInfo(backupDecryptionKey, keyBackupInfo)) {
throw new Error("loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info");
throw new DecryptionKeyDoesNotMatchError(
"loadSessionBackupPrivateKeyFromSecretStorage: decryption key does not match backup info",
);
}
await this.backupManager.saveBackupDecryptionKey(backupDecryptionKey, keyBackupInfo.version);
@@ -1368,6 +1376,8 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
public async resetKeyBackup(): Promise<void> {
const backupInfo = await this.backupManager.setupKeyBackup((o) => this.signObject(o));
await this.pushSecretToVerifiedDevices("m.megolm_backup.v1");
// we want to store the private key in 4S
// need to check if 4S is set up?
if (await this.secretStorageHasAESKey()) {
@@ -1616,25 +1626,31 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
logger.info("Sharing message history");
// 1. Construct the key bundle
// 1. Download keys from backup.
if (!(await this.getOlmMachineOrThrow().hasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId)))) {
await this.backupManager.downloadLatestRoomKeyBackup(roomId);
await this.getOlmMachineOrThrow().setHasDownloadedAllRoomKeys(new RustSdkCryptoJs.RoomId(roomId));
}
// 2. Construct the key bundle
const bundle = await this.getOlmMachineOrThrow().buildRoomKeyBundle(new RustSdkCryptoJs.RoomId(roomId));
if (!bundle) {
logger.info("No keys to share");
return;
}
// 2. Upload the encrypted bundle to the server
// 3. Upload the encrypted bundle to the server
const uploadResponse = await this.http.uploadContent(bundle.encryptedData as Uint8Array<ArrayBuffer>);
logger.info(`Uploaded encrypted key blob: ${JSON.stringify(uploadResponse)}`);
// 3. We may not share a room with the user, so get a fresh list of devices for the invited user.
// 4. We may not share a room with the user, so get a fresh list of devices for the invited user.
const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(userId)]);
await this.outgoingRequestProcessor.makeOutgoingRequest(req);
// 4. Establish Olm sessions with all of the recipient's devices.
// 5. Establish Olm sessions with all of the recipient's devices.
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(userId)]);
// 5. Send to-device messages to the recipient to share the keys.
// 6. Send to-device messages to the recipient to share the keys.
const requests = await this.getOlmMachineOrThrow().shareRoomKeyBundleData(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.RoomId(roomId),
@@ -1717,34 +1733,37 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
},
});
// If we have received a room key bundle message, and have previously marked the room
// IDs it references as pending key bundles, tell the Rust SDK to try and accept it,
// just in case it was received after invite.
// If we have received a room key bundle message, and have recently joined the room in question,
// tell the Rust SDK to try and accept the key bundle.
//
// We don't actually need to validate the contents of the bundle message, or do
// anything with its contents at all. We simply want to inform the Rust SDK we have
// received a new room key bundle that we might be able to download.
if (
isRoomKeyBundleMessage(parsedMessage) &&
this.roomsPendingKeyBundles.has(parsedMessage.content.room_id)
) {
// No `await`-ing here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(
parsedMessage.content.room_id,
this.roomsPendingKeyBundles.get(parsedMessage.content.room_id)!,
).then(
(success) => {
if (success) {
this.roomsPendingKeyBundles.delete(parsedMessage.content.room_id);
}
},
(err) => {
this.logger.error(
`Error attempting to download key bundle for room ${parsedMessage.content.room_id}`,
);
this.logger.error(err);
},
if (isRoomKeyBundleMessage(parsedMessage)) {
const roomId = parsedMessage.content.room_id;
const pendingDetails = await this.olmMachine.getPendingKeyBundleDetailsForRoom(
new RustSdkCryptoJs.RoomId(roomId),
);
// Only accept the key bundle if we joined the room less than 24 hours ago.
if (!pendingDetails) {
this.logger.debug(
`Not yet accepting key bundle for room where we are not awaiting a bundle: ${roomId}`,
);
} else if (
Date.now() - pendingDetails.inviteAcceptedAtMillis >
MAX_INVITE_ACCEPTANCE_MS_FOR_KEY_BUNDLE
) {
this.logger.info(
`Ignoring key bundle for room we joined too long ago: ${roomId}, joining time: ${new Date(pendingDetails.inviteAcceptedAtMillis).toISOString()}`,
);
} else {
this.logger.info(`Considering key bundle for recently-joined room ${roomId}`);
// Don't block for the import to happen, here, as this is called from inside the `/sync` loop.
this.maybeAcceptKeyBundle(roomId, pendingDetails.inviterId.toString()).catch((err) => {
this.logger.error(`Error attempting to download key bundle for room ${roomId}`);
this.logger.error(err);
});
}
}
break;
@@ -1917,7 +1936,21 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @param oldMembership - The previous membership state. Null if it's a new member.
*/
public onRoomMembership(event: MatrixEvent, member: RoomMember, oldMembership?: string): void {
const enc = this.roomEncryptors[event.getRoomId()!];
const roomId = event.getRoomId()!;
// If it's our own membership, and we are no longer joined, clear any indication that we are waiting for a key
// bundle.
if (
oldMembership === KnownMembership.Join &&
member.membership !== KnownMembership.Join &&
member.userId === this.olmMachine.userId.toString()
) {
this.olmMachine.clearRoomPendingKeyBundle(new RustSdkCryptoJs.RoomId(roomId)).catch((e) => {
this.logger.error(`Error clearing room pending key bundle indicator for ${roomId}: ${e}`);
});
}
const enc = this.roomEncryptors[roomId];
if (!enc) {
// not encrypting in this room
return;
@@ -1925,6 +1958,44 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
enc.onRoomMembership(member);
}
/**
* Previously, it was sufficient to check if we need to rotate the room key
* prior to sending a message. However, the history sharing feature
* (MSC4268) breaks this logic:
*
* 1. Alice sends a message M1 in room X;
* 2. Bob invites Charlie, who joins and immediately leaves the room;
* 3. Alice sends another message M2 in room X.
*
* Under the old logic, Alice would not rotate her key after Charlie
* leaves, resulting in M2 being encrypted with the same session as M1.
* This would allow Charlie to decrypt M2 if he ever gains access to
* the event.
*
* To counter this, we proactively discard any active outgoing Megolm
* session when we see an event indicating the user left.
*
* Note that we have to do this in `onRoomStateEvent` rather than
* `onRoomMembership`, because `onRoomMembership` is only called when we see
* a *change* in membership. In the case of a gappy sync, we might miss
* Charlie's invite and join, and only see the final `leave` event (so his
* membership goes from `leave` to `leave`).
*/
public onRoomStateEvent(event: MatrixEvent, _state: RoomState, _prevEvent: MatrixEvent | null): void {
if (event.getType() != EventType.RoomMember) {
// Ignore all events that aren't member updates.
return;
}
if (
event.getStateKey()! !== this.olmMachine.userId.toString() &&
event.getContent().membership !== KnownMembership.Join
) {
this.logger.info(`Rotating session for room ${event.getRoomId()} due to member leaving the room`);
this.forceDiscardSession(event.getRoomId()!);
}
}
/** Callback for OlmMachine.registerRoomKeyUpdatedCallback
*
* Called by the rust-sdk whenever there is an update to (megolm) room keys. We
@@ -2035,9 +2106,9 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
/**
* Handles secret received from the rust secret inbox.
*
* The gossipped secrets are received using the `m.secret.send` event type
* and are guaranteed to have been received over a 1-to-1 Olm
* Session from a verified device.
* The gossipped secrets are received using the `m.secret.send` or
* `io.element.msc4385.secret.push` event types and are guaranteed to have
* been received over a 1-to-1 Olm Session from a verified device.
*
* The only secret currently handled in this way is `m.megolm_backup.v1`.
*
@@ -2180,7 +2251,22 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* Used during migration from legacy js-crypto to update local trust if needed.
*/
public async getOwnIdentity(): Promise<RustSdkCryptoJs.OwnUserIdentity | undefined> {
return await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId));
const identity = (await this.getOlmMachineOrThrow().getIdentity(new RustSdkCryptoJs.UserId(this.userId))) as
| RustSdkCryptoJs.OwnUserIdentity
| undefined;
return identity;
}
/**
* Push a secret to all of the current user's verified devices.
*/
public async pushSecretToVerifiedDevices(name: string): Promise<void> {
const logger = new LogSpan(this.logger, "pushSecretToVerifiedDevices");
await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(this.userId)]);
await this.olmMachine.pushSecretToVerifiedDevices(name);
this.outgoingRequestsManager.doProcessOutgoingRequests().catch((e) => {
logger.warn("pushSecretToVerifiedDevices: Error processing outgoing requests", e);
});
}
}
@@ -2510,7 +2596,7 @@ function rustEncryptionInfoToJsEncryptionInfo(
}
interface RoomKeyBundleMessage {
type: "io.element.msc4268.room_key_bundle";
type: "m.room_key_bundle" | "io.element.msc4268.room_key_bundle";
content: {
room_id: string;
};
@@ -2520,13 +2606,16 @@ interface RoomKeyBundleMessage {
* Determines if the given payload is a RoomKeyBundleMessage.
*
* A RoomKeyBundleMessage is identified by having a specific message type
* ("io.element.msc4268.room_key_bundle") and a valid room_id in its content.
* ("m.room_key_bundle") and a valid room_id in its content.
*
* @param message - The received to-device message to check.
* @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise.
*/
function isRoomKeyBundleMessage(message: IToDeviceEvent): message is IToDeviceEvent & RoomKeyBundleMessage {
return message.type === "io.element.msc4268.room_key_bundle" && typeof message.content.room_id === "string";
return (
(message.type === "io.element.msc4268.room_key_bundle" || message.type === "m.room_key_bundle") &&
typeof message.content.room_id === "string"
);
}
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
+2 -1
View File
@@ -61,6 +61,7 @@ const VERSION = DB_MIGRATIONS.length;
* Return the data you want to keep.
* @returns Promise which resolves to an array of whatever you returned from
* resultMapper.
* @throws If there was an error completing the query.
*/
function selectQuery<T>(
store: IDBObjectStore,
@@ -71,7 +72,7 @@ function selectQuery<T>(
return new Promise((resolve, reject) => {
const results: T[] = [];
query.onerror = (): void => {
reject(new Error("Query failed: " + query.error?.name));
reject(new Error(`selectQuery failed for ${store.name}`, { cause: query.error }));
};
// collect results
query.onsuccess = (): void => {

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