Compare commits

...

76 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
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
42 changed files with 2602 additions and 1212 deletions
@@ -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 }}
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
deployments: write
steps:
- name: 📥 Download artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
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 # zizmor: ignore[unpinned-uses]
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
+1 -1
View File
@@ -18,7 +18,7 @@ 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
- 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 }}
+4 -4
View File
@@ -18,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: |
@@ -38,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: |
@@ -63,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({
@@ -84,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:
@@ -22,8 +22,8 @@ jobs:
fetch-depth: 0
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- 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: "pnpm"
@@ -50,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 }}
+2 -2
View File
@@ -38,8 +38,8 @@ jobs:
sparse-checkout: |
scripts/release
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
+23 -16
View File
@@ -33,10 +33,14 @@ on:
description: The number of expected assets, including signatures, excluding generated zip & tarball.
type: number
required: false
dir:
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"
@@ -96,7 +100,7 @@ jobs:
- name: Prepare variables
id: prepare
working-directory: ${{ inputs.dir }}
working-directory: ${{ inputs.dist-dir }}
run: |
echo "VERSION=$VERSION" >> $GITHUB_ENV
@@ -111,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;
@@ -130,17 +134,17 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: ${{ inputs.dir }}/package.json
node-version-file: ${{ inputs.dist-dir }}/package.json
- name: Install dependencies
run: "pnpm install --frozen-lockfile"
- name: Handle develop dependencies
working-directory: ${{ inputs.dir }}
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
@@ -154,11 +158,14 @@ jobs:
git commit -m "Keep $PACKAGE at $VERSION"
done
- name: Bump package.json version
working-directory: ${{ inputs.dir }}
- name: Bump package.json versions
run: |
pnpm version --no-git-tag-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
@@ -185,7 +192,7 @@ jobs:
- name: Build assets
if: steps.prepare.outputs.has-dist-script == '1'
working-directory: ${{ inputs.dir }}
working-directory: ${{ inputs.dist-dir }}
run: DIST_VERSION="$VERSION" pnpm dist
- name: Upload release assets & signatures
@@ -194,7 +201,7 @@ jobs:
with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.draft-release.outputs.upload_url }}
asset-path: ${{ inputs.dir }}/${{ inputs.asset-path }}
asset-path: ${{ inputs.dist-dir }}/${{ inputs.asset-path }}
- name: Create signed tag
if: inputs.gpg-fingerprint
@@ -227,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 }}
@@ -255,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 }}
@@ -289,7 +296,7 @@ jobs:
if: inputs.npm
uses: matrix-org/matrix-js-sdk/.github/workflows/release-npm.yml@develop # zizmor: ignore[unpinned-uses]
with:
dir: ${{ inputs.dir }}
dir: ${{ inputs.dist-dir }}
permissions:
contents: read
id-token: write
+2 -2
View File
@@ -27,9 +27,9 @@ jobs:
ref: staging
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
+6 -6
View File
@@ -52,8 +52,8 @@ jobs:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
persist-credentials: true
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version: "lts/*"
@@ -81,9 +81,9 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -95,7 +95,7 @@ jobs:
run: pnpm gendoc
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: _docs
@@ -113,4 +113,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5
+8 -5
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:
@@ -44,7 +48,7 @@ jobs:
persist-credentials: false
- name: 📥 Download artifact
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -52,14 +56,13 @@ jobs:
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8
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
@@ -76,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:
@@ -84,7 +87,7 @@ 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 }}
+16 -16
View File
@@ -18,8 +18,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -38,8 +38,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -58,8 +58,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -92,8 +92,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -105,7 +105,7 @@ jobs:
run: "pnpm lint:workflows"
- name: Run zizmor
uses: zizmorcore/zizmor-action@0dce2577a4760a2749d8cfb7a84b7d5585ebcb7d # v0.5.0
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
docs:
name: "JSDoc Checker"
@@ -115,8 +115,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -128,7 +128,7 @@ jobs:
run: "pnpm run gendoc --treatWarningsAsErrors --suppressCommentWarningsInDeclarationFiles"
- name: Upload Artifact
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: docs
path: _docs
@@ -143,8 +143,8 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -165,8 +165,8 @@ jobs:
repository: element-hq/element-web
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
cache: "pnpm"
node-version: "lts/*"
+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@dac99c67f08f8f2a079e885ffb682a2f39cd3960
uses: element-hq/element-meta/.github/workflows/sync-labels.yml@7f2f93fb9b52ece7a0998f60e64862aa203c1746
with:
LABELS: |
element-hq/element-meta
+3 -3
View File
@@ -26,10 +26,10 @@ jobs:
with:
persist-credentials: false
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 # v4
- 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: "pnpm"
node-version: ${{ matrix.node }}
@@ -59,7 +59,7 @@ jobs:
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
+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@2c9f55cbea90702aa61b9304afa9cbf940efa14f
uses: element-hq/element-web/.github/workflows/triage-labelled.yml@6339bcda15c71d209303b18a06a9b1c021220bf9
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
+7
View File
@@ -1,3 +1,10 @@
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
+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`
+17 -14
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "41.2.0",
"version": "41.3.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -18,8 +18,8 @@
"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",
"test": "vitest run",
"test:watch": "vitest watch",
"coverage": "pnpm test --coverage"
},
"repository": {
@@ -48,7 +48,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^18.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",
@@ -57,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",
@@ -106,21 +105,18 @@
"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.1",
"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": {
@@ -129,7 +125,14 @@
},
"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.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}
+1466 -944
View File
File diff suppressed because it is too large Load Diff
+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);
});
});
});
+532 -135
View File
@@ -51,6 +51,78 @@ import {
const debug = mkDebug("matrix-js-sdk:history-sharing");
interface TestClient {
client: MatrixClient;
userId: string;
homeserverUrl: string;
keyResponder: E2EKeyResponder;
keyReceiver: E2EKeyReceiver;
keyClaimResponder: E2EOTKClaimResponder;
syncResponder: SyncResponder;
}
// Add to this array to allow for more testing clients.
const TEST_USER_IDS = [TEST_USER_ID, "@bob:xyz", "@charlie:zyx"];
let activeClients: TestClient[] = [];
/**
* Sets up a number of test clients.
* @param n - The total number of clients.
* @param options
* @returns
*/
async function setupClients(n: number, options = { setupNewCrossSigning: true }): Promise<TestClient[]> {
if (n > TEST_USER_IDS.length) {
throw new Error("Requested more clients than configured - add to TEST_USER_IDS");
}
mockSetupCrossSigningRequests();
const clients = Array.from({ length: n }).map((_, i) => {
const userId = TEST_USER_IDS[i];
const routePrefix = `${userId.split(":")[0].slice(1)}-`; // e.g. @alice:example.com -> alice-
const homeserverUrl = `https://${routePrefix}server.com`; // e.g. @alice:example.com -> https://alice-homeserver.com
return {
client: createClient({
baseUrl: homeserverUrl,
userId: userId,
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
logger: new DebugLogger(mkDebug(`matrix-js-sdk:${userId}`)),
}),
userId,
homeserverUrl,
keyReceiver: new E2EKeyReceiver(homeserverUrl, routePrefix),
keyResponder: new E2EKeyResponder(homeserverUrl),
keyClaimResponder: new E2EOTKClaimResponder(homeserverUrl),
syncResponder: new SyncResponder(homeserverUrl),
};
});
// Add all combinations of key receivers to key (claim) responders.
for (const { keyResponder: lhsKeyResponder, keyClaimResponder: lhsKeyClaimResponder } of clients) {
for (const { userId: lhsUserId, keyReceiver: rhsKeyReceiver, client: lhsClient } of clients) {
lhsKeyResponder.addKeyReceiver(lhsUserId, rhsKeyReceiver);
lhsKeyClaimResponder.addKeyReceiver(lhsUserId, lhsClient.deviceId!, rhsKeyReceiver);
}
}
// Start all the clients.
for (const { userId, homeserverUrl, client, syncResponder } of clients) {
mockInitialApiRequests(homeserverUrl, userId);
await client.initRustCrypto({ cryptoDatabasePrefix: userId });
await client.startClient();
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: options.setupNewCrossSigning });
syncResponder.sendOrQueueSyncResponse({});
await syncPromise(client);
}
activeClients = clients;
return activeClients;
}
// load the rust library. This can take a few seconds on a slow GH worker.
beforeAll(async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
@@ -58,16 +130,19 @@ beforeAll(async () => {
await RustSdkCryptoJs.initAsync();
}, 10000);
afterEach(() => {
afterEach(async () => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
// Stop and clear the active clients
activeClients.forEach(({ client }) => client.stopClient());
await flushPromises();
activeClients = [];
});
const ROOM_ID = "!room:example.com";
const ALICE_HOMESERVER_URL = "https://alice-server.com";
const BOB_HOMESERVER_URL = "https://bob-server.com";
async function createAndInitClient(homeserverUrl: string, userId: string, setupNewCrossSigning = true) {
mockInitialApiRequests(homeserverUrl, userId);
@@ -87,60 +162,24 @@ async function createAndInitClient(homeserverUrl: string, userId: string, setupN
}
describe("History Sharing", () => {
let aliceClient: MatrixClient;
let aliceSyncResponder: SyncResponder;
let bobClient: MatrixClient;
let bobSyncResponder: SyncResponder;
beforeEach(async () => {
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
mockSetupCrossSigningRequests();
const aliceId = TEST_USER_ID;
const bobId = "@bob:xyz";
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL);
const aliceKeyClaimResponder = new E2EOTKClaimResponder(ALICE_HOMESERVER_URL);
aliceSyncResponder = new SyncResponder(ALICE_HOMESERVER_URL);
const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-");
const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL);
bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL);
aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId);
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId);
aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver);
aliceSyncResponder.sendOrQueueSyncResponse({});
await syncPromise(aliceClient);
bobSyncResponder.sendOrQueueSyncResponse({});
await syncPromise(bobClient);
});
test("Room keys are successfully shared on invite", async () => {
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
const msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Alice invites Bob, and shares the room history with them.
await assertInviteAndShareHistory(ROOM_ID);
await assertInviteAndShareHistory(alice, bob, ROOM_ID);
// Bob receives, should be able to decrypt, the megolm message
const event = await bobReceivesEvent(
@@ -166,22 +205,26 @@ describe("History Sharing", () => {
});
test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => {
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
const msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
const uploadProm = expectUploadRequest(alice);
const toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
@@ -201,7 +244,7 @@ describe("History Sharing", () => {
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
fetchMock.postOnce(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
@@ -223,7 +266,7 @@ describe("History Sharing", () => {
expect(event.isDecryptionFailure()).toBeTruthy();
// Now the room key bundle message arrives
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobSyncResponder.sendOrQueueSyncResponse({
@@ -255,22 +298,26 @@ describe("History Sharing", () => {
test("Room keys are not imported if the bundle arrives more than 24H after the invite is accepted", async () => {
vitest.useFakeTimers();
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
const msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
const uploadProm = expectUploadRequest(alice);
const toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
@@ -290,7 +337,7 @@ describe("History Sharing", () => {
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
fetchMock.postOnce(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
@@ -314,7 +361,7 @@ describe("History Sharing", () => {
// 24 hours elapse before the room key bundle message arrives.
vitest.advanceTimersByTime(24 * 60 * 60 * 1000 + 1);
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobSyncResponder.sendOrQueueSyncResponse({
@@ -339,22 +386,26 @@ describe("History Sharing", () => {
});
test("Room keys are not imported if we left and rejoined the room after accepting the invite", async () => {
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
const msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Now, Alice invites Bob
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
const uploadProm = expectUploadRequest(alice);
const toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
@@ -374,7 +425,7 @@ describe("History Sharing", () => {
const room = bobClient.getRoom(ROOM_ID);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.post(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
fetchMock.post(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
@@ -430,7 +481,7 @@ describe("History Sharing", () => {
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Now the bundle arrives
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobSyncResponder.sendOrQueueSyncResponse({
@@ -453,6 +504,10 @@ describe("History Sharing", () => {
});
test("Room keys are downloaded from key backup before inviting", async () => {
const [alice, bob] = await setupClients(2);
const { client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { client: bobClient, syncResponder: bobSyncResponder } = bob;
// Set up backup, and ignore requests to send room key requests
fetchMock.get("path:/_matrix/client/v3/room_keys/version", SIGNED_BACKUP_DATA);
fetchMock.get(
@@ -475,7 +530,7 @@ describe("History Sharing", () => {
await syncPromise(aliceClient);
// Alice invites Bob, and shares the room history with them.
await assertInviteAndShareHistory(TEST_ROOM_ID);
await assertInviteAndShareHistory(alice, bob, TEST_ROOM_ID);
// Bob receives, and should be able to decrypt, the historical message
const event = await bobReceivesEvent(aliceClient, bobClient, ENCRYPTED_EVENT as any, bobSyncResponder);
@@ -489,21 +544,25 @@ describe("History Sharing", () => {
});
test("Room keys are successfully imported, if the app is shut down while the import is in progress", async () => {
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
const msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
const sentMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
// Alice invites Bob, and shares the room history with him.
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
const uploadProm = expectUploadRequest(alice);
const toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
@@ -530,13 +589,13 @@ describe("History Sharing", () => {
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
fetchMock.postOnce(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
// Have the /download request block indefinitely
const downloadStarted = Promise.withResolvers<void>();
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, () => {
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, () => {
downloadStarted.resolve();
return new Promise(() => {});
});
@@ -549,15 +608,15 @@ describe("History Sharing", () => {
bobSyncResponder.sendOrQueueSyncResponse({});
await flushPromises();
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobClient.getSafeUserId(), false);
const bobNewClient = await createAndInitClient(bobHomeserverUrl, bobClient.getSafeUserId(), false);
// Now, Bob receives the megolm message, and can decrypt it
const event = await bobReceivesEvent(
aliceClient,
bobClient,
bobNewClient,
mkEventCustom({
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
@@ -567,24 +626,31 @@ describe("History Sharing", () => {
}),
bobSyncResponder,
);
expect(event.getId()).toEqual("$event_id");
await event.getDecryptionPromise();
expect(event.getId()).toEqual("$event_id");
expect(event.getType()).toEqual("m.room.message");
expect(event.getContent().body).toEqual("Hi!");
expect(event.getKeyForwardingUser()).toEqual(aliceClient.getUserId());
const encryptionInfo = await bobClient.getCrypto()!.getEncryptionInfoForEvent(event);
const encryptionInfo = await bobNewClient.getCrypto()!.getEncryptionInfoForEvent(event);
expect(encryptionInfo?.shieldColour).toEqual(EventShieldColour.GREY);
expect(encryptionInfo?.shieldReason).toEqual(EventShieldReason.AUTHENTICITY_NOT_GUARANTEED);
// We need to stop Bob's new client manually, since it isn't tracked by `setupClients`.
bobNewClient.stopClient();
});
test("Room keys are not shared if the current history visibility is unshared", async () => {
const [alice, bob] = await setupClients(2);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
// Alice is in an encrypted room
let syncResponse = getSyncResponse([aliceClient.getSafeUserId()], HistoryVisibility.Shared, ROOM_ID);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
// ... and she sends an event
let msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
let msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Sent when shared",
@@ -598,7 +664,7 @@ describe("History Sharing", () => {
await syncPromise(aliceClient);
/// ... and sends a second message.
msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
msgProm = expectSendRoomEvent(aliceHomeserverUrl, "m.room.encrypted");
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Sent when invited",
@@ -606,7 +672,7 @@ describe("History Sharing", () => {
const secondMessage = await msgProm;
debug(`Alice sent encrypted room event: ${JSON.stringify(secondMessage)}`);
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const inviteEvent = mkEventCustom({
@@ -627,7 +693,7 @@ describe("History Sharing", () => {
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
fetchMock.postOnce(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
@@ -658,85 +724,416 @@ describe("History Sharing", () => {
// Assert alice never uploaded the key bundle ...
expect(
fetchMock.callHistory.called(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString()),
fetchMock.callHistory.called(new URL("/_matrix/media/v3/upload", aliceHomeserverUrl).toString()),
).toBeFalsy();
// ... didn't send Bob the key bundle info ...
expect(
fetchMock.callHistory.called(
new RegExp(
`^${escapeRegExp(ALICE_HOMESERVER_URL)}/_matrix/client/v3/sendToDevice/${escapeRegExp("m.room.encrypted")}/`,
`^${escapeRegExp(aliceHomeserverUrl)}/_matrix/client/v3/sendToDevice/${escapeRegExp("m.room.encrypted")}/`,
),
),
).toBeFalsy();
// ... and Bob didn't try to download the key bundle.
expect(
fetchMock.callHistory.called(
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`,
),
).toBeFalsy();
});
afterEach(async () => {
vitest.useRealTimers();
bobClient.stopClient();
aliceClient.stopClient();
await flushPromises();
});
test.each([
{ gappySync: false, leftState: KnownMembership.Ban },
{ gappySync: false, leftState: KnownMembership.Invite },
{ gappySync: false, leftState: KnownMembership.Knock },
{ gappySync: false, leftState: KnownMembership.Leave },
{ gappySync: true, leftState: KnownMembership.Ban },
{ gappySync: true, leftState: KnownMembership.Invite },
{ gappySync: true, leftState: KnownMembership.Knock },
{ gappySync: true, leftState: KnownMembership.Leave },
])(
"Room key is rotated after a member joins and leaves the room (%s)",
async (config) => {
const [alice, bob, charlie] = await setupClients(3);
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient, syncResponder: aliceSyncResponder } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
const {
homeserverUrl: charlieHomeserverUrl,
client: charlieClient,
syncResponder: charlieSyncResponder,
} = charlie;
/**
* Helper function to automatically test that room history is shared on invite.
* The function performs the following:
*
* 1. Sets up the relevant fetchMock and to-device event listeners for Alice.
* 2. Alice invites Bob to the room.
* 3. Checks the key bundle was uploaded and that the `m.room_key_bundle`
* to-device message was sent.
* 4. Sends the invite event to Bob and ensures it is processed correctly.
* 5. Sets up the relevant fetchMock listeners for Bob.
* 5. Simulates Bob joining the room and verifies that the room history is shared.
*
* @param roomId The ID of the room where the invite and history sharing will be tested.
*/
async function assertInviteAndShareHistory(roomId: string): Promise<void> {
const uploadProm = expectUploadRequest();
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {});
await aliceClient.invite(roomId, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
// Alice and Bob are in an encrypted room
let syncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
bobSyncResponder.sendOrQueueSyncResponse(syncResponse);
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [roomId]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
await syncPromise(aliceClient);
await syncPromise(bobClient);
// Bob sends a message M1, which both he and Alice receive.
let msgProm = expectSendRoomEvent(bobHomeserverUrl, "m.room.encrypted");
let toDeviceMessageProm = expectSendToDeviceMessage(bobHomeserverUrl, "m.room.encrypted");
await bobClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Charlie should be able to read",
});
const bobEventM1Content = await msgProm;
let sentToDeviceRequest = await toDeviceMessageProm;
expect(sentToDeviceRequest).toBeDefined();
let aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!];
// Alice receives the message down sync.
syncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: bobEventM1Content,
event_id: "$event_id_m1",
}) as any,
);
syncResponse.to_device = {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
sender: bobClient.getSafeUserId(),
content: aliceToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
};
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
const room = bobClient.getRoom(roomId);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
// Alice checks she can read M1.
const aliceRoom = aliceClient.getRoom(ROOM_ID);
const aliceM1 = aliceRoom!.getLastLiveEvent()!;
await aliceM1.getDecryptionPromise();
expect(aliceM1.getType()).toEqual("m.room.message");
expect(aliceM1.getContent().body).toEqual("Charlie should be able to read");
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, {
room_id: roomId,
});
fetchMock.getOnce(`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
await bobClient.joinRoom(roomId, { acceptSharedHistory: true });
}
// Alice invites and sends a key bundle to Charlie
const uploadProm = expectUploadRequest(alice);
toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
fetchMock.postOnce(
`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`,
{},
);
await aliceClient.invite(ROOM_ID, charlieClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const charlieToDeviceMessage = sentToDeviceRequest[charlieClient.getSafeUserId()][charlieClient.deviceId!];
expect(charlieToDeviceMessage).toBeDefined();
/// Charlie receives the invite ...
const inviteEvent = mkInviteEvent(aliceClient, charlieClient);
charlieSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: charlieToDeviceMessage,
},
],
},
});
await syncPromise(charlieClient);
const charlieRoom = charlieClient.getRoom(ROOM_ID);
expect(charlieRoom).toBeTruthy();
expect(charlieRoom?.getMyMembership()).toEqual(KnownMembership.Invite);
// ... and subsequently joins.
fetchMock.postOnce(`${charlieHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
fetchMock.getOnce(`begin:${charlieHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
await charlieClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
// Charlie syncs to receive M1 and ensure he can read it.
syncResponse = getSyncResponse(
[aliceClient.getSafeUserId(), bobClient.getSafeUserId(), charlieClient.getSafeUserId()],
HistoryVisibility.Shared,
ROOM_ID,
);
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: bobEventM1Content,
event_id: "$event_id_m1",
}) as any,
);
charlieSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(charlieClient);
const charlieEventM1 = charlieRoom!
.getLiveTimeline()
.getEvents()
.find((e) => e.getId() === "$event_id_m1");
await charlieEventM1!.getDecryptionPromise();
expect(charlieEventM1!.getType()).toEqual("m.room.message");
expect(charlieEventM1!.getContent().body).toEqual("Charlie should be able to read");
// Charlie then immediately leaves.
const charlieSyncResponse = {
next_batch: "1",
rooms: {
leave: {
[ROOM_ID]: {
state: { events: [] },
timeline: {
events: [
mkEventCustom({
content: { membership: config.leftState },
type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(),
}),
],
prev_batch: "",
},
account_data: { events: [] },
},
},
invite: {},
join: {},
knock: {},
},
account_data: { events: [] },
};
charlieSyncResponder.sendOrQueueSyncResponse(charlieSyncResponse);
await syncPromise(charlieClient);
syncResponse = {
next_batch: "2",
rooms: {
join: {
[ROOM_ID]: {
timeline: {
events: [],
},
},
},
},
} as any;
if (config.gappySync) {
// In case of a gappy sync, the timeline is limited and we only see the leave event.
syncResponse.rooms.join[ROOM_ID].timeline.limited = true;
syncResponse.rooms.join[ROOM_ID].state = {
events: [
mkEventCustom({
content: { membership: config.leftState },
type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(),
}) as any,
],
};
} else {
syncResponse.rooms.join[ROOM_ID].timeline.events.push(
mkEventCustom({
content: { membership: KnownMembership.Join },
type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(),
}) as any,
mkEventCustom({
content: { membership: config.leftState },
type: EventType.RoomMember,
sender: charlieClient.getSafeUserId(),
state_key: charlieClient.getSafeUserId(),
}) as any,
);
}
// Bob syncs to learn about Charlie's leaving (and joining if non-gappy).
bobSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(bobClient);
// Bob then sends M2, sharing a new room key with Alice.
msgProm = expectSendRoomEvent(bobHomeserverUrl, "m.room.encrypted");
toDeviceMessageProm = expectSendToDeviceMessage(bobHomeserverUrl, "m.room.encrypted");
await bobClient.sendEvent(ROOM_ID, EventType.RoomMessage, {
msgtype: MsgType.Text,
body: "Charlie should not be able to read",
});
const bobEventM2Content = await msgProm;
sentToDeviceRequest = await toDeviceMessageProm;
expect(sentToDeviceRequest).toBeDefined();
aliceToDeviceMessage = sentToDeviceRequest[aliceClient.getSafeUserId()][aliceClient.deviceId!];
// Charlie should not receive the room key, but may receive a
// different to-device message if he is invited.
const charliesToDevice = sentToDeviceRequest[charlieClient.getSafeUserId()];
expect(charliesToDevice === undefined || config.leftState === KnownMembership.Invite).toBeTruthy();
debug(`Bob sent encrypted room event: ${JSON.stringify(bobEventM2Content)}`);
// Sync the message to Alice along with the to-device message, and check she can decrypt it.
syncResponse = {
next_batch: "3",
rooms: {
join: {
[ROOM_ID]: {
timeline: {
events: [
mkEventCustom({
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: bobEventM2Content,
event_id: "$event_id_m2",
}) as any,
],
},
},
},
},
to_device: {
events: [
{
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: aliceToDeviceMessage,
},
],
},
} as any;
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(aliceClient);
const aliceEventM2 = aliceRoom!.getLastLiveEvent()!;
await aliceEventM2.getDecryptionPromise();
expect(aliceEventM2.getType()).toEqual("m.room.message");
expect(aliceEventM2.getContent().body).toEqual("Charlie should not be able to read");
// Charlie rejoins the room by ID, receives any supplied to-device
// messages, and receives M2, which he should not be able to
// decrypt. This proves that any to-device message he received was
// not the room key.
fetchMock.postOnce(`${charlieHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
room_id: ROOM_ID,
});
await charlieClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
syncResponse = {
next_batch: "4",
rooms: {
join: {
[ROOM_ID]: {
timeline: {
events: [
mkEventCustom({
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: bobEventM2Content,
event_id: "$event_id_m2",
}) as any,
],
},
},
},
},
to_device: {
events: [
{
type: "m.room.encrypted",
sender: bobClient.getSafeUserId(),
content: charliesToDevice,
},
],
},
} as any;
charlieSyncResponder.sendOrQueueSyncResponse(syncResponse);
await syncPromise(charlieClient);
const events = charlieRoom!.getLiveTimeline().getEvents();
expect(events.length).toBeGreaterThanOrEqual(2);
const charlieM2 = charlieRoom!
.getLiveTimeline()
.getEvents()
.find((e) => e.getId() === "$event_id_m2");
await charlieM2!.getDecryptionPromise();
expect(charlieM2!.isDecryptionFailure()).toBeTruthy();
},
60e3,
);
afterEach(async () => {
vitest.useRealTimers();
});
});
/**
* Helper function to automatically test that room history is shared on invite.
* The function performs the following:
*
* 1. Sets up the relevant fetchMock and to-device event listeners for Alice.
* 2. Alice invites Bob to the room.
* 3. Checks the key bundle was uploaded and that the `m.room_key_bundle`
* to-device message was sent.
* 4. Sends the invite event to Bob and ensures it is processed correctly.
* 5. Sets up the relevant fetchMock listeners for Bob.
* 5. Simulates Bob joining the room and verifies that the room history is shared.
*
* @param roomId The ID of the room where the invite and history sharing will be tested.
*/
async function assertInviteAndShareHistory(alice: TestClient, bob: TestClient, roomId: string): Promise<void> {
const { homeserverUrl: aliceHomeserverUrl, client: aliceClient } = alice;
const { homeserverUrl: bobHomeserverUrl, client: bobClient, syncResponder: bobSyncResponder } = bob;
const uploadProm = expectUploadRequest(alice);
const toDeviceMessageProm = expectSendToDeviceMessage(aliceHomeserverUrl, "m.room.encrypted");
fetchMock.postOnce(`${aliceHomeserverUrl}/_matrix/client/v3/rooms/${encodeURIComponent(roomId)}/invite`, {});
await aliceClient.invite(roomId, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
const uploadedBlob = await uploadProm;
const sentToDeviceRequest = await toDeviceMessageProm;
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
expect(bobToDeviceMessage).toBeDefined();
const inviteEvent = mkInviteEvent(aliceClient, bobClient);
bobSyncResponder.sendOrQueueSyncResponse({
rooms: { invite: { [roomId]: { invite_state: { events: [inviteEvent] } } } },
to_device: {
events: [
{
type: "m.room.encrypted",
sender: aliceClient.getSafeUserId(),
content: bobToDeviceMessage,
},
],
},
});
await syncPromise(bobClient);
const room = bobClient.getRoom(roomId);
expect(room).toBeTruthy();
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
fetchMock.postOnce(`${bobHomeserverUrl}/_matrix/client/v3/join/${encodeURIComponent(roomId)}`, {
room_id: roomId,
});
fetchMock.getOnce(`begin:${bobHomeserverUrl}/_matrix/client/v1/media/download/alice-server/here`, {
body: uploadedBlob,
});
await bobClient.joinRoom(roomId, { acceptSharedHistory: true });
}
function mkInviteEvent(inviter: MatrixClient, recipient: MatrixClient): Partial<IRoomEvent> {
return mkEventCustom({
type: "m.room.member",
@@ -763,11 +1160,11 @@ function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IC
}
/** Expect an upload to Alice's server. Returns a Promise that resolves when the upload is complete. */
function expectUploadRequest(): Promise<Uint8Array> {
function expectUploadRequest({ userId, homeserverUrl }: TestClient): Promise<Uint8Array> {
return new Promise<Uint8Array>((resolve) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (callLog) => {
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", homeserverUrl).toString(), (callLog) => {
const body = callLog.options.body as Uint8Array;
debug(`Alice uploaded blob of length ${body.length}`);
debug(`${userId} uploaded blob of length ${body.length}`);
resolve(body);
return { content_uri: "mxc://alice-server/here" };
});
@@ -279,7 +279,7 @@ 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.
*/
@@ -422,7 +422,7 @@ def build_exported_megolm_key(device_curve_key: x25519.X25519PrivateKey) -> tupl
"ed25519": encode_base64(ed25519.Ed25519PrivateKey.from_private_bytes(randbytes(32)).public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)),
},
"forwarding_curve25519_key_chain": [],
"org.matrix.msc3061.shared_history": True,
"m.shared_history": True,
}
return megolm_export, private_key
+25
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", () => {
+1 -1
View File
@@ -427,7 +427,7 @@ describe("CallMembership", () => {
});
it("uses unpadded base64 for RTC backend identities", async () => {
const membership = await CallMembership.parseFromEvent(makeMockEvent(0, { ...membershipTemplate }));
expect(membership.rtcBackendIdentity).toBe("j9N1u04ZbvI9qKf3cxrf2NauD-fIGJ4uAcYkfI9V7SY");
expect(membership.rtcBackendIdentity).toBe("jUZ0Q1yF5nV3LlAI5xfD1I7BPnAytJaPEAR57EXjJ6s");
});
});
});
@@ -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");
});
});
+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");
+71
View File
@@ -592,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>(), {
@@ -790,6 +856,7 @@ describe("RustCrypto", () => {
undefined,
secretStorage,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -837,6 +904,7 @@ describe("RustCrypto", () => {
{} as CryptoCallbacks,
false,
);
vi.spyOn(rustCrypto, "pushSecretToVerifiedDevices").mockResolvedValue();
async function createSecretStorageKey() {
return {
@@ -1526,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();
@@ -1540,6 +1609,7 @@ describe("RustCrypto", () => {
} 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();
@@ -2322,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());
+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 -2
View File
@@ -416,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;
@@ -428,6 +431,15 @@ export interface AccountDataEvents extends SecretStorageAccountDataEvents {
[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;
}>;
};
}
/**
+2 -1
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,
@@ -2027,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);
});
+36 -5
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.
@@ -626,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>;
@@ -794,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;
@@ -820,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;
@@ -851,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.
*
@@ -866,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;
}
@@ -903,7 +934,7 @@ export class UserVerificationStatus {
* @deprecated No longer supported, with the Rust crypto stack.
*/
public isTofu(): boolean {
return this.tofu;
return false;
}
}
+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`);
+2 -1
View File
@@ -863,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
+5 -4
View File
@@ -20,7 +20,7 @@ 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 { encodeUnpaddedBase64Url } from "../../base64.ts";
import { encodeUnpaddedBase64 } from "../../base64.ts";
import { slotIdToDescription } from "../utils.ts";
/**
@@ -149,8 +149,9 @@ export const checkRtcMembershipData = (data: IContent, sender: string): data is
};
export async function computeRtcIdentityRaw(userId: string, deviceId: string, memberId: string): Promise<string> {
const hashInput = `${userId}|${deviceId}|${memberId}`;
const hashBuffer = await sha256(hashInput);
const hashedString = encodeUnpaddedBase64Url(hashBuffer);
// 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;
}
+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,
};
};
+31 -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;
+63 -7
View File
@@ -98,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,
@@ -735,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);
}
/**
@@ -1375,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()) {
@@ -1955,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
@@ -2065,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`.
*
@@ -2215,6 +2256,18 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
| 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);
});
}
}
class EventDecryptor {
@@ -2543,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;
};
@@ -2553,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 => {
+1 -2
View File
@@ -21,7 +21,6 @@ limitations under the License.
* This is an internal module. See {@link createNewMatrixCall} for the public API.
*/
import { v4 as uuidv4 } from "uuid";
import { parse as parseSdp, write as writeSdp } from "sdp-transform";
import { logger } from "../logger.ts";
@@ -2490,7 +2489,7 @@ export class MatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap
sender_session_id: this.client.getSessionId(),
dest_session_id: this.opponentSessionId,
seq: toDeviceSeq,
[ToDeviceMessageId]: uuidv4(),
[ToDeviceMessageId]: globalThis.crypto.randomUUID(),
};
this.emit(