Compare commits

..

27 Commits

Author SHA1 Message Date
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
21 changed files with 1419 additions and 510 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 -1
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: |
@@ -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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
node-version-file: package.json
cache: "pnpm"
+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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
node-version-file: package.json
+2 -2
View File
@@ -130,8 +130,8 @@ 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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
node-version-file: ${{ inputs.dir }}/package.json
+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 # v4
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
registry-url: "https://registry.npmjs.org"
+5 -5
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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- name: 🔧 pnpm cache
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -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
+7 -3
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,7 +56,7 @@ 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 }}
@@ -84,7 +88,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 }}
+15 -15
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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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@71321a20a9ded102f6e9ce5718a2fcec2c4f70d8 # v0.5.2
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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
node-version-file: package.json
@@ -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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # 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 # v4
- name: Setup Node
id: setupNode
uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6
with:
cache: "pnpm"
node-version: ${{ matrix.node }}
@@ -88,7 +88,7 @@ jobs:
complement-crypto:
name: "Run Complement Crypto tests"
if: github.event_name == 'merge_group'
if: false # disabled due to flakiness
permissions: read-all # zizmor: ignore[excessive-permissions]
uses: matrix-org/complement-crypto/.github/workflows/single_sdk_tests.yml@main # zizmor: ignore[unpinned-uses]
with:
+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 }}
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "matrix-js-sdk",
"version": "41.2.0",
"version": "41.3.0-rc.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
@@ -106,7 +106,7 @@
"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",
@@ -114,12 +114,12 @@
"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"
"expect": "30.3.0"
},
"pnpm": {
"peerDependencyRules": {
@@ -131,5 +131,5 @@
"eslint": "8"
}
},
"packageManager": "pnpm@10.30.3+sha512.c961d1e0a2d8e354ecaa5166b822516668b7f44cb5bd95122d590dd81922f606f5473b6d23ec4a5be05e7fcd18e8488d47d978bbe981872f1145d06e9a740017"
"packageManager": "pnpm@10.32.1+sha512.a706938f0e89ac1456b6563eab4edf1d1faf3368d1191fc5c59790e96dc918e4456ab2e67d613de1043d2e8c81f87303e6b40d4ffeca9df15ef1ad567348f2be"
}
+813 -327
View File
File diff suppressed because it is too large Load Diff
+509 -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,393 @@ 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([false, true])(
"Room key is rotated after a member joins and leaves the room (gappy sync = %s)",
async (gappySync) => {
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: KnownMembership.Leave },
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 (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: KnownMembership.Leave },
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: KnownMembership.Leave },
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
expect(sentToDeviceRequest[charlieClient.getSafeUserId()]).toBeUndefined();
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 M2, which he should not be able to decrypt.
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,
],
},
},
},
},
} 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 +1137,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" };
});
+1 -1
View File
@@ -180,7 +180,7 @@ describe("oidc authorization", () => {
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 = {
+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);
});
+6
View File
@@ -794,6 +794,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 +823,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;
+38
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,
@@ -1955,6 +1956,43 @@ 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 a `leave` event. 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 (sohis 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.Leave
) {
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