Compare commits

..

51 Commits

Author SHA1 Message Date
RiotRobot 946cf4f359 v37.11.0 2025-07-15 14:44:53 +00:00
RiotRobot b7cce93edc v37.11.0-rc.0 2025-07-08 13:21:56 +00:00
Valere Fedronic e5c8c20a34 MatrixRTC: Refactor | Introduce a new Encryption manager (used with experimental to device transport) (#4799)
* refactor: New encryption manager BasicEncryptionManager for todevice

fixup: bad do not commit

* fix: ToDevice transport not setting the sent_ts

* test: BasicEncryptionManager add statistics tests

* code review

* feat: Encryption manager just reshare on new joiner

* refactor: Rename BasicEncryptionManger to RTCEncryptionManager

* fixup: RTC experimental todevice should use new encryption mgr

* fixup: use proper logger hierarchy

* fixup: RTC rollout first key asap even if no members to send to

* fixup: RTC add test for first key use

* fixup! emitting outbound key before anyone registered

* fix: quick patch for transport switch, need test

* test: RTC encryption manager, add test for transport switch

* post rebase fix

* Remove bad corepack commit

* review: cleaning, renaming

* review: cleaning and renaming

* stop using root logger in favor of a parent logger

* post merge fix broken test

* remove corepack again

* fix reverted changes after a merge

* review: Properly deprecate getEncryptionKeys

* review: rename ensureMediaKeyDistribution to ensureKeyDistribution

* review: use OutdatedKeyFilter instead of KeyBuffer
2025-07-08 12:43:16 +00:00
Neil Johnson 137379b7b7 Update README.md
style
2025-07-04 17:43:29 +01:00
Neil Johnson 4981efa54b Update README to make Element sponsorship explicit (#4901) 2025-07-04 17:41:35 +01:00
Richard van der Hoff 6e9740d787 Use client logger in more places (crypto code) (#4900)
* Use client logger for `RustBackupManager`

* use client logger in `CrossSigningIdentity`

* use client logger in `OutgoingRequestProcessor`

* RoomEncryptor: use correct logger for logDuration

use the logger for this specific event, rather than the more general one for the room

* Use client logger in `RoomEncryptor`
2025-07-04 07:35:42 +00:00
Richard van der Hoff 70257e0ab4 use client logger in MatrixRTCSessionManager (#4898) 2025-07-03 15:56:51 +00:00
Valere Fedronic 9baba151c6 Move ClientEvent docs to the event itself instead of the type map (#4894)
* doc: Add ClientEvent doc on the event enum

* Cleanup: Remove useless doc on EventHandlerMap

* quick format
2025-07-03 12:26:10 +00:00
Richard van der Hoff b4672e26ec Use client logger in more places (core code) (#4899)
* Use client logger for sync

Use the logger attached to the MatrixClient when writing log messages out of
the sync api. This helps figure out what's going on when multiple clients are
running in the same JS environment.

* Use client logger for to-device message queue

* Use client logger in `PushProcessor.rewriteDefaultRules`

* use client logger in `ServerCapabilities`

* Mark global `logger` as deprecated
2025-07-03 09:58:01 +01:00
Richard van der Hoff 940d358b0e Test: stop loading Olm into global namespace (#4895)
* Test: stop loading Olm into global namespace

Now that the js-sdk no longer relies on libolm, there is no need to populate
`globalThis.Olm`. Remove the code that did so (or relied on it being done).

* fix lint
2025-07-02 14:48:45 +00:00
Valere Fedronic 161c12f5d5 crypto: Add new ClientEvent.ReceivedToDeviceMessage with proper OlmEncryptionInfo support (#4891)
* crypto: Add new ClientEvent.ReceivedToDeviceMessage

refactor rename ProcessedToDeviceEvent to ReceivedToDeviceEvent

* fix: Restore legacy isEncrypted() for to-device messages

* Update test for new preprocessToDeviceMessages API

* quick fix on doc

* quick update docs and renaming

* review: Better doc and names for OlmEncryptionInfo

* review: Remove IToDeviceMessage alias and only keep IToDeviceEvent

* review: improve comments of processToDeviceMessages

* review: pass up encrypted event when no crypto callbacks

* review: use single payload for ReceivedToDeviceMessage

* fix linter

* review: minor comment update
2025-07-02 08:02:23 +00:00
RiotRobot de659d6431 Merge branch 'master' into develop 2025-07-01 14:54:29 +00:00
RiotRobot b095aa600d v37.10.0 2025-07-01 14:53:40 +00:00
David Baker 18ea8befdc Specify typeroot (#4892)
This prevents tsc from picking up random types from parent directories
such as in situations like an element-web layered build, and generally
seems like good hygiene as we don't want to pick up random types from
whatever directory we happen to be checked out into.
2025-07-01 11:31:28 +00:00
Timo 4f9ca2c697 Remove LegacyMembershipManager (#4862)
* Remove `LegacyMemberhsipManager`

* remove tests from rtc session
Those tests were only run with the legacy membership manager and are redundant with the memberhsip manager test spec.

* fix tests

* dont use non existing TestManager anymore

* remove fails for legacy

* fix another test
2025-06-26 12:19:51 +00:00
renovate[bot] 57a4dc8841 Update dependency @types/node to v18.19.112 (#4886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 12:34:38 +00:00
renovate[bot] 841c02e56d Update shogo82148/actions-upload-release-asset digest to 610b198 (#4885)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-25 11:04:54 +00:00
renovate[bot] 6ece4c3c16 Update all non-major dependencies (#4888)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 16:44:15 +00:00
renovate[bot] 5a3c07f91d Update typescript-eslint monorepo to v8.34.1 (#4887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:54:48 +00:00
renovate[bot] 3ed4b3ed50 Update mheap/github-action-required-labels digest to 8afbe8a (#4884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:20:47 +00:00
renovate[bot] 9ca9bd9baf Update guibranco/github-status-action-v2 digest to 741ea90 (#4883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-24 15:02:53 +00:00
RiotRobot 0265f6ea2d v37.10.0-rc.0 2025-06-24 12:40:23 +00:00
Richard van der Hoff 8f597f0f87 Update matrix-sdk-crypto-wasm to 15.0.0 (#4882)
For js-sdk users, this includes the following:

    -   Send stable identifier `sender_device_keys` for MSC4147 (Including device keys with Olm-encrypted events).
        ([#4964](https://github.com/matrix-org/matrix-rust-sdk/pull/4964))

    -   Check the `sender_device_keys` field on _all_ incoming Olm-encrypted to-device messages and ignore any to-device messages which include the field but whose data is invalid (as per [MSC4147](https://github.com/matrix-org/matrix-spec-proposals/pull/4147)).
        ([#4922](https://github.com/matrix-org/matrix-rust-sdk/pull/4922))

    -   Fix bug which caused room keys to be unnecessarily rotated on every send in the presence of blacklisted/withheld devices in the room.
        ([#4954](https://github.com/matrix-org/matrix-rust-sdk/pull/4954))

    -   Fix [matrix-rust-sdk#2729](https://github.com/matrix-org/matrix-rust-sdk/issues/2729) which in rare cases can cause room key oversharing.
        ([#4975](https://github.com/matrix-org/matrix-rust-sdk/pull/4975))
2025-06-23 17:35:37 +00:00
Michael Telatynski 67df2a53c4 Remove redundant git-revision.txt file (#4881)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-20 08:08:30 +00:00
Patrick Cloke 8367277894 Allow customizing the IndexedDB database prefix used by Rust crypto. (#4878)
* Allow customizing the IndexedDB database prefix used by Rust crypto.

Related to #3974

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>

* Rename argument

---------

Signed-off-by: Patrick Cloke <clokep@patrick.cloke.us>
2025-06-18 09:07:35 +00:00
RiotRobot 4efb27354f Merge branch 'master' into develop 2025-06-17 13:09:40 +00:00
RiotRobot 28bc90563e v37.9.0 2025-06-17 13:09:06 +00:00
Will Hunt f9be1bf57a Add prepare script (#4877)
* Add prepare script

* Remove 'prepack' script.
2025-06-17 10:18:19 +00:00
Richard van der Hoff adaf921623 Remove @matrix-org/olm from dependency list (#4876)
Olm is needed at build time to run the tests, but is no longer needed at
runtime.
2025-06-12 09:28:39 +00:00
Travis Ralston cdece6cb9f Redact on ban: Client implementation (#4867)
* First pass implementation

* fix naming/docs

* apply lint

* Add test for existing behaviour

* Add happy path tests

* Fix bug identified by tests

* ... and this is why we add negative tests too

* Add some sanity tests

* Apply linter
2025-06-10 14:28:02 +00:00
renovate[bot] d438e25f87 Update dependency @stylistic/eslint-plugin to v4.4.1 (#4873)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 14:16:20 +00:00
renovate[bot] 73d8f4384d Update all non-major dependencies (#4870)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:32 +00:00
renovate[bot] b0cb6aa724 Update babel monorepo (#4871)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:20 +00:00
renovate[bot] dfc26f8aa1 Update definitelyTyped (#4872)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:56:11 +00:00
renovate[bot] 286fb8f752 Update typescript-eslint monorepo to v8.33.1 (#4874)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 13:55:42 +00:00
RiotRobot 921de6807d v37.9.0-rc.0 2025-06-10 13:16:01 +00:00
RiotRobot d4e7b949e7 Merge branch 'master' into develop 2025-06-10 12:00:47 +00:00
renovate[bot] 1e80538cfb Update dependency @matrix-org/matrix-sdk-crypto-wasm to v14.2.1 (#4868)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-06-10 11:30:04 +00:00
Michael Telatynski 73cbcfa4ee Ensure we send spec-compliant filter strings by stripping out null values (#4865)
* Ensure we send spec-compliant filter strings by stripping out null values

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

* Add test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-09 08:32:42 +00:00
Richard van der Hoff 99972ce0a9 Factor out common code for processing to-device events (#4863)
`sliding-sync-sdk.ts` and `sync.ts` both have copies of this code, and it's
redundant.
2025-06-05 08:57:50 +00:00
Timo 44399f6017 Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) (#4861)
* add test run helper to allow running long tests in vs code

* deprecate IDeferred (as its associated defer method is also deprecated and its just a type rename to PromiseWithResolvers)

* Improve docs and readability of MembershipManager.spec.ts

* Intoduce test for a race condition which results in a state where the state event and the hasMemberStateEvent variable diverge

* fix room state and membership manager state diverging. See:
https://github.com/element-hq/element-call-rageshakes/issues/10609
https://github.com/element-hq/element-call-rageshakes/issues/10594
https://github.com/element-hq/element-call-rageshakes/issues/9902

* logging, docstings and variable name improvements

* review

* review pending timers
2025-06-04 10:44:12 +00:00
RiotRobot c387f30e5c Merge branch 'master' into develop 2025-06-03 14:50:36 +00:00
rsb-tbg 12a9875c46 Include extraParams in all HTTP requests (#4860)
* attaching queryParams from client config in getUrl

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* changed client queryParams to QueryDict for consistency and now merging both sets of params in getUrl if one or both exist

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

* added tests

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>

---------

Signed-off-by: rsb-tbg <69879226+rsb-tbg@users.noreply.github.com>
2025-05-30 09:09:21 +00:00
renovate[bot] 74f5efc4ef Update all non-major dependencies (#4859)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 08:01:03 +00:00
renovate[bot] 43d47982ed Update dependency @types/node to v18.19.105 (#4858)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-29 07:47:37 +00:00
renovate[bot] 94fb489952 Update dependency @stylistic/eslint-plugin to v4.4.0 (#4855)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 15:36:54 +00:00
renovate[bot] 75ae05e5eb Update typescript-eslint monorepo to v8.33.0 (#4856)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:45:13 +00:00
renovate[bot] 9058b79c39 Update all non-major dependencies (#4854)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:33 +00:00
renovate[bot] 671dd2ca40 Update dependency typedoc to v0.28.5 (#4852)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:44:13 +00:00
renovate[bot] 34f35393ff Update dependency @types/node to v18.19.103 (#4851)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:50 +00:00
renovate[bot] e206a12902 Update babel monorepo to v7.27.3 (#4850)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-05-28 14:43:26 +00:00
76 changed files with 3197 additions and 1784 deletions
@@ -22,7 +22,7 @@ runs:
- name: Upload tarball signature
if: ${{ inputs.upload-url }}
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # 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@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}.asc
- name: Upload assets
uses: shogo82148/actions-upload-release-asset@d22998fda4c1407f60d1ab48cd6fe67f360f34de # v1
uses: shogo82148/actions-upload-release-asset@610b1987249a69a79de9565777e112fb38f22436 # v1
with:
upload_url: ${{ inputs.upload-url }}
asset_path: ${{ inputs.asset-path }}
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-24.04
steps:
- uses: mheap/github-action-required-labels@388fd6af37b34cdfe5a23b37060e763217e58b03 # v5
- uses: mheap/github-action-required-labels@8afbe8ae6ab7647d0c9f0cfa7c2f939650d22509 # v5
if: github.event_name != 'merge_group'
with:
labels: |
+2 -2
View File
@@ -27,7 +27,7 @@ jobs:
steps:
# We create the status here and then update it to success/failure in the `report` stage
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -87,7 +87,7 @@ jobs:
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
- uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
- uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -116,7 +116,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
uses: guibranco/github-status-action-v2@741ea90ba6c3ca76fe0d43ba11a90cda97d5e685
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+1 -2
View File
@@ -13,8 +13,7 @@ out
/dist
/lib
# version file and tarball created by `npm pack` / `yarn pack`
/git-revision.txt
# tarball created by `npm pack` / `yarn pack`
/matrix-js-sdk-*.tgz
.vscode
+30
View File
@@ -1,3 +1,33 @@
Changes in [37.11.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.11.0) (2025-07-15)
====================================================================================================
## ✨ Features
* Update README to make Element sponsorship explicit ([#4901](https://github.com/matrix-org/matrix-js-sdk/pull/4901)). Contributed by @neilisfragile.
* Use client logger in more places (crypto code) ([#4900](https://github.com/matrix-org/matrix-js-sdk/pull/4900)). Contributed by @richvdh.
* Use client logger in `MatrixRTCSessionManager` ([#4898](https://github.com/matrix-org/matrix-js-sdk/pull/4898)). Contributed by @richvdh.
* Use client logger in more places (core code) ([#4899](https://github.com/matrix-org/matrix-js-sdk/pull/4899)). Contributed by @richvdh.
* crypto: Add new `ClientEvent.ReceivedToDeviceMessage` with proper `OlmEncryptionInfo` support ([#4891](https://github.com/matrix-org/matrix-js-sdk/pull/4891)). Contributed by @BillCarsonFr.
Changes in [37.10.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.10.0) (2025-07-01)
====================================================================================================
## ✨ Features
* Update matrix-sdk-crypto-wasm to `15.0.0` ([#4882](https://github.com/matrix-org/matrix-js-sdk/pull/4882)). Contributed by @richvdh.
* Allow customizing the IndexedDB database prefix used by Rust crypto. ([#4878](https://github.com/matrix-org/matrix-js-sdk/pull/4878)). Contributed by @clokep.
* Remove `@matrix-org/olm` from dependency list ([#4876](https://github.com/matrix-org/matrix-js-sdk/pull/4876)). Contributed by @richvdh.
* Redact on ban: Client implementation ([#4867](https://github.com/matrix-org/matrix-js-sdk/pull/4867)). Contributed by @turt2live.
Changes in [37.9.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.9.0) (2025-06-17)
==================================================================================================
## 🐛 Bug Fixes
* Ensure we send spec-compliant filter strings by stripping out null values ([#4865](https://github.com/matrix-org/matrix-js-sdk/pull/4865)). Contributed by @t3chguy.
* Fix MatrixRTC membership manager failing to rejoin in a race condition (sync vs not found response) ([#4861](https://github.com/matrix-org/matrix-js-sdk/pull/4861)). Contributed by @toger5.
* Include extraParams in all HTTP requests ([#4860](https://github.com/matrix-org/matrix-js-sdk/pull/4860)). Contributed by @rsb-tbg.
Changes in [37.8.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v37.8.0) (2025-06-10)
==================================================================================================
## 🐛 Bug Fixes
+16
View File
@@ -11,6 +11,22 @@
This is the [Matrix](https://matrix.org) Client-Server SDK for JavaScript and TypeScript. This SDK can be run in a
browser or in Node.js.
---
<picture>
<source srcset="contrib/element-logo-light.png" media="(prefers-color-scheme: dark)">
<source srcset="contrib/element-logo-dark.png" media="(prefers-color-scheme: light)">
<img src="contrib/element-logo-fallback.png" alt="Element logo">
</picture>
<br>
Development and maintenance is proudly sponsored by [Element](https://element.io). Element uses the SDK in their flagship [web](https://github.com/element-hq/element-web) and [desktop](https://github.com/element-hq/element-desktop) clients.
The SDK is also the basis for multiple Matrix projects and we welcome contributions from all.
---
#### Minimum Matrix server version: v1.1
The Matrix specification is constantly evolving - while this SDK aims for maximum backwards compatibility, it only
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+5 -6
View File
@@ -1,16 +1,15 @@
{
"name": "matrix-js-sdk",
"version": "37.8.0",
"version": "37.11.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=22.0.0"
},
"scripts": {
"prepack": "yarn build",
"prepare": "yarn build",
"start": "echo THIS IS FOR LEGACY PURPOSES ONLY. && babel src -w -s -d lib --verbose --extensions \".ts,.js\"",
"clean": "rimraf lib",
"build": "yarn build:dev",
"build:dev": "yarn clean && git rev-parse HEAD > git-revision.txt && yarn build:compile && yarn build:types",
"build": "yarn clean && yarn build:compile && yarn build:types",
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
@@ -50,8 +49,7 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^14.2.0",
"@matrix-org/olm": "3.2.15",
"@matrix-org/matrix-sdk-crypto-wasm": "^15.0.0",
"another-json": "^0.2.0",
"bs58": "^6.0.0",
"content-type": "^1.0.4",
@@ -81,6 +79,7 @@
"@babel/preset-env": "^7.12.11",
"@babel/preset-typescript": "^7.12.7",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
"@stylistic/eslint-plugin": "^4.0.0",
"@types/content-type": "^1.1.5",
-3
View File
@@ -19,9 +19,6 @@ limitations under the License.
// `expect` is allowed in helper functions which are called within `test`/`it` blocks
/* eslint-disable jest/no-standalone-expect */
// load olm before the sdk if possible
import "./olm-loader";
import MockHttpBackend from "matrix-mock-request";
import type { IDeviceKeys, IOneTimeKey } from "../src/@types/crypto";
+1 -10
View File
@@ -19,9 +19,9 @@ import anotherjson from "another-json";
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import Olm from "@matrix-org/olm";
import type FetchMock from "fetch-mock";
import type Olm from "@matrix-org/olm";
import * as testUtils from "../../test-utils/test-utils";
import {
emitPromise,
@@ -124,7 +124,6 @@ async function expectSendRoomKey(
recipientOlmAccount: Olm.Account,
recipientOlmSession: Olm.Session | null = null,
): Promise<Olm.InboundGroupSession> {
const Olm = globalThis.Olm;
const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"];
function onSendRoomKey(content: any): Olm.InboundGroupSession {
@@ -207,14 +206,6 @@ async function expectSendMegolmMessage(
}
describe("crypto", () => {
if (!globalThis.Olm) {
// currently we use libolm to implement the crypto in the tests, so need it to be present.
logger.warn("not running megolm tests: Olm not present");
return;
}
const Olm = globalThis.Olm;
let testOlmAccount = {} as unknown as Olm.Account;
let testSenderKey = "";
+4 -4
View File
@@ -93,15 +93,15 @@ export function bootstrapCrossSigningTestOlmAccount(
deviceId: string,
keyBackupInfo: KeyBackupInfo[] = [],
): Partial<IDownloadKeyResult> {
const olmAliceMSK = new globalThis.Olm.PkSigning();
const olmAliceMSK = new Olm.PkSigning();
const masterPrivkey = olmAliceMSK.generate_seed();
const masterPubkey = olmAliceMSK.init_with_seed(masterPrivkey);
const olmAliceUSK = new globalThis.Olm.PkSigning();
const olmAliceUSK = new Olm.PkSigning();
const userPrivkey = olmAliceUSK.generate_seed();
const userPubkey = olmAliceUSK.init_with_seed(userPrivkey);
const olmAliceSSK = new globalThis.Olm.PkSigning();
const olmAliceSSK = new Olm.PkSigning();
const sskPrivkey = olmAliceSSK.generate_seed();
const sskPubkey = olmAliceSSK.init_with_seed(sskPrivkey);
@@ -189,7 +189,7 @@ export async function createOlmSession(
const otkId = Object.keys(keys)[0];
const otk = keys[otkId];
const session = new globalThis.Olm.Session();
const session = new Olm.Session();
session.create_outbound(olmAccount, recipientTestClient.getDeviceKey(), otk.key);
return session;
}
+35
View File
@@ -67,6 +67,23 @@ describe("MatrixClient.initRustCrypto", () => {
expect(databaseNames).toEqual(expect.arrayContaining(["matrix-js-sdk::matrix-sdk-crypto"]));
});
it("should create the indexed db with a custom prefix", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
// should have an indexed db now
const databaseNames = (await indexedDB.databases()).map((db) => db.name);
expect(databaseNames).toEqual(expect.arrayContaining(["my-prefix::matrix-sdk-crypto"]));
});
it("should create the meta db if given a storageKey", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
@@ -475,4 +492,22 @@ describe("MatrixClient.clearStores", () => {
await matrixClient.clearStores();
// No error thrown in clearStores
});
it("should clear the indexeddbs with a custom prefix", async () => {
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@alice:localhost",
deviceId: "aliceDevice",
});
// No databases.
expect(await indexedDB.databases()).toHaveLength(0);
await matrixClient.initRustCrypto({ cryptoDatabasePrefix: "my-prefix" });
expect(await indexedDB.databases()).toHaveLength(1);
await matrixClient.stopClient();
await matrixClient.clearStores({ cryptoDatabasePrefix: "my-prefix" });
expect(await indexedDB.databases()).toHaveLength(0);
});
});
+121 -3
View File
@@ -17,13 +17,22 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import Olm from "@matrix-org/olm";
import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
import { createClient, type MatrixClient } from "../../../src";
import {
ClientEvent,
createClient,
type IToDeviceEvent,
type MatrixClient,
type MatrixEvent,
type ReceivedToDeviceMessage,
} from "../../../src";
import * as testData from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -43,6 +52,8 @@ describe("to-device-messages", () => {
/** an object which intercepts `/keys/query` requests on the test homeserver */
let e2eKeyResponder: E2EKeyResponder;
let e2eKeyReceiver: E2EKeyReceiver;
let syncResponder: SyncResponder;
beforeEach(
async () => {
@@ -59,8 +70,8 @@ describe("to-device-messages", () => {
});
e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
new E2EKeyReceiver(homeserverUrl);
const syncResponder = new SyncResponder(homeserverUrl);
e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
// add bob as known user
syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
@@ -149,4 +160,111 @@ describe("to-device-messages", () => {
// for future: check that bob's device can decrypt the ciphertext?
});
});
describe("receive to-device-messages", () => {
it("Should receive decrypted to-device message via ClientEvent", async () => {
// create a test olm device which we will use to communicate with alice. We use libolm to implement this.
await Olm.init();
const testOlmAccount = new Olm.Account();
testOlmAccount.create();
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
e2eKeyResponder.addDeviceKeys(testDeviceKeys);
await aliceClient.startClient();
await syncPromise(aliceClient);
syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"]));
await syncPromise(aliceClient);
const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount);
const toDeviceEvent = encryptOlmEvent({
sender: "@bob:xyz",
senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`],
senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`],
p2pSession: p2pSession,
recipient: aliceClient.getUserId()!,
recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(),
recipientEd25519Key: e2eKeyReceiver.getSigningKey(),
plaincontent: {
body: "foo",
},
plaintype: "m.test.type",
});
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
processedToDeviceResolver.resolve(payload);
});
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
oldToDeviceResolver.resolve(event);
});
expect(toDeviceEvent.type).toBe("m.room.encrypted");
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
await syncPromise(aliceClient);
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
expect(message.type).toBe("m.test.type");
expect(message.content["body"]).toBe("foo");
expect(encryptionInfo).not.toBeNull();
expect(encryptionInfo!.senderVerified).toBe(false);
expect(encryptionInfo!.sender).toBe("@bob:xyz");
expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID");
const oldFormat = await oldToDeviceResolver.promise;
expect(oldFormat.isEncrypted()).toBe(true);
expect(oldFormat.getType()).toBe("m.test.type");
expect(oldFormat.getContent()["body"]).toBe("foo");
});
it("Should receive clear to-device message via ClientEvent", async () => {
await aliceClient.startClient();
await syncPromise(aliceClient);
const toDeviceEvent: IToDeviceEvent = {
sender: "@bob:xyz",
type: "m.test.type",
content: {
body: "foo",
},
};
const processedToDeviceResolver: PromiseWithResolvers<ReceivedToDeviceMessage> = Promise.withResolvers();
aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => {
processedToDeviceResolver.resolve(payload);
});
const oldToDeviceResolver: PromiseWithResolvers<MatrixEvent> = Promise.withResolvers();
aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
oldToDeviceResolver.resolve(event);
});
syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } });
await syncPromise(aliceClient);
const { message, encryptionInfo } = await processedToDeviceResolver.promise;
expect(message.type).toBe("m.test.type");
expect(message.content["body"]).toBe("foo");
// When the message is not encrypted, we don't have the encryptionInfo.
expect(encryptionInfo).toBeNull();
const oldFormat = await oldToDeviceResolver.promise;
expect(oldFormat.isEncrypted()).toBe(false);
expect(oldFormat.getType()).toBe("m.test.type");
expect(oldFormat.getContent()["body"]).toBe("foo");
});
});
});
+3 -4
View File
@@ -84,7 +84,7 @@ jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
beforeAll(async () => {
// we use the libolm primitives in the test, so init the Olm library
await globalThis.Olm.init();
await Olm.init();
});
// load the rust library. This can take a few seconds on a slow GH worker.
@@ -110,7 +110,6 @@ const TEST_HOMESERVER_URL = "https://alice-server.com";
* These tests work by intercepting HTTP requests via fetch-mock rather than mocking out bits of the client, so as
* to provide the most effective integration tests possible.
*/
// we test with both crypto stacks...
describe("verification", () => {
/** the client under test */
let aliceClient: MatrixClient;
@@ -254,7 +253,7 @@ describe("verification", () => {
// The dummy device makes up a curve25519 keypair and sends the public bit back in an `m.key.verification.key'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new globalThis.Olm.SAS();
const olmSAS = new Olm.SAS();
returnToDeviceMessageFromSync(buildSasKeyMessage(transactionId, olmSAS.get_pubkey()));
// alice responds with a 'key' ...
@@ -348,7 +347,7 @@ describe("verification", () => {
// The dummy device makes up a curve25519 keypair and uses the hash in an 'm.key.verification.accept'
// We use the Curve25519, HMAC and HKDF implementations in libolm, for now
const olmSAS = new globalThis.Olm.SAS();
const olmSAS = new Olm.SAS();
const commitmentStr = olmSAS.get_pubkey() + anotherjson.stringify(toDeviceMessage);
sendToDevicePromise = expectSendToDeviceMessage("m.key.verification.key");
+105
View File
@@ -205,4 +205,109 @@ describe("MatrixClient opts", function () {
expect(res.event_id).toEqual("foo");
});
});
describe("with opts.queryParams", function () {
let client: MatrixClient;
let httpBackend: HttpBackend;
const userId = "@rsb-tbg:localhost";
beforeEach(function () {
httpBackend = new HttpBackend();
client = new MatrixClient({
fetchFn: httpBackend.fetchFn as typeof globalThis.fetch,
store: new MemoryStore() as IStore,
baseUrl: baseUrl,
userId: userId,
accessToken: accessToken,
queryParams: { user_id: userId },
});
});
afterEach(function () {
client.stopClient();
httpBackend.verifyNoOutstandingExpectation();
return httpBackend.stop();
});
it("should include queryParams in matrix server requests", async () => {
const eventId = "$test:event";
httpBackend
.when("PUT", "/txn1")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {
event_id: eventId,
});
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "test message", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual(eventId);
});
it("should include queryParams in sync requests", async () => {
httpBackend
.when("GET", "/versions")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {});
httpBackend
.when("GET", "/pushrules")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {});
httpBackend
.when("POST", "/filter")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, { filter_id: "foo" });
httpBackend
.when("GET", "/sync")
.check((req) => {
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, syncData);
client.startClient();
await httpBackend.flush("/versions", 1);
await httpBackend.flush("/pushrules", 1);
await httpBackend.flush("/filter", 1);
await Promise.all([httpBackend.flush("/sync", 1), utils.syncPromise(client)]);
});
it("should merge queryParams with request-specific params", async () => {
const eventId = "$test:event";
httpBackend
.when("PUT", "/txn1")
.check((req) => {
// Should contain both global queryParams and request-specific params
expect(req.path).toContain(`user_id=${encodeURIComponent(userId)}`);
return true;
})
.respond(200, {
event_id: eventId,
});
const [res] = await Promise.all([
client.sendTextMessage("!foo:bar", "test message", "txn1"),
httpBackend.flush("/txn1", 1),
]);
expect(res.event_id).toEqual(eventId);
});
});
});
+1 -1
View File
@@ -126,7 +126,7 @@ describe("SlidingSyncSdk", () => {
// assign client/httpBackend globals
const setupClient = async (testOpts?: Partial<IStoredClientOpts & { withCrypto: boolean }>) => {
testOpts = testOpts || {};
const syncOpts: SyncApiOptions = {};
const syncOpts: SyncApiOptions = { logger };
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
httpBackend = testClient.httpBackend;
client = testClient.client;
-27
View File
@@ -1,27 +0,0 @@
/*
Copyright 2017 Vector creations Ltd
Copyright 2019 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger } from "../src/logger";
// try to load the olm library.
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
globalThis.Olm = require("@matrix-org/olm");
logger.log("loaded libolm");
} catch (e) {
logger.warn("unable to run crypto tests: libolm not available", e);
}
+5
View File
@@ -19,3 +19,8 @@ jest.mock("../src/http-api/utils", () => ({
// We mock timeoutSignal otherwise it causes tests to leave timers running
timeoutSignal: () => new AbortController().signal,
}));
// Dont make test fail too soon due to timeouts while debugging.
if (process.env.VSCODE_INSPECTOR_OPTIONS) {
jest.setTimeout(60 * 1000 * 5); // 5 minutes
}
-3
View File
@@ -1,6 +1,3 @@
// load olm before the sdk if possible
import "../olm-loader";
// eslint-disable-next-line no-restricted-imports
import type EventEmitter from "events";
import { logger } from "../../src/logger";
+2 -1
View File
@@ -5,6 +5,7 @@ import { getMockClientWithEventEmitter } from "../test-utils/client";
import { StubStore } from "../../src/store/stub";
import { type IndexedToDeviceBatch } from "../../src/models/ToDeviceMessage";
import { SyncState } from "../../src/sync";
import { logger } from "../../src/logger.ts";
describe("onResumedSync", () => {
let batch: IndexedToDeviceBatch | null;
@@ -55,7 +56,7 @@ describe("onResumedSync", () => {
}
});
queue = new ToDeviceMessageQueue(mockClient);
queue = new ToDeviceMessageQueue(mockClient, logger);
});
it("resends queue after connectivity restored", async () => {
+7
View File
@@ -170,4 +170,11 @@ describe("Filter Component", function () {
expect(filter.check(noMatchEvent)).toBe(false);
});
});
describe("toJSON", () => {
it("should omit empty values", () => {
const filter = new FilterComponent({ types: ["m.room.message"], senders: ["@alice:example.com"] });
expect(filter.toJSON()).toEqual({ types: ["m.room.message"], senders: ["@alice:example.com"] });
});
});
});
+77
View File
@@ -521,6 +521,83 @@ describe("FetchHttpApi", () => {
describe("when fetch.opts.baseUrl does have a trailing slash", () => {
runTests(baseUrlWithTrailingSlash);
});
describe("extraParams handling", () => {
const makeApiWithExtraParams = (extraParams: QueryDict): FetchHttpApi<any> => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
return new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn, extraParams });
};
const userId = "@rsb-tbg:localhost";
const encodedUserId = encodeURIComponent(userId);
it("should include extraParams in URL when no queryParams provided", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const result = api.getUrl("/test");
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
});
it("should merge extraParams with queryParams", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const queryParams = { userId: "123", filter: "active" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("user_id")!).toBe(userId);
expect(result.searchParams.get("version")!).toBe("1.0");
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.searchParams.get("filter")!).toBe("active");
});
it("should allow queryParams to override extraParams", () => {
const extraParams = { user_id: "@default:localhost", version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const queryParams = { user_id: "@override:localhost", userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("user_id")).toBe("@override:localhost");
expect(result.searchParams.get("version")!).toBe("1.0");
expect(result.searchParams.get("userId")!).toBe("123");
});
it("should handle empty extraParams", () => {
const extraParams = {};
const api = makeApiWithExtraParams(extraParams);
const queryParams = { userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.searchParams.has("user_id")).toBe(false);
});
it("should work when extraParams is undefined", () => {
const fetchFn = jest.fn();
const emitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const api = new FetchHttpApi(emitter, { baseUrl: localBaseUrl, prefix, fetchFn });
const queryParams = { userId: "123" };
const result = api.getUrl("/test", queryParams);
expect(result.searchParams.get("userId")!).toBe("123");
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?userId=123`);
});
it("should work when queryParams is undefined", () => {
const extraParams = { user_id: userId, version: "1.0" };
const api = makeApiWithExtraParams(extraParams);
const result = api.getUrl("/test");
expect(result.searchParams.get("user_id")!).toBe(userId);
expect(result.toString()).toBe(`${localBaseUrl}${prefix}/test?user_id=${encodedUserId}&version=1.0`);
});
});
});
it("should not log query parameters", async () => {
+31 -144
View File
@@ -16,11 +16,12 @@ limitations under the License.
import { encodeBase64, EventType, MatrixClient, type MatrixError, type MatrixEvent, type Room } from "../../../src";
import { KnownMembership } from "../../../src/@types/membership";
import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession";
import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types";
import { secureRandomString } from "../../../src/randomstring";
import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
const mockFocus = { type: "mock" };
@@ -201,58 +202,6 @@ describe("MatrixRTCSession", () => {
});
});
describe("updateCallMembershipEvent", () => {
const mockFocus = { type: "livekit", livekit_service_url: "https://test.org" };
const joinSessionConfig = {};
const sessionMembershipData: SessionMembershipData = {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: "AAAAAAA_session",
focus_active: mockFocus,
foci_preferred: [mockFocus],
};
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sentStateEvent: Promise<void>;
let sentDelayedState: Promise<void>;
beforeEach(() => {
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
sentDelayedState = new Promise((resolve) => {
sendDelayedStateMock = jest.fn(() => {
resolve();
return {
delay_id: "id",
};
});
});
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
});
async function testSession(membershipData: SessionMembershipData): Promise<void> {
sess = MatrixRTCSession.roomSessionForRoom(client, makeMockRoom(membershipData));
sess.joinRoomSession([mockFocus], mockFocus, joinSessionConfig);
await Promise.race([sentStateEvent, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(sendStateEventMock).toHaveBeenCalledTimes(1);
await Promise.race([sentDelayedState, new Promise((resolve) => setTimeout(resolve, 500))]);
expect(sendDelayedStateMock).toHaveBeenCalledTimes(1);
}
it("sends events", async () => {
await testSession(sessionMembershipData);
});
});
describe("getOldestMembership", () => {
it("returns the oldest membership event", () => {
jest.useFakeTimers();
@@ -320,28 +269,10 @@ describe("MatrixRTCSession", () => {
describe("joining", () => {
let mockRoom: Room;
let sendStateEventMock: jest.Mock;
let sendDelayedStateMock: jest.Mock;
let sendEventMock: jest.Mock;
let sentStateEvent: Promise<void>;
let sentDelayedState: Promise<void>;
beforeEach(() => {
sentStateEvent = new Promise((resolve) => {
sendStateEventMock = jest.fn(resolve);
});
sentDelayedState = new Promise((resolve) => {
sendDelayedStateMock = jest.fn(() => {
resolve();
return {
delay_id: "id",
};
});
});
sendEventMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
client.sendEvent = sendEventMock;
client._unstable_updateDelayedEvent = jest.fn();
@@ -367,67 +298,6 @@ describe("MatrixRTCSession", () => {
sess!.joinRoomSession([mockFocus], mockFocus);
expect(sess!.isJoined()).toEqual(true);
});
it("sends a membership event when joining a call", async () => {
const realSetTimeout = setTimeout;
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus], mockFocus);
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: DEFAULT_EXPIRE_DURATION,
foci_preferred: [mockFocus],
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
);
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
// Because we actually want to send the state
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
// For checking if the delayed event is still there or got removed while sending the state.
expect(client._unstable_updateDelayedEvent).toHaveBeenCalledTimes(1);
// For scheduling the delayed event
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
// This returns no error so we do not check if we reschedule the event again. this is done in another test.
jest.useRealTimers();
});
it("uses membershipEventExpiryMs from join config", async () => {
const realSetTimeout = setTimeout;
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus], mockFocus, { membershipEventExpiryMs: 60000 });
await Promise.race([sentStateEvent, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: 60000,
foci_preferred: [mockFocus],
focus_active: {
focus_selection: "oldest_membership",
type: "livekit",
},
},
"_@alice:example.org_AAAAAAA",
);
await Promise.race([sentDelayedState, new Promise((resolve) => realSetTimeout(resolve, 500))]);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
jest.useRealTimers();
});
});
describe("onMembershipsChanged", () => {
@@ -489,9 +359,9 @@ describe("MatrixRTCSession", () => {
let sendToDeviceMock: jest.Mock;
beforeEach(() => {
sendStateEventMock = jest.fn();
sendDelayedStateMock = jest.fn();
sendEventMock = jest.fn();
sendStateEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendDelayedStateMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendEventMock = jest.fn().mockResolvedValue({ event_id: "id" });
sendToDeviceMock = jest.fn();
client.sendStateEvent = sendStateEventMock;
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
@@ -569,24 +439,22 @@ describe("MatrixRTCSession", () => {
let firstEventSent = false;
try {
const eventSentPromise = new Promise<void>((resolve) => {
const eventSentPromise = new Promise<{ event_id: string }>((resolve) => {
sendEventMock.mockImplementation(() => {
if (!firstEventSent) {
jest.advanceTimersByTime(10000);
firstEventSent = true;
const e = new Error() as MatrixError;
e.data = {};
throw e;
} else {
resolve();
resolve({ event_id: "id" });
}
});
});
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
await jest.runAllTimersAsync();
// wait for the encryption event to get sent
await jest.advanceTimersByTimeAsync(5000);
await eventSentPromise;
expect(sendEventMock).toHaveBeenCalledTimes(2);
@@ -878,11 +746,27 @@ describe("MatrixRTCSession", () => {
expect(sendKeySpy).toHaveBeenCalledTimes(1);
// check that we send the key with index 1 even though the send gets delayed when leaving.
// this makes sure we do not use an index that is one too old.
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
// fake a condition in which we send another encryption key event.
// this could happen do to someone joining the call.
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
expect(sendKeySpy).toHaveBeenLastCalledWith(
expect.any(String),
1,
sess.memberships.map((m) => ({
userId: m.sender,
deviceId: m.deviceId,
membershipTs: m.createdTs(),
})),
);
jest.advanceTimersByTime(7000);
const secondKeysPayload = await keysSentPromise2;
@@ -993,13 +877,16 @@ describe("MatrixRTCSession", () => {
sess!.joinRoomSession([mockFocus], mockFocus, {
manageMediaKeys: true,
useNewMembershipManager: true,
useExperimentalToDeviceTransport: true,
});
sess.onRTCSessionMemberUpdate();
await keySentPromise;
expect(sendToDeviceMock).toHaveBeenCalled();
// Access private to test
expect(sess["encryptionManager"]).toBeInstanceOf(RTCEncryptionManager);
} finally {
jest.useRealTimers();
}
+118 -87
View File
@@ -1,6 +1,3 @@
/**
* @jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
*/
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
@@ -27,43 +24,47 @@ import {
type LivekitFocusActive,
type SessionMembershipData,
} from "../../../src/matrixrtc";
import { LegacyMembershipManager } from "../../../src/matrixrtc/LegacyMembershipManager";
import { makeMockClient, makeMockRoom, membershipTemplate, mockCallMembership, type MockClient } from "./mocks";
import { MembershipManager } from "../../../src/matrixrtc/NewMembershipManager";
import { logger } from "../../../src/logger.ts";
import { MembershipManager } from "../../../src/matrixrtc/MembershipManager.ts";
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
/**
* Create a promise that will resolve once a mocked method is called.
* @param method The method to wait for.
* @param returnVal Provide an optional value that the mocked method should return. (use Promise.resolve(val) or Promise.reject(err))
* @returns The promise that resolves once the method is called.
*/
function waitForMockCall(method: MockedFunction<any>, returnVal?: Promise<any>): Promise<void> {
const { promise, resolve } = Promise.withResolvers<void>();
method.mockImplementation(() => {
resolve();
return returnVal ?? Promise.resolve();
});
return promise;
}
/** See waitForMockCall */
function waitForMockCallOnce(method: MockedFunction<any>, returnVal?: Promise<any>) {
return new Promise<void>((resolve) => {
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
const { promise, resolve } = Promise.withResolvers<void>();
method.mockImplementationOnce(() => {
resolve();
return returnVal ?? Promise.resolve();
});
return promise;
}
function createAsyncHandle(method: MockedFunction<any>) {
const { reject, resolve, promise } = Promise.withResolvers<void>();
/**
* A handle to control when in the test flow the provided method resolves (or gets rejected).
* @param method The method to control the resolve timing.
* @returns
*/
function createAsyncHandle<T>(method: MockedFunction<any>) {
const { reject, resolve, promise } = Promise.withResolvers<T>();
method.mockImplementation(() => promise);
return { reject, resolve };
}
/**
* Tests different MembershipManager implementations. Some tests don't apply to `LegacyMembershipManager`
* use !FailsForLegacy to skip those. See: testEnvironment for more details.
*/
describe.each([
{ TestMembershipManager: LegacyMembershipManager, description: "LegacyMembershipManager" },
{ TestMembershipManager: MembershipManager, description: "MembershipManager" },
])("$description", ({ TestMembershipManager }) => {
describe("MembershipManager", () => {
let client: MockClient;
let room: Room;
const focusActive: LivekitFocusActive = {
@@ -94,12 +95,12 @@ describe.each([
describe("isActivated()", () => {
it("defaults to false", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
expect(manager.isActivated()).toEqual(false);
});
it("returns true after join()", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([]);
expect(manager.isActivated()).toEqual(true);
});
@@ -110,13 +111,13 @@ describe.each([
it("sends a membership event and schedules delayed leave when joining a call", async () => {
// Spys/Mocks
const updateDelayedEventHandle = createAsyncHandle(client._unstable_updateDelayedEvent as Mock);
const updateDelayedEventHandle = createAsyncHandle<void>(client._unstable_updateDelayedEvent as Mock);
// Test
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
memberManager.join([focus], focusActive);
// expects
await waitForMockCall(client.sendStateEvent);
await waitForMockCall(client.sendStateEvent, Promise.resolve({ event_id: "id" }));
expect(client.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
"org.matrix.msc3401.call.member",
@@ -143,7 +144,7 @@ describe.each([
});
it("reschedules delayed leave event if sending state cancels it", async () => {
const memberManager = new TestMembershipManager(undefined, room, client, () => undefined);
const memberManager = new MembershipManager(undefined, room, client, () => undefined);
const waitForSendState = waitForMockCall(client.sendStateEvent);
const waitForUpdateDelaye = waitForMockCallOnce(
client._unstable_updateDelayedEvent,
@@ -212,7 +213,7 @@ describe.each([
return Promise.reject(error);
});
});
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{
delayedLeaveEventDelayMs: 9000,
},
@@ -275,7 +276,7 @@ describe.each([
describe("delayed leave event", () => {
it("does not try again to schedule a delayed leave event if not supported", () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
delayedHandle.reject?.(
new UnsupportedDelayedEventsEndpointError(
@@ -287,14 +288,14 @@ describe.each([
});
it("does try to schedule a delayed leave event again if rate limited", async () => {
const delayedHandle = createAsyncHandle(client._unstable_sendDelayedStateEvent as Mock);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
delayedHandle.reject?.(new HTTPError("rate limited", 429, undefined));
await jest.advanceTimersByTimeAsync(5000);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
it("uses delayedLeaveEventDelayMs from config", () => {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ delayedLeaveEventDelayMs: 123456 },
room,
client,
@@ -311,13 +312,46 @@ describe.each([
});
});
it("uses membershipEventExpiryMs from config", async () => {
const manager = new TestMembershipManager(
{ membershipEventExpiryMs: 1234567 },
it("rejoins if delayed event is not found (404)", async () => {
const RESTART_DELAY = 15000;
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: RESTART_DELAY },
room,
client,
() => undefined,
);
// Join with the membership manager
manager.join([focus], focusActive);
expect(manager.status).toBe(Status.Connecting);
// Let the scheduler run one iteration so that we can send the join state event
await jest.runOnlyPendingTimersAsync();
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
expect(manager.status).toBe(Status.Connected);
// Now that we are connected, we set up the mocks.
// We enforce the following scenario where we simulate that the delayed event activated and caused the user to leave:
// - We wait until the delayed event gets sent and then mock its response to be "not found."
// - We enforce a race condition between the sync that informs us that our call membership state event was set to "left"
// and the "not found" response from the delayed event: we receive the sync while we are waiting for the delayed event to be sent.
// - While the delayed leave event is being sent, we inform the manager that our membership state event was set to "left."
// (onRTCSessionMemberUpdate)
// - Only then do we resolve the sending of the delayed event.
// - We test that the manager acknowledges the leave and sends a new membership state event.
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValueOnce(
new MatrixError({ errcode: "M_NOT_FOUND" }),
);
const { resolve } = createAsyncHandle(client._unstable_sendDelayedStateEvent);
await jest.advanceTimersByTimeAsync(RESTART_DELAY);
// first simulate the sync, then resolve sending the delayed event.
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
resolve({ delay_id: "id" });
// Let the scheduler run one iteration so that the new join gets sent
await jest.runOnlyPendingTimersAsync();
expect(client.sendStateEvent).toHaveBeenCalledTimes(2);
});
it("uses membershipEventExpiryMs from config", async () => {
const manager = new MembershipManager({ membershipEventExpiryMs: 1234567 }, room, client, () => undefined);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
@@ -341,7 +375,7 @@ describe.each([
});
it("does nothing if join called when already joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await waitForMockCall(client.sendStateEvent);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
@@ -353,7 +387,7 @@ describe.each([
describe("leave()", () => {
// TODO add rate limit cases.
it("resolves delayed leave event when leave is called", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
await manager.leave();
@@ -361,7 +395,7 @@ describe.each([
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("send leave event when leave is called and resolving delayed leave fails", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue("unknown");
@@ -375,9 +409,8 @@ describe.each([
"_@alice:example.org_AAAAAAA",
);
});
// FailsForLegacy because legacy implementation always sends the empty state event even though it isn't needed
it("does nothing if not joined !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("does nothing if not joined", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
expect(async () => await manager.leave()).not.toThrow();
expect(client._unstable_sendDelayedStateEvent).not.toHaveBeenCalled();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -387,7 +420,7 @@ describe.each([
describe("getsActiveFocus", () => {
it("gets the correct active focus with oldest_membership", () => {
const getOldestMembership = jest.fn();
const manager = new TestMembershipManager({}, room, client, getOldestMembership);
const manager = new MembershipManager({}, room, client, getOldestMembership);
// Before joining the active focus should be undefined (see FocusInUse on MatrixRTCSession)
expect(manager.getActiveFocus()).toBe(undefined);
manager.join([focus], focusActive);
@@ -422,7 +455,7 @@ describe.each([
});
it("does not provide focus if the selection method is unknown", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], Object.assign(focusActive, { type: "unknown_type" }));
expect(manager.getActiveFocus()).toBe(undefined);
});
@@ -430,7 +463,7 @@ describe.each([
describe("onRTCSessionMemberUpdate()", () => {
it("does nothing if not joined", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
await manager.onRTCSessionMemberUpdate([mockCallMembership(membershipTemplate, room.roomId)]);
await jest.advanceTimersToNextTimerAsync();
expect(client.sendStateEvent).not.toHaveBeenCalled();
@@ -438,7 +471,7 @@ describe.each([
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("does nothing if own membership still present", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
const myMembership = (client.sendStateEvent as Mock).mock.calls[0][2];
@@ -459,7 +492,7 @@ describe.each([
expect(client._unstable_updateDelayedEvent).not.toHaveBeenCalled();
});
it("recreates membership if it is missing", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
await jest.advanceTimersByTimeAsync(1);
// clearing all mocks before checking what happens when calling: `onRTCSessionMemberUpdate`
@@ -480,7 +513,7 @@ describe.each([
// TODO: Not sure about this name
describe("background timers", () => {
it("sends only one keep-alive for delayed leave event per `delayedLeaveEventRestartMs`", async () => {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ delayedLeaveEventRestartMs: 10_000, delayedLeaveEventDelayMs: 30_000 },
room,
client,
@@ -506,12 +539,12 @@ describe.each([
}
});
// !FailsForLegacy because the expires logic was removed for the legacy call manager.
// because the expires logic was removed for the legacy call manager.
// Delayed events should replace it entirely but before they have wide adoption
// the expiration logic still makes sense.
// TODO: Add git commit when we removed it.
async function testExpires(expire: number, headroom?: number) {
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ membershipEventExpiryMs: expire, membershipEventExpiryHeadroomMs: headroom },
room,
client,
@@ -529,23 +562,23 @@ describe.each([
expect(sentMembership.expires).toBe(expire * i);
}
}
it("extends `expires` when call still active !FailsForLegacy", async () => {
it("extends `expires` when call still active", async () => {
await testExpires(10_000);
});
it("extends `expires` using headroom configuration !FailsForLegacy", async () => {
it("extends `expires` using headroom configuration", async () => {
await testExpires(10_000, 1_000);
});
});
describe("status updates", () => {
it("starts 'Disconnected' !FailsForLegacy", () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("starts 'Disconnected'", () => {
const manager = new MembershipManager({}, room, client, () => undefined);
expect(manager.status).toBe(Status.Disconnected);
});
it("emits 'Connection' and 'Connected' after join !FailsForLegacy", async () => {
const handleDelayedEvent = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle(client.sendStateEvent);
it("emits 'Connection' and 'Connected' after join", async () => {
const handleDelayedEvent = createAsyncHandle<void>(client._unstable_sendDelayedStateEvent);
const handleStateEvent = createAsyncHandle<void>(client.sendStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
expect(manager.status).toBe(Status.Disconnected);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
@@ -558,8 +591,8 @@ describe.each([
await jest.advanceTimersByTimeAsync(1);
expect(connectEmit).toHaveBeenCalledWith(Status.Connecting, Status.Connected);
});
it("emits 'Disconnecting' and 'Disconnected' after leave !FailsForLegacy", async () => {
const manager = new TestMembershipManager({}, room, client, () => undefined);
it("emits 'Disconnecting' and 'Disconnected' after leave", async () => {
const manager = new MembershipManager({}, room, client, () => undefined);
const connectEmit = jest.fn();
manager.on(MembershipManagerEvent.StatusChanged, connectEmit);
manager.join([focus], focusActive);
@@ -575,7 +608,7 @@ describe.each([
it("sends retry if call membership event is still valid at time of retry", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(1);
@@ -592,9 +625,8 @@ describe.each([
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop and sends new own membership if not present anymore !FailsForLegacy", async () => {
(client._unstable_sendDelayedStateEvent as any).mockRejectedValue(
it("abandons retry loop and sends new own membership if not present anymore", async () => {
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
429,
@@ -603,7 +635,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
// Should call _unstable_sendDelayedStateEvent but not sendStateEvent because of the
// RateLimit error.
manager.join([focus], focusActive);
@@ -620,11 +652,10 @@ describe.each([
expect(client._unstable_sendDelayedStateEvent).toHaveBeenCalledTimes(2);
expect(client.sendStateEvent).toHaveBeenCalledTimes(1);
});
// FailsForLegacy as implementation does not re-check membership before retrying.
it("abandons retry loop if leave() was called before sending state event !FailsForLegacy", async () => {
it("abandons retry loop if leave() was called before sending state event", async () => {
const handle = createAsyncHandle(client._unstable_sendDelayedStateEvent);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
handle.reject?.(
new MatrixError(
@@ -649,7 +680,7 @@ describe.each([
});
});
describe("retries sending update delayed leave event restart", () => {
it("resends the initial check delayed update event !FailsForLegacy", async () => {
it("resends the initial check delayed update event", async () => {
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
{ errcode: "M_LIMIT_EXCEEDED" },
@@ -659,7 +690,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive);
// Hit rate limit
@@ -680,8 +711,8 @@ describe.each([
});
});
describe("unrecoverable errors", () => {
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation !FailsForLegacy", async () => {
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries for initial delayed event creation", async () => {
const delayEventSendError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new MatrixError(
@@ -692,7 +723,7 @@ describe.each([
new Headers({ "Retry-After": "2" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventSendError);
for (let i = 0; i < 10; i++) {
@@ -700,8 +731,8 @@ describe.each([
}
expect(delayEventSendError).toHaveBeenCalled();
});
// !FailsForLegacy because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries !FailsForLegacy", async () => {
// because legacy does not have a retry limit and no mechanism to communicate unrecoverable errors.
it("throws, when reaching maximum number of retries", async () => {
const delayEventRestartError = jest.fn();
(client._unstable_updateDelayedEvent as Mock<any>).mockRejectedValue(
new MatrixError(
@@ -712,7 +743,7 @@ describe.each([
new Headers({ "Retry-After": "1" }),
),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, delayEventRestartError);
for (let i = 0; i < 10; i++) {
@@ -720,19 +751,19 @@ describe.each([
}
expect(delayEventRestartError).toHaveBeenCalled();
});
it("falls back to using pure state events when some error occurs while sending delayed events !FailsForLegacy", async () => {
it("falls back to using pure state events when some error occurs while sending delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 601));
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await waitForMockCall(client.sendStateEvent);
expect(unrecoverableError).not.toHaveBeenCalledWith();
expect(client.sendStateEvent).toHaveBeenCalled();
});
it("retries before failing in case its a network error !FailsForLegacy", async () => {
it("retries before failing in case its a network error", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(new HTTPError("unknown", 501));
const manager = new TestMembershipManager(
const manager = new MembershipManager(
{ networkErrorRetryMs: 1000, maximumNetworkErrorRetryCount: 7 },
room,
client,
@@ -749,12 +780,12 @@ describe.each([
);
expect(client.sendStateEvent).not.toHaveBeenCalled();
});
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events !FailsForLegacy", async () => {
it("falls back to using pure state events when UnsupportedDelayedEventsEndpointError encountered for delayed events", async () => {
const unrecoverableError = jest.fn();
(client._unstable_sendDelayedStateEvent as Mock<any>).mockRejectedValue(
new UnsupportedDelayedEventsEndpointError("not supported", "sendDelayedStateEvent"),
);
const manager = new TestMembershipManager({}, room, client, () => undefined);
const manager = new MembershipManager({}, room, client, () => undefined);
manager.join([focus], focusActive, unrecoverableError);
await jest.advanceTimersByTimeAsync(1);
@@ -777,5 +808,5 @@ it("Should prefix log with MembershipManager used", () => {
expect(spy).toHaveBeenCalled();
const logline: string = spy.mock.calls[0][0];
expect(logline.startsWith("[NewMembershipManager]")).toBe(true);
expect(logline.startsWith("[MembershipManager]")).toBe(true);
});
@@ -0,0 +1,43 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { OutdatedKeyFilter } from "../../../src/matrixrtc/utils.ts";
import { type InboundEncryptionSession } from "../../../src/matrixrtc";
describe("OutdatedKeyFilter Test", () => {
it("Should buffer and disambiguate keys by timestamp", () => {
const filter = new OutdatedKeyFilter();
const aKey = fakeInboundSessionWithTimestamp(1000);
const olderKey = fakeInboundSessionWithTimestamp(300);
// Simulate receiving out of order keys
expect(filter.isOutdated(aKey.participantId, aKey)).toBe(false);
// Then we receive the most recent key out of order
const isOutdated = filter.isOutdated(aKey.participantId, olderKey);
// this key is older and should be ignored even if received after
expect(isOutdated).toBe(true);
});
function fakeInboundSessionWithTimestamp(ts: number): InboundEncryptionSession {
return {
keyIndex: 0,
creationTS: ts,
participantId: "@alice:localhost|ABCDE",
key: new Uint8Array(16),
};
}
});
@@ -0,0 +1,598 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type Mocked } from "jest-mock";
import { RTCEncryptionManager } from "../../../src/matrixrtc/RTCEncryptionManager.ts";
import { type CallMembership, type Statistics } from "../../../src/matrixrtc";
import { type ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap } from "../../../src/matrixrtc/IKeyTransport.ts";
import { membershipTemplate, mockCallMembership } from "./mocks.ts";
import { decodeBase64, TypedEventEmitter } from "../../../src";
import { RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
import { type RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
import type { Logger } from "../../../src/logger.ts";
describe("RTCEncryptionManager", () => {
// The manager being tested
let encryptionManager: RTCEncryptionManager;
let getMembershipMock: jest.Mock;
let mockTransport: Mocked<ToDeviceKeyTransport>;
let statistics: Statistics;
let onEncryptionKeysChanged: jest.Mock;
beforeEach(() => {
statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
getMembershipMock = jest.fn().mockReturnValue([]);
onEncryptionKeysChanged = jest.fn();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: jest.fn(),
off: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
it("should start and stop the transport properly", () => {
encryptionManager.join(undefined);
expect(mockTransport.start).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledTimes(1);
expect(mockTransport.on).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
encryptionManager.leave();
expect(mockTransport.stop).toHaveBeenCalledTimes(1);
expect(mockTransport.off).toHaveBeenCalledWith(KeyTransportEvents.ReceivedKeys, expect.any(Function));
});
describe("Sharing Keys", () => {
it("Set up my key asap even if no key distribution is needed", () => {
getMembershipMock.mockReturnValue([]);
encryptionManager.join(undefined);
// After join it is too early, key might be lost as no one is listening yet
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
encryptionManager.onMembershipsUpdate([]);
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
});
it("Should distribute keys to members on join", async () => {
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
await jest.runOnlyPendingTimersAsync();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
});
it("Should re-distribute keys to members whom callMemberhsip ts has changed", async () => {
let members = [aCallMembership("@bob:example.org", "BOBDEVICE", 1000)];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 1000,
},
],
);
await jest.runOnlyPendingTimersAsync();
// The key should have been rolled out immediately
expect(onEncryptionKeysChanged).toHaveBeenCalled();
mockTransport.sendKey.mockClear();
onEncryptionKeysChanged.mockClear();
members = [aCallMembership("@bob:example.org", "BOBDEVICE", 2000)];
getMembershipMock.mockReturnValue(members);
// There are no membership change but the callMembership ts has changed (reset?)
// Resend the key
encryptionManager.onMembershipsUpdate(members);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// Re send the same key to that user
0,
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE",
membershipTs: 2000,
},
],
);
});
it("Should not rotate key when a user join", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate(updatedMembers);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should not have incremented the key index
0,
// And send it to the newly joined only
[{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1000 }],
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
it("Should not resend keys when no changes", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(members);
// initial rollout
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.runOnlyPendingTimersAsync();
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
mockTransport.sendKey.mockClear();
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(200);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(100);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(50);
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(100);
expect(mockTransport.sendKey).not.toHaveBeenCalled();
});
it("Should rotate key when a user leaves and delay the rollout", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
expect(mockTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key
0,
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
// initial rollout
expect(mockTransport.sendKey).toHaveBeenCalled();
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(1);
onEncryptionKeysChanged.mockClear();
const updatedMembers = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
];
getMembershipMock.mockReturnValue(updatedMembers);
encryptionManager.onMembershipsUpdate(updatedMembers);
await jest.advanceTimersByTimeAsync(200);
// The is rotated but not rolled out yet to give time for the key to be sent
expect(mockTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It should have incremented the key index
1,
// And send it to the updated members
updatedMembers.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
expect(onEncryptionKeysChanged).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1000);
// now should be rolled out
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(2);
});
});
describe("Receiving Keys", () => {
beforeEach(() => {
const emitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
mockTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: emitter.on.bind(emitter),
off: emitter.off.bind(emitter),
emit: emitter.emit.bind(emitter),
} as unknown as Mocked<ToDeviceKeyTransport>;
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
mockTransport,
statistics,
onEncryptionKeysChanged,
);
});
it("should accept keys from transport", async () => {
jest.useFakeTimers();
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE2",
"BBBBBBBBBBB",
4 /* KeyId */,
0 /* Timestamp */,
);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carl:example.org",
"CARLDEVICE",
"CCCCCCCCCC",
8 /* KeyId */,
0 /* Timestamp */,
);
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(4);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("AAAAAAAAAAA"),
0,
"@bob:example.org:BOBDEVICE",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("BBBBBBBBBBB"),
4,
"@bob:example.org:BOBDEVICE2",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
decodeBase64("CCCCCCCCCC"),
8,
"@carl:example.org:CARLDEVICE",
);
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(3);
});
it("Should support quick re-joiner if keys received out of order", async () => {
jest.useFakeTimers();
const members = [aCallMembership("@carl:example.org", "CARLDEVICE")];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
await jest.advanceTimersByTimeAsync(10);
// Simulate Carl leaving then joining back, and key received out of order
const initialKey0TimeStamp = 1000;
const newKey0TimeStamp = 2000;
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"BBBBBBBBBBB",
0 /* KeyId */,
newKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
mockTransport.emit(
KeyTransportEvents.ReceivedKeys,
"@carol:example.org",
"CAROLDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
initialKey0TimeStamp,
);
await jest.advanceTimersByTimeAsync(20);
// The latest key used for carol should be the one with the latest timestamp
expect(onEncryptionKeysChanged).toHaveBeenLastCalledWith(
decodeBase64("BBBBBBBBBBB"),
0,
"@carol:example.org:CAROLDEVICE",
);
});
});
it("Should only rotate once again if several membership changes during a rollout", async () => {
jest.useFakeTimers();
let members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
// The initial rollout
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
0,
"@alice:example.org:DEVICE01",
);
onEncryptionKeysChanged.mockClear();
// Trigger a key rotation with a leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE"), aCallMembership("@bob:example.org", "BOBDEVICE2")];
getMembershipMock.mockReturnValue(members);
// This should start a new key rollout
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(10);
// Now simulate a new leaver
members = [aCallMembership("@bob:example.org", "BOBDEVICE")];
getMembershipMock.mockReturnValue(members);
// The key `1` rollout is in progress
encryptionManager.onMembershipsUpdate(members);
await jest.advanceTimersByTimeAsync(10);
// And another one ( plus a joiner)
const lastMembership = [aCallMembership("@bob:example.org", "BOBDEVICE3")];
getMembershipMock.mockReturnValue(lastMembership);
// The key `1` rollout is still in progress
encryptionManager.onMembershipsUpdate(lastMembership);
await jest.advanceTimersByTimeAsync(10);
// Let all rollouts finish
await jest.advanceTimersByTimeAsync(2000);
// There should 2 rollout. The `1` rollout, then just one additional one
// that has "buffered" the 2 membership changes with leavers
expect(onEncryptionKeysChanged).toHaveBeenCalledTimes(2);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
1,
"@alice:example.org:DEVICE01",
);
expect(onEncryptionKeysChanged).toHaveBeenCalledWith(
expect.any(Uint8Array<ArrayBufferLike>),
2,
"@alice:example.org:DEVICE01",
);
// Key `2` should only be distributed to the last membership
expect(mockTransport.sendKey).toHaveBeenLastCalledWith(
expect.any(String),
2,
// And send only to the last membership
[
{
userId: "@bob:example.org",
deviceId: "BOBDEVICE3",
membershipTs: 1000,
},
],
);
});
it("Should re-distribute key on transport switch", async () => {
const toDeviceEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockToDeviceTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: toDeviceEmitter.on.bind(toDeviceEmitter),
off: toDeviceEmitter.off.bind(toDeviceEmitter),
emit: toDeviceEmitter.emit.bind(toDeviceEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<ToDeviceKeyTransport>;
const roomEmitter = new TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>();
const mockRoomTransport = {
start: jest.fn(),
stop: jest.fn(),
sendKey: jest.fn().mockResolvedValue(undefined),
on: roomEmitter.on.bind(roomEmitter),
off: roomEmitter.off.bind(roomEmitter),
emit: roomEmitter.emit.bind(roomEmitter),
setParentLogger: jest.fn(),
} as unknown as Mocked<RoomKeyTransport>;
const mockLogger = {
debug: jest.fn(),
warn: jest.fn(),
} as unknown as Mocked<Logger>;
const transport = new RoomAndToDeviceTransport(mockToDeviceTransport, mockRoomTransport, {
getChild: jest.fn().mockReturnValue(mockLogger),
} as unknown as Mocked<Logger>);
encryptionManager = new RTCEncryptionManager(
"@alice:example.org",
"DEVICE01",
getMembershipMock,
transport,
statistics,
onEncryptionKeysChanged,
);
const members = [
aCallMembership("@bob:example.org", "BOBDEVICE"),
aCallMembership("@bob:example.org", "BOBDEVICE2"),
aCallMembership("@carl:example.org", "CARLDEVICE"),
];
getMembershipMock.mockReturnValue(members);
// Let's join
encryptionManager.join(undefined);
encryptionManager.onMembershipsUpdate([]);
await jest.advanceTimersByTimeAsync(10);
// Should have sent the key to the toDevice transport
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledTimes(1);
expect(mockRoomTransport.sendKey).not.toHaveBeenCalled();
// Simulate receiving a key by room transport
roomEmitter.emit(
KeyTransportEvents.ReceivedKeys,
"@bob:example.org",
"BOBDEVICE",
"AAAAAAAAAAA",
0 /* KeyId */,
0 /* Timestamp */,
);
await jest.runOnlyPendingTimersAsync();
// The key should have beed re-distributed to the room transport
expect(mockRoomTransport.sendKey).toHaveBeenCalled();
expect(mockToDeviceTransport.sendKey).toHaveBeenCalledWith(
expect.any(String),
// It is the first key re-distributed
0,
// to all the members
members.map((m) => ({ userId: m.sender, deviceId: m.deviceId, membershipTs: m.createdTs() })),
);
});
function aCallMembership(userId: string, deviceId: string, ts: number = 1000): CallMembership {
return mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: deviceId, created_ts: ts }),
"!room:id",
userId,
);
}
});
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock";
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
import { makeKey, makeMockEvent, makeMockRoom } from "./mocks";
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import {
@@ -24,7 +24,7 @@ import {
mockClientMethodsEvents,
mockClientMethodsUser,
} from "../../test-utils/client.ts";
import { type Statistics } from "../../../src/matrixrtc";
import { type ParticipantDeviceInfo, type Statistics } from "../../../src/matrixrtc";
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
import { type Logger } from "../../../src/logger.ts";
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
@@ -88,7 +88,9 @@ describe("RoomAndToDeviceTransport", () => {
});
it("only sends to device keys when sending a key", async () => {
transport.start();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "ALICEDEVICE", membershipTs: 1234 },
]);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
expect(transport.enabled.room).toBeFalsy();
@@ -118,7 +120,9 @@ describe("RoomAndToDeviceTransport", () => {
expect(transport.enabled.room).toBeTruthy();
expect(transport.enabled.toDevice).toBeFalsy();
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
await transport.sendKey("1235", 0, [
{ userId: "@alice:example.org", deviceId: "AlICEDEV", membershipTs: 1234 },
]);
expect(sendEventMock).toHaveBeenCalledTimes(1);
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
@@ -132,7 +136,11 @@ describe("RoomAndToDeviceTransport", () => {
});
transport.start();
const membership = mockCallMembership(membershipTemplate, roomId, "@alice:example.org");
const membership: ParticipantDeviceInfo = {
userId: "@alice:example.org",
deviceId: "ALICEDEVICE",
membershipTs: 1234,
};
const onTransportEnabled = jest.fn();
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
@@ -16,7 +16,7 @@ limitations under the License.
import { type Mocked } from "jest-mock";
import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
import { makeMockEvent } from "./mocks";
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
@@ -61,21 +61,9 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
roomId,
"@bob:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
roomId,
"@carl:example.org",
),
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
roomId,
"@mat:example.org",
),
{ userId: "@bob:example.org", deviceId: "BOBDEVICE", membershipTs: 1234 },
{ userId: "@carl:example.org", deviceId: "CARLDEVICE", membershipTs: 1234 },
{ userId: "@mat:example.org", deviceId: "MATDEVICE", membershipTs: 1234 },
]);
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
@@ -100,6 +88,7 @@ describe("ToDeviceKeyTransport", () => {
call_id: "",
scope: "m.room",
},
sent_ts: expect.any(Number),
},
);
@@ -153,11 +142,7 @@ describe("ToDeviceKeyTransport", () => {
const keyBase64Encoded = "ABCDEDF";
const keyIndex = 2;
await transport.sendKey(keyBase64Encoded, keyIndex, [
mockCallMembership(
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
roomId,
"@alice:example.org",
),
{ userId: "@alice:example.org", deviceId: "MYDEVICE", membershipTs: 1234 },
]);
transport.start();
@@ -1,54 +0,0 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
/*
This file adds a custom test environment for the MembershipManager.spec.ts
It can be used with the comment at the top of the file:
@jest-environment ./spec/unit/matrixrtc/memberManagerTestEnvironment.ts
It is very specific to the MembershipManager.spec.ts file and introduces the following behaviour:
- The describe each block in the MembershipManager.spec.ts will go through describe block names `LegacyMembershipManager` and `MembershipManager`
- It will check all tests that are a child or indirect child of the `LegacyMembershipManager` block and skip the ones which include "!FailsForLegacy"
in their test name.
*/
import { TestEnvironment } from "jest-environment-jsdom";
import { logger as rootLogger } from "../../../src/logger";
const logger = rootLogger.getChild("[MatrixRTCSession]");
class MemberManagerTestEnvironment extends TestEnvironment {
handleTestEvent(event: any) {
if (event.name === "test_start" && event.test.name.includes("!FailsForLegacy")) {
let parent = event.test.parent;
let isLegacy = false;
while (parent) {
if (parent.name === "LegacyMembershipManager") {
isLegacy = true;
break;
} else {
parent = parent.parent;
}
}
if (isLegacy) {
logger.info("skip test: ", event.test.name);
event.test.mode = "skip";
}
}
}
}
module.exports = MemberManagerTestEnvironment;
+188
View File
@@ -0,0 +1,188 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Direction, type MatrixClient, MatrixEvent, Room } from "../../../src";
import type { MockedObject } from "jest-mock";
const CREATOR_USER_ID = "@creator:example.org";
const MODERATOR_USER_ID = "@moderator:example.org";
describe("Room", () => {
function createMockClient(): MatrixClient {
return {
supportsThreads: jest.fn().mockReturnValue(true),
decryptEventIfNeeded: jest.fn().mockReturnThis(),
getUserId: jest.fn().mockReturnValue(CREATOR_USER_ID),
} as unknown as MockedObject<MatrixClient>;
}
function createEvent(eventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
body: eventId, // we do this for ease of use, not practicality
},
event_id: eventId,
sender: CREATOR_USER_ID,
});
}
function createRedaction(redactsEventId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.redaction",
redacts: redactsEventId,
event_id: "$redacts_" + redactsEventId.substring(1),
sender: CREATOR_USER_ID,
});
}
function getNonStateMainTimelineLiveEvents(room: Room): Array<MatrixEvent> {
return room
.getLiveTimeline()
.getEvents()
.filter((e) => !e.isState());
}
it("should apply redactions locally", async () => {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
const messageEvent = createEvent("$message_event");
// Set up the room
await room.addLiveEvents([messageEvent], { addToState: false });
let timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(1);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(false); // "should never happen"
// Now redact
const redactionEvent = createRedaction(messageEvent.getId()!);
await room.addLiveEvents([redactionEvent], { addToState: false });
timeline = getNonStateMainTimelineLiveEvents(room);
expect(timeline.length).toEqual(2);
expect(timeline[0].getId()).toEqual(messageEvent.getId());
expect(timeline[0].isRedacted()).toEqual(true); // test case
expect(timeline[1].getId()).toEqual(redactionEvent.getId());
expect(timeline[1].isRedacted()).toEqual(false); // "should never happen"
});
describe("MSC4293: Redact on ban", () => {
async function setupRoom(andGrantPermissions: boolean): Promise<{ room: Room; messageEvents: MatrixEvent[] }> {
const mockClient = createMockClient();
const room = new Room("!room:example.org", mockClient, CREATOR_USER_ID);
// Pre-populate room
const messageEvents: MatrixEvent[] = [];
for (let i = 0; i < 3; i++) {
messageEvents.push(createEvent(`$message_${i}`));
}
await room.addLiveEvents(messageEvents, { addToState: false });
if (andGrantPermissions) {
room.getLiveTimeline().getState(Direction.Forward)!.maySendRedactionForEvent = (ev, userId) => {
return true;
};
}
return { room, messageEvents };
}
function createRedactOnMembershipChange(
targetUserId: string,
senderUserId: string,
membership: string,
): MatrixEvent {
return new MatrixEvent({
type: "m.room.member",
state_key: targetUserId,
content: {
"membership": membership,
"org.matrix.msc4293.redact_events": true,
},
sender: senderUserId,
});
}
function expectRedacted(messageEvents: MatrixEvent[], room: Room, shouldAllBeRedacted: boolean) {
const actualEvents = getNonStateMainTimelineLiveEvents(room).filter((e) =>
messageEvents.find((e2) => e2.getId() === e.getId()),
);
expect(actualEvents.length).toEqual(messageEvents.length);
const redactedEvents = actualEvents.filter((e) => e.isRedacted());
if (shouldAllBeRedacted) {
expect(redactedEvents.length).toEqual(messageEvents.length);
} else {
expect(redactedEvents.length).toEqual(0);
}
}
it("should apply on ban", async () => {
const { room, messageEvents } = await setupRoom(true);
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should apply on kick", async () => {
const { room, messageEvents } = await setupRoom(true);
const kickEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "leave");
await room.addLiveEvents([kickEvent], { addToState: true });
expectRedacted(messageEvents, room, true);
});
it("should not apply if the user doesn't have permission to redact", async () => {
const { room, messageEvents } = await setupRoom(false); // difference from other tests here
const banEvent = createRedactOnMembershipChange(CREATOR_USER_ID, MODERATOR_USER_ID, "ban");
await room.addLiveEvents([banEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to self-leaves", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "leave");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to invites", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "invite");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to joins", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "join");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
it("should not apply to knocks", async () => {
const { room, messageEvents } = await setupRoom(true);
const leaveEvent = createRedactOnMembershipChange(CREATOR_USER_ID, CREATOR_USER_ID, "knock");
await room.addLiveEvents([leaveEvent], { addToState: true });
expectRedacted(messageEvents, room, false);
});
});
});
+4 -3
View File
@@ -28,6 +28,7 @@ import {
TweakName,
} from "../../src";
import { mockClientMethodsUser } from "../test-utils/client";
import { logger } from "../../src/logger.ts";
const msc3914RoomCallRule: IPushRule = {
rule_id: ".org.matrix.msc3914.rule.room.call",
@@ -209,7 +210,7 @@ describe("NotificationService", function () {
msgtype: "m.text",
},
});
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(matrixClient.pushRules!);
matrixClient.pushRules = PushProcessor.rewriteDefaultRules(logger, matrixClient.pushRules!);
pushProcessor = new PushProcessor(matrixClient);
});
@@ -731,7 +732,7 @@ describe("Test PushProcessor.partsForDottedKey", function () {
describe("rewriteDefaultRules", () => {
it("should add default rules in the correct order", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
device: {},
global: {
content: [],
@@ -867,7 +868,7 @@ describe("rewriteDefaultRules", () => {
});
it("should add missing msc3914 rule in correct place", () => {
const pushRules = PushProcessor.rewriteDefaultRules({
const pushRules = PushProcessor.rewriteDefaultRules(logger, {
device: {},
global: {
// Sample push rules from a Synapse user.
@@ -20,6 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { CrossSigningIdentity } from "../../../src/rust-crypto/CrossSigningIdentity";
import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { type ServerSideSecretStorage } from "../../../src/secret-storage";
import { logger } from "../../../src/logger.ts";
describe("CrossSigningIdentity", () => {
describe("bootstrapCrossSigning", () => {
@@ -55,7 +56,7 @@ describe("CrossSigningIdentity", () => {
store: jest.fn(),
} as unknown as Mocked<ServerSideSecretStorage>;
crossSigning = new CrossSigningIdentity(olmMachine, outgoingRequestProcessor, secretStorage);
crossSigning = new CrossSigningIdentity(logger, olmMachine, outgoingRequestProcessor, secretStorage);
});
it("should do nothing if keys are present on-device and in secret storage", async () => {
@@ -53,7 +53,7 @@ describe("KeyClaimManager", () => {
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
const outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, httpApi);
const outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
keyClaimManager = new KeyClaimManager(olmMachine, outgoingRequestProcessor);
});
@@ -40,6 +40,7 @@ import {
type UIAuthCallback,
} from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { logger } from "../../../src/logger.ts";
describe("OutgoingRequestProcessor", () => {
/** the OutgoingRequestProcessor implementation under test */
@@ -76,7 +77,7 @@ describe("OutgoingRequestProcessor", () => {
markRequestAsSent: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
/* simple requests that map directly to the request body */
@@ -293,7 +294,7 @@ describe("OutgoingRequestProcessor", () => {
return await authRequestResultResolvers.promise;
},
} as unknown as Mocked<MatrixHttpApi<IHttpOpts & { onlyData: true }>>;
processor = new OutgoingRequestProcessor(olmMachine, mockHttpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, mockHttpApi);
});
// build a request
@@ -325,7 +326,7 @@ describe("OutgoingRequestProcessor", () => {
onlyData: true,
});
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
processor = new OutgoingRequestProcessor(logger, olmMachine, httpApi);
});
afterEach(() => {
@@ -36,6 +36,7 @@ import {
AllDevicesIsolationMode,
OnlySignedDevicesIsolationMode,
} from "../../../src/crypto-api";
import { logger } from "../../../src/logger.ts";
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
@@ -108,6 +109,7 @@ describe("RoomEncryptor", () => {
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
logger,
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
+2 -1
View File
@@ -8,6 +8,7 @@ import { type OutgoingRequestProcessor } from "../../../src/rust-crypto/Outgoing
import * as testData from "../../test-utils/test-data";
import * as TestData from "../../test-utils/test-data";
import { RustBackupManager, type KeyBackup } from "../../../src/rust-crypto/backup";
import { logger } from "../../../src/logger.ts";
describe("Upload keys to backup", () => {
/** The backup manager under test */
@@ -63,7 +64,7 @@ describe("Upload keys to backup", () => {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
rustBackupManager = new RustBackupManager(logger, mockOlmMachine, httpAPi, outgoingRequestProcessor);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
});
+3 -3
View File
@@ -551,11 +551,11 @@ describe("RustCrypto", () => {
const inputs: IToDeviceEvent[] = [
{ content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" },
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message);
expect(res).toEqual(inputs);
});
it("should pass through bad encrypted messages", async () => {
it("should fail to process bad encrypted messages", async () => {
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const keys = olmMachine.identityKeys;
const inputs: IToDeviceEvent[] = [
@@ -576,7 +576,7 @@ describe("RustCrypto", () => {
];
const res = await rustCrypto.preprocessToDeviceMessages(inputs);
expect(res).toEqual(inputs);
expect(res.length).toEqual(0);
});
it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => {
+13 -10
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { ToDeviceMessageId } from "./@types/event.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
import { type MatrixClient, ClientEvent } from "./client.ts";
import { type MatrixError } from "./http-api/index.ts";
import {
@@ -40,7 +40,10 @@ export class ToDeviceMessageQueue {
private retryTimeout: ReturnType<typeof setTimeout> | null = null;
private retryAttempts = 0;
public constructor(private client: MatrixClient) {}
public constructor(
private client: MatrixClient,
private readonly logger: Logger,
) {}
public start(): void {
this.running = true;
@@ -67,7 +70,7 @@ export class ToDeviceMessageQueue {
const msgmap = batchWithTxnId.batch.map(
(msg) => `${msg.userId}/${msg.deviceId} (msgid ${msg.payload[ToDeviceMessageId]})`,
);
logger.info(
this.logger.info(
`Enqueuing batch of to-device messages. type=${batch.eventType} txnid=${batchWithTxnId.txnId}`,
msgmap,
);
@@ -83,7 +86,7 @@ export class ToDeviceMessageQueue {
if (this.sending || !this.running) return;
logger.debug("Attempting to send queued to-device messages");
this.logger.debug("Attempting to send queued to-device messages");
this.sending = true;
let headBatch: IndexedToDeviceBatch | null;
@@ -99,7 +102,7 @@ export class ToDeviceMessageQueue {
// Make sure we're still running after the async tasks: if not, stop.
if (!this.running) return;
logger.debug("All queued to-device messages sent");
this.logger.debug("All queued to-device messages sent");
} catch (e) {
++this.retryAttempts;
// eslint-disable-next-line @typescript-eslint/naming-convention
@@ -109,15 +112,15 @@ export class ToDeviceMessageQueue {
// the scheduler function doesn't differentiate between fatal errors and just getting
// bored and giving up for now
if (Math.floor((<MatrixError>e).httpStatus! / 100) === 4) {
logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
this.logger.error("Fatal error when sending to-device message - dropping to-device batch!", e);
await this.client.store.removeToDeviceBatch(headBatch!.id);
} else {
logger.info("Automatic retry limit reached for to-device messages.");
this.logger.info("Automatic retry limit reached for to-device messages.");
}
return;
}
logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.logger.info(`Failed to send batch of to-device messages. Will retry in ${retryDelay}ms`, e);
this.retryTimeout = setTimeout(this.sendQueue, retryDelay);
} finally {
this.sending = false;
@@ -133,7 +136,7 @@ export class ToDeviceMessageQueue {
contentMap.getOrCreate(item.userId).set(item.deviceId, item.payload);
}
logger.info(
this.logger.info(
`Sending batch of ${batch.batch.length} to-device messages with ID ${batch.id} and txnId ${batch.txnId}`,
);
@@ -146,7 +149,7 @@ export class ToDeviceMessageQueue {
*/
private onResumedSync = (state: SyncState | null, oldState: SyncState | null): void => {
if (state === SyncState.Syncing && oldState !== SyncState.Syncing) {
logger.info(`Resuming queue after resumed sync`);
this.logger.info(`Resuming queue after resumed sync`);
this.sendQueue();
}
};
+162 -129
View File
@@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t
import { type MatrixScheduler } from "./scheduler.ts";
import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts";
import { type AuthDict } from "./interactive-auth.ts";
import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts";
import {
type IMinimalEvent,
type IRoomEvent,
type IStateEvent,
type ReceivedToDeviceMessage,
} from "./sync-accumulator.ts";
import type { EventTimelineSet } from "./models/event-timeline-set.ts";
import * as ContentHelpers from "./content-helpers.ts";
import {
@@ -360,7 +365,7 @@ export interface ICreateClientOpts {
* to all requests with this client. Useful for application services which require
* `?user_id=`.
*/
queryParams?: Record<string, string>;
queryParams?: QueryDict;
/**
* Encryption key used for encrypting sensitive data (such as e2ee keys) in {@link ICreateClientOpts#cryptoStore}.
@@ -883,76 +888,6 @@ interface IWhoamiResponse {
const EVENT_ID_PREFIX = "$";
export enum ClientEvent {
Sync = "sync",
Event = "event",
ToDeviceEvent = "toDeviceEvent",
AccountData = "accountData",
Room = "Room",
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
type RoomEvents =
| RoomEvent.Name
| RoomEvent.Redaction
| RoomEvent.RedactionCancelled
| RoomEvent.Receipt
| RoomEvent.Tags
| RoomEvent.LocalEchoUpdated
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.AccountData
| RoomEvent.MyMembership
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
type RoomStateEvents =
| RoomStateEvent.Events
| RoomStateEvent.Members
| RoomStateEvent.NewMember
| RoomStateEvent.Update
| RoomStateEvent.Marker;
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
type RoomMemberEvents =
| RoomMemberEvent.Name
| RoomMemberEvent.Typing
| RoomMemberEvent.PowerLevel
| RoomMemberEvent.Membership;
type UserEvents =
| UserEvent.AvatarUrl
| UserEvent.DisplayName
| UserEvent.Presence
| UserEvent.CurrentlyActive
| UserEvent.LastPresenceTs;
export type EmittedEvents =
| ClientEvent
| RoomEvents
| RoomStateEvents
| CryptoEvents
| MatrixEventEvents
| RoomMemberEvents
| UserEvents
| CallEvent // re-emitted by call.ts using Object.values
| CallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Outgoing
| GroupCallEventHandlerEvent.Ended
| GroupCallEventHandlerEvent.Participants
| HttpApiEvent.SessionLoggedOut
| HttpApiEvent.NoConsent
| BeaconEvent;
export type ClientEventHandlerMap = {
/**
* Fires whenever the SDK's syncing state is updated. The state can be one of:
* <ul>
@@ -1035,13 +970,15 @@ export type ClientEventHandlerMap = {
* trying to sync after stopClient has been called.</li>
* </ul>
*
* @param state - An enum representing the syncing state. One of "PREPARED",
* The payloads consits of the following 3 parameters:
*
* - state - An enum representing the syncing state. One of "PREPARED",
* "SYNCING", "ERROR", "STOPPED".
*
* @param prevState - An enum representing the previous syncing state.
* - prevState - An enum representing the previous syncing state.
* One of "PREPARED", "SYNCING", "ERROR", "STOPPED" <b>or null</b>.
*
* @param data - Data about this transition.
* - data - Data about this transition.
*
* @example
* ```
@@ -1061,14 +998,14 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
Sync = "sync",
/**
* Fires whenever the SDK receives a new event.
* <p>
* This is only fired for live events received via /sync - it is not fired for
* events received over context, search, or pagination APIs.
*
* @param event - The matrix event which caused this event to fire.
* The payload is the matrix event which caused this event to fire.
* @example
* ```
* matrixClient.on("event", function(event){
@@ -1076,10 +1013,10 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.Event]: (event: MatrixEvent) => void;
/**
Event = "event",
/** @deprecated Use {@link ReceivedToDeviceMessage}.
* Fires whenever the SDK receives a new to-device event.
* @param event - The matrix event which caused this event to fire.
* The payload is the matrix event ({@link MatrixEvent}) which caused this event to fire.
* @example
* ```
* matrixClient.on("toDeviceEvent", function(event){
@@ -1087,8 +1024,69 @@ export type ClientEventHandlerMap = {
* });
* ```
*/
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
ToDeviceEvent = "toDeviceEvent",
/**
* Fires whenever the SDK receives a new (potentially decrypted) to-device message.
* The payload is the to-device message and the encryption info for that message ({@link ReceivedToDeviceMessage}).
* @example
* ```
* matrixClient.on("receivedToDeviceMessage", function(payload){
* const { message, encryptionInfo } = payload;
* var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender;
* var isVerified = encryptionInfo ? encryptionInfo.verified : false;
* var type = message.type;
* });
*/
ReceivedToDeviceMessage = "receivedToDeviceMessage",
/**
* Fires whenever new user-scoped account_data is added.
* The payload is a pair of event ({@link MatrixEvent}) describing the account_data just added, and the previous event, if known:
* - event: The event describing the account_data just added
* - oldEvent: The previous account data, if known.
* @example
* ```
* matrixClient.on("accountData", function(event, oldEvent){
* myAccountData[event.type] = event.content;
* });
* ```
*/
AccountData = "accountData",
/**
* Fires whenever a new Room is added. This will fire when you are invited to a
* room, as well as when you join a room. <strong>This event is experimental and
* may change.</strong>
*
* The payload is the newly created room, fully populated.
* @example
* ```
* matrixClient.on("Room", function(room){
* var roomId = room.roomId;
* });
* ```
*/
Room = "Room",
/**
* Fires whenever a Room is removed. This will fire when you forget a room.
* <strong>This event is experimental and may change.</strong>
* The payload is the roomId of the deleted room.
* @example
* ```
* matrixClient.on("deleteRoom", function(roomId){
* // update UI from getRooms()
* });
* ```
*/
DeleteRoom = "deleteRoom",
SyncUnexpectedError = "sync.unexpectedError",
/**
* Fires when the client .well-known info is fetched.
* The payload is the JSON object (see {@link IClientWellKnown}) returned by the server
*/
ClientWellKnown = "WellKnown.client",
ReceivedVoipEvent = "received_voip_event",
/**
* @deprecated This event is not supported anymore.
*
* Fires if a to-device event is received that cannot be decrypted.
* Encrypted to-device events will (generally) use plain Olm encryption,
* in which case decryption failures are fatal: the event will never be
@@ -1097,52 +1095,78 @@ export type ClientEventHandlerMap = {
*
* An undecryptable to-device event is therefore likely to indicate problems.
*
* @param event - The undecyptable to-device event
* The payload is the undecyptable to-device event
*/
UndecryptableToDeviceEvent = "toDeviceEvent.undecryptable",
TurnServers = "turnServers",
TurnServersError = "turnServers.error",
}
type RoomEvents =
| RoomEvent.Name
| RoomEvent.Redaction
| RoomEvent.RedactionCancelled
| RoomEvent.Receipt
| RoomEvent.Tags
| RoomEvent.LocalEchoUpdated
| RoomEvent.HistoryImportedWithinTimeline
| RoomEvent.AccountData
| RoomEvent.MyMembership
| RoomEvent.Timeline
| RoomEvent.TimelineReset;
type RoomStateEvents =
| RoomStateEvent.Events
| RoomStateEvent.Members
| RoomStateEvent.NewMember
| RoomStateEvent.Update
| RoomStateEvent.Marker;
type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent];
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
type RoomMemberEvents =
| RoomMemberEvent.Name
| RoomMemberEvent.Typing
| RoomMemberEvent.PowerLevel
| RoomMemberEvent.Membership;
type UserEvents =
| UserEvent.AvatarUrl
| UserEvent.DisplayName
| UserEvent.Presence
| UserEvent.CurrentlyActive
| UserEvent.LastPresenceTs;
export type EmittedEvents =
| ClientEvent
| RoomEvents
| RoomStateEvents
| CryptoEvents
| MatrixEventEvents
| RoomMemberEvents
| UserEvents
| CallEvent // re-emitted by call.ts using Object.values
| CallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Incoming
| GroupCallEventHandlerEvent.Outgoing
| GroupCallEventHandlerEvent.Ended
| GroupCallEventHandlerEvent.Participants
| HttpApiEvent.SessionLoggedOut
| HttpApiEvent.NoConsent
| BeaconEvent;
export type ClientEventHandlerMap = {
[ClientEvent.Sync]: (state: SyncState, prevState: SyncState | null, data?: ISyncStateData) => void;
[ClientEvent.Event]: (event: MatrixEvent) => void;
[ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void;
[ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void;
[ClientEvent.UndecryptableToDeviceEvent]: (event: MatrixEvent) => void;
/**
* Fires whenever new user-scoped account_data is added.
* @param event - The event describing the account_data just added
* @param event - The previous account data, if known.
* @example
* ```
* matrixClient.on("accountData", function(event, oldEvent){
* myAccountData[event.type] = event.content;
* });
* ```
*/
[ClientEvent.AccountData]: (event: MatrixEvent, lastEvent?: MatrixEvent) => void;
/**
* Fires whenever a new Room is added. This will fire when you are invited to a
* room, as well as when you join a room. <strong>This event is experimental and
* may change.</strong>
* @param room - The newly created, fully populated room.
* @example
* ```
* matrixClient.on("Room", function(room){
* var roomId = room.roomId;
* });
* ```
*/
[ClientEvent.Room]: (room: Room) => void;
/**
* Fires whenever a Room is removed. This will fire when you forget a room.
* <strong>This event is experimental and may change.</strong>
* @param roomId - The deleted room ID.
* @example
* ```
* matrixClient.on("deleteRoom", function(roomId){
* // update UI from getRooms()
* });
* ```
*/
[ClientEvent.DeleteRoom]: (roomId: string) => void;
[ClientEvent.SyncUnexpectedError]: (error: Error) => void;
/**
* Fires when the client .well-known info is fetched.
*
* @param data - The JSON object returned by the server
*/
[ClientEvent.ClientWellKnown]: (data: IClientWellKnown) => void;
[ClientEvent.ReceivedVoipEvent]: (event: MatrixEvent) => void;
[ClientEvent.TurnServers]: (servers: ITurnServer[]) => void;
@@ -1341,9 +1365,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// NB. We initialise MatrixRTC whether we have call support or not: this is just
// the underlying session management and doesn't use any actual media capabilities
this.matrixRTC = new MatrixRTCSessionManager(this);
this.matrixRTC = new MatrixRTCSessionManager(this.logger, this);
this.serverCapabilitiesService = new ServerCapabilities(this.http);
this.serverCapabilitiesService = new ServerCapabilities(this.logger, this.http);
this.on(ClientEvent.Sync, this.fixupRoomNotifications);
@@ -1365,7 +1389,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.roomNameGenerator = opts.roomNameGenerator;
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this, this.logger);
// The SDK doesn't really provide a clean way for events to recalculate the push
// actions for themselves, so we have to kinda help them out when they are encrypted.
@@ -1482,6 +1506,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
return this.canResetTimelineCallback(roomId);
},
logger: this.logger.getChild("sync"),
};
}
@@ -1527,9 +1552,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Clear any data out of the persistent stores used by the client.
*
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
* @returns Promise which resolves when the stores have been cleared.
*/
public clearStores(): Promise<void> {
public clearStores(
args: {
cryptoDatabasePrefix?: string;
} = {},
): Promise<void> {
if (this.clientRunning) {
throw new Error("Cannot clear stores while client is running");
}
@@ -1552,8 +1582,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return;
}
for (const dbname of [
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
`${RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto`,
`${args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX}::matrix-sdk-crypto-meta`,
]) {
const prom = new Promise((resolve, reject) => {
this.logger.info(`Removing IndexedDB instance ${dbname}`);
@@ -1901,6 +1931,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* ensuring that only one `MatrixClient` issue is instantiated at a time.
*
* @param args.useIndexedDB - True to use an indexeddb store, false to use an in-memory store. Defaults to 'true'.
* @param args.cryptoDatabasePrefix - The database name to use for indexeddb, defaults to 'matrix-js-sdk'.
* Unused if useIndexedDB is 'false'.
* @param args.storageKey - A key with which to encrypt the indexeddb store. If provided, it must be exactly
* 32 bytes of data, and must be the same each time the client is initialised for a given device.
* If both this and `storagePassword` are unspecified, the store will be unencrypted.
@@ -1914,6 +1946,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async initRustCrypto(
args: {
useIndexedDB?: boolean;
cryptoDatabasePrefix?: string;
storageKey?: Uint8Array;
storagePassword?: string;
} = {},
@@ -1950,7 +1983,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
deviceId: deviceId,
secretStorage: this.secretStorage,
cryptoCallbacks: this.cryptoCallbacks,
storePrefix: args.useIndexedDB === false ? null : RUST_SDK_STORE_PREFIX,
storePrefix: args.useIndexedDB === false ? null : (args.cryptoDatabasePrefix ?? RUST_SDK_STORE_PREFIX),
storeKey: args.storageKey,
storePassphrase: args.storagePassword,
@@ -7295,7 +7328,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setPushRules(rules: IPushRules): void {
// Fix-up defaults, if applicable.
this.pushRules = PushProcessor.rewriteDefaultRules(rules, this.getUserId()!);
this.pushRules = PushProcessor.rewriteDefaultRules(this.logger, rules, this.getUserId()!);
// Pre-calculate any necessary caches.
this.pushProcessor.updateCachedPushRuleKeys(this.pushRules);
}
+5 -3
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts";
import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
@@ -96,9 +96,11 @@ export interface SyncCryptoCallbacks {
* messages, rather than the results of any decryption attempts.
*
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
* @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or
* failed to decrypt, and so will be omitted from the output list.
*
*/
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]>;
/**
* Called by the /sync loop when one time key counts and unused fallback key details are received.
+21
View File
@@ -1365,6 +1365,27 @@ export interface OwnDeviceKeys {
curve25519: string;
}
/**
* Information about the encryption of a successfully decrypted to-device message.
*/
export interface OlmEncryptionInfo {
/** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/
sender: string;
/**
* The device ID of the device that sent us the event.
* Note this is untrusted data unless {@link senderVerified} is true.
* If the device ID is not known, this will be `null`.
**/
senderDevice?: string;
/** The sender device's public Curve25519 key, base64 encoded **/
senderCurve25519KeyBase64: string;
/**
* If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified.
* This is the state at the time of decryption (the user could be verified later).
*/
senderVerified: boolean;
}
export * from "./verification.ts";
export type * from "./keybackup.ts";
export * from "./recovery-key.ts";
+13 -11
View File
@@ -99,17 +99,19 @@ export class FilterComponent {
* Converts the filter component into the form expected over the wire
*/
public toJSON(): object {
return {
types: this.filterJson.types || null,
not_types: this.filterJson.not_types || [],
rooms: this.filterJson.rooms || null,
not_rooms: this.filterJson.not_rooms || [],
senders: this.filterJson.senders || null,
not_senders: this.filterJson.not_senders || [],
contains_url: this.filterJson.contains_url || null,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name] || [],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name] || [],
};
return Object.fromEntries(
Object.entries({
types: this.filterJson.types,
not_types: this.filterJson.not_types,
rooms: this.filterJson.rooms,
not_rooms: this.filterJson.not_rooms,
senders: this.filterJson.senders,
not_senders: this.filterJson.not_senders,
contains_url: this.filterJson.contains_url,
[FILTER_RELATED_BY_SENDERS.name]: this.filterJson[FILTER_RELATED_BY_SENDERS.name],
[FILTER_RELATED_BY_REL_TYPES.name]: this.filterJson[FILTER_RELATED_BY_REL_TYPES.name],
}).filter(([_key, value]) => value),
);
}
/**
+5 -2
View File
@@ -379,9 +379,12 @@ export class FetchHttpApi<O extends IHttpOpts> {
? baseUrlWithFallback.slice(0, -1)
: baseUrlWithFallback;
const url = new URL(baseUrlWithoutTrailingSlash + (prefix ?? this.opts.prefix) + path);
if (queryParams) {
encodeParams(queryParams, url.searchParams);
// If there are any params, encode and append them to the URL.
if (this.opts.extraParams || queryParams) {
const mergedParams = { ...this.opts.extraParams, ...queryParams };
encodeParams(mergedParams, url.searchParams);
}
return url;
}
}
+2 -1
View File
@@ -16,6 +16,7 @@ limitations under the License.
import { type MatrixError } from "./errors.ts";
import { type Logger } from "../logger.ts";
import { type QueryDict } from "../utils.ts";
export type Body = Record<string, any> | BodyInit;
@@ -52,7 +53,7 @@ export interface IHttpOpts {
baseUrl: string;
idBaseUrl?: string;
prefix: string;
extraParams?: Record<string, string>;
extraParams?: QueryDict;
accessToken?: string;
/**
+3
View File
@@ -160,6 +160,9 @@ function getPrefixedLogger(prefix?: string): PrefixedLogger {
/**
* Drop-in replacement for `console` using {@link https://www.npmjs.com/package/loglevel|loglevel}.
* Can be tailored down to specific use cases if needed.
*
* @deprecated avoid the use of this unless you are the constructor of `MatrixClient`: you should be using the logger
* associated with `MatrixClient`.
*/
export const logger = getPrefixedLogger() as LoggerWithLogMethod;
+18 -3
View File
@@ -6,6 +6,7 @@ import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type Statistics } from "./types.ts";
import { getParticipantId } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
@@ -42,6 +43,10 @@ export interface IEncryptionManager {
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
* @deprecated This method is used internally for testing. It is also used to re-emit keys when there is a change
* of RTCSession (matrixKeyProvider#setRTCSession) -Not clear why/when switch RTCSession would occur-. Note that if we switch focus, we do keep the same RTC session,
* so no need to re-emit. But it requires the encryption manager to store all keys of all participants, and this is already done
* by the key provider. We don't want to add another layer of key storage.
*/
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
}
@@ -82,6 +87,7 @@ export class EncryptionManager implements IEncryptionManager {
private latestGeneratedKeyIndex = -1;
private joinConfig: EncryptionConfig | undefined;
private logger: Logger;
public constructor(
private userId: string,
private deviceId: string,
@@ -280,7 +286,18 @@ export class EncryptionManager implements IEncryptionManager {
try {
this.statistics.counters.roomEventEncryptionKeysSent += 1;
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
const targets = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, targets);
this.logger.debug(
`sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys,
@@ -408,8 +425,6 @@ export class EncryptionManager implements IEncryptionManager {
};
}
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
+2 -2
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { type CallMembership } from "./CallMembership.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
export enum KeyTransportEvents {
ReceivedKeys = "received_keys",
@@ -45,7 +45,7 @@ export interface IKeyTransport {
* @param index
* @param members - The participants that should get they key
*/
sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void>;
sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void>;
/** Subscribe to keys from this transport. */
on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this;
-414
View File
@@ -1,414 +0,0 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import type { MatrixClient } from "../client.ts";
import { HTTPError, MatrixError } from "../http-api/errors.ts";
import { logger } from "../logger.ts";
import { EventTimeline } from "../models/event-timeline.ts";
import { type Room } from "../models/room.ts";
import { sleep } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { type EmptyObject } from "../@types/common.ts";
import { Status } from "./types.ts";
import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts";
/**
* This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session.
*
* Its responsibitiy is to manage the locals user membership:
* - send that sate event
* - send the delayed leave event
* - update the delayed leave event while connected
* - update the state event when it times out (for calls longer than membershipExpiryTimeout ~ 4h)
*
* It is possible to test this class on its own. The api surface (to use for tests) is
* defined in `MembershipManagerInterface`.
*
* It is recommended to only use this interface for testing to allow replacing this class.
*
* @internal
* @deprecated Use {@link MembershipManager} instead
*/
export class LegacyMembershipManager implements IMembershipManager {
private relativeExpiry: number | undefined;
private memberEventTimeout?: ReturnType<typeof setTimeout>;
/**
* This is a Foci array that contains the Focus objects this user is aware of and proposes to use.
*/
private ownFociPreferred?: Focus[];
/**
* This is a Focus with the specified fields for an ActiveFocus (e.g. LivekitFocusActive for type="livekit")
*/
private ownFocusActive?: Focus;
private updateCallMembershipRunning = false;
private needCallMembershipUpdate = false;
/**
* If the server disallows the configured {@link delayedLeaveEventDelayMs},
* this stores a delay that the server does allow.
*/
private delayedLeaveEventDelayMsOverride?: number;
private disconnectDelayId: string | undefined;
private get networkErrorRetryMs(): number {
return this.joinConfig?.networkErrorRetryMs ?? this.joinConfig?.callMemberEventRetryDelayMinimum ?? 3_000;
}
private get membershipEventExpiryMs(): number {
return (
this.joinConfig?.membershipEventExpiryMs ??
this.joinConfig?.membershipExpiryTimeout ??
DEFAULT_EXPIRE_DURATION
);
}
private get delayedLeaveEventDelayMs(): number {
return (
this.delayedLeaveEventDelayMsOverride ??
this.joinConfig?.delayedLeaveEventDelayMs ??
this.joinConfig?.membershipServerSideExpiryTimeout ??
8_000
);
}
private get delayedLeaveEventRestartMs(): number {
return this.joinConfig?.delayedLeaveEventRestartMs ?? this.joinConfig?.membershipKeepAlivePeriod ?? 5_000;
}
public constructor(
private joinConfig: MembershipConfig | undefined,
private room: Pick<Room, "getLiveTimeline" | "roomId" | "getVersion">,
private client: Pick<
MatrixClient,
| "getUserId"
| "getDeviceId"
| "sendStateEvent"
| "_unstable_sendDelayedStateEvent"
| "_unstable_updateDelayedEvent"
>,
private getOldestMembership: () => CallMembership | undefined,
) {}
public off(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("off is not implemented on LegacyMembershipManager");
return this;
}
public on(
event: MembershipManagerEvent.StatusChanged,
listener: (oldStatus: Status, newStatus: Status) => void,
): this {
logger.error("on is not implemented on LegacyMembershipManager");
return this;
}
public isJoined(): boolean {
return this.relativeExpiry !== undefined;
}
public isActivated(): boolean {
return this.isJoined();
}
/**
* Unimplemented
* @returns Status.Unknown
*/
public get status(): Status {
return Status.Unknown;
}
public join(fociPreferred: Focus[], fociActive?: Focus): void {
this.ownFocusActive = fociActive;
this.ownFociPreferred = fociPreferred;
this.relativeExpiry = this.membershipEventExpiryMs;
// We don't wait for this, mostly because it may fail and schedule a retry, so this
// function returning doesn't really mean anything at all.
void this.triggerCallMembershipEventUpdate();
}
public async leave(timeout: number | undefined = undefined): Promise<boolean> {
this.relativeExpiry = undefined;
this.ownFocusActive = undefined;
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
if (timeout) {
// The sleep promise returns the string 'timeout' and the membership update void
// A success implies that the membership update was quicker then the timeout.
const raceResult = await Promise.race([this.triggerCallMembershipEventUpdate(), sleep(timeout, "timeout")]);
return raceResult !== "timeout";
} else {
await this.triggerCallMembershipEventUpdate();
return true;
}
}
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const isMyMembership = (m: CallMembership): boolean =>
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
if (this.isJoined() && !memberships.some(isMyMembership)) {
logger.warn("Missing own membership: force re-join");
// TODO: Should this be awaited? And is there anything to tell the focus?
return this.triggerCallMembershipEventUpdate();
}
}
public getActiveFocus(): Focus | undefined {
if (this.ownFocusActive) {
// A livekit active focus
if (isLivekitFocusActive(this.ownFocusActive)) {
if (this.ownFocusActive.focus_selection === "oldest_membership") {
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
} else {
logger.warn("Unknown own ActiveFocus type. This makes it impossible to connect to an SFU.");
}
} else {
// We do not understand the membership format (could be legacy). We default to oldestMembership
// Once there are other methods this is a hard error!
const oldestMembership = this.getOldestMembership();
return oldestMembership?.getPreferredFoci()[0];
}
}
private triggerCallMembershipEventUpdate = async (): Promise<void> => {
// TODO: Should this await on a shared promise?
if (this.updateCallMembershipRunning) {
this.needCallMembershipUpdate = true;
return;
}
this.updateCallMembershipRunning = true;
try {
// if anything triggers an update while the update is running, do another update afterwards
do {
this.needCallMembershipUpdate = false;
await this.updateCallMembershipEvent();
} while (this.needCallMembershipUpdate);
} finally {
this.updateCallMembershipRunning = false;
}
};
private makeNewMembership(deviceId: string): SessionMembershipData | EmptyObject {
// If we're joined, add our own
if (this.isJoined()) {
return this.makeMyMembership(deviceId);
}
return {};
}
/**
* Constructs our own membership
*/
private makeMyMembership(deviceId: string): SessionMembershipData {
return {
call_id: "",
scope: "m.room",
application: "m.call",
device_id: deviceId,
expires: this.relativeExpiry,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
};
}
private async updateCallMembershipEvent(): Promise<void> {
if (this.memberEventTimeout) {
clearTimeout(this.memberEventTimeout);
this.memberEventTimeout = undefined;
}
const roomState = this.room.getLiveTimeline().getState(EventTimeline.FORWARDS);
if (!roomState) throw new Error("Couldn't get room state for room " + this.room.roomId);
const localUserId = this.client.getUserId();
const localDeviceId = this.client.getDeviceId();
if (!localUserId || !localDeviceId) throw new Error("User ID or device ID was null!");
let newContent: EmptyObject | SessionMembershipData = {};
// TODO: add back expiary logic to non-legacy events
// previously we checked here if the event is timed out and scheduled a check if not.
// maybe there is a better way.
newContent = this.makeNewMembership(localDeviceId);
try {
if (this.isJoined()) {
const stateKey = this.makeMembershipStateKey(localUserId, localDeviceId);
const prepareDelayedDisconnection = async (): Promise<void> => {
try {
const res = await resendIfRateLimited(() =>
this.client._unstable_sendDelayedStateEvent(
this.room.roomId,
{
delay: this.delayedLeaveEventDelayMs,
},
EventType.GroupCallMemberPrefix,
{}, // leave event
stateKey,
),
);
this.disconnectDelayId = res.delay_id;
} catch (e) {
if (
e instanceof MatrixError &&
e.errcode === "M_UNKNOWN" &&
e.data["org.matrix.msc4140.errcode"] === "M_MAX_DELAY_EXCEEDED"
) {
const maxDelayAllowed = e.data["org.matrix.msc4140.max_delay"];
if (
typeof maxDelayAllowed === "number" &&
this.delayedLeaveEventDelayMs > maxDelayAllowed
) {
this.delayedLeaveEventDelayMsOverride = maxDelayAllowed;
return prepareDelayedDisconnection();
}
}
logger.error("Failed to prepare delayed disconnection event:", e);
}
};
await prepareDelayedDisconnection();
// Send join event _after_ preparing the delayed disconnection event
await resendIfRateLimited(() =>
this.client.sendStateEvent(this.room.roomId, EventType.GroupCallMemberPrefix, newContent, stateKey),
);
// If sending state cancels your own delayed state, prepare another delayed state
// TODO: Remove this once MSC4140 is stable & doesn't cancel own delayed state
if (this.disconnectDelayId !== undefined) {
try {
const knownDisconnectDelayId = this.disconnectDelayId;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(
knownDisconnectDelayId,
UpdateDelayedEventAction.Restart,
),
);
} catch (e) {
if (e instanceof MatrixError && e.errcode === "M_NOT_FOUND") {
// If we get a M_NOT_FOUND we prepare a new delayed event.
// In other error cases we do not want to prepare anything since we do not have the guarantee, that the
// future is not still running.
logger.warn("Failed to update delayed disconnection event, prepare it again:", e);
this.disconnectDelayId = undefined;
await prepareDelayedDisconnection();
}
}
}
if (this.disconnectDelayId !== undefined) {
this.scheduleDelayDisconnection();
}
// TODO throw or log an error if this.disconnectDelayId === undefined
} else {
// Not joined
let sentDelayedDisconnect = false;
if (this.disconnectDelayId !== undefined) {
try {
const knownDisconnectDelayId = this.disconnectDelayId;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(
knownDisconnectDelayId,
UpdateDelayedEventAction.Send,
),
);
sentDelayedDisconnect = true;
} catch (e) {
logger.error("Failed to send our delayed disconnection event:", e);
}
this.disconnectDelayId = undefined;
}
if (!sentDelayedDisconnect) {
await resendIfRateLimited(() =>
this.client.sendStateEvent(
this.room.roomId,
EventType.GroupCallMemberPrefix,
{},
this.makeMembershipStateKey(localUserId, localDeviceId),
),
);
}
}
logger.info("Sent updated call member event.");
} catch (e) {
const resendDelay = this.networkErrorRetryMs;
logger.warn(`Failed to send call member event (retrying in ${resendDelay}): ${e}`);
await sleep(resendDelay);
await this.triggerCallMembershipEventUpdate();
}
}
private scheduleDelayDisconnection(): void {
this.memberEventTimeout = setTimeout(() => void this.delayDisconnection(), this.delayedLeaveEventRestartMs);
}
private readonly delayDisconnection = async (): Promise<void> => {
try {
const knownDisconnectDelayId = this.disconnectDelayId!;
await resendIfRateLimited(() =>
this.client._unstable_updateDelayedEvent(knownDisconnectDelayId, UpdateDelayedEventAction.Restart),
);
this.scheduleDelayDisconnection();
} catch (e) {
logger.error("Failed to delay our disconnection event:", e);
}
};
private makeMembershipStateKey(localUserId: string, localDeviceId: string): string {
const stateKey = `${localUserId}_${localDeviceId}`;
if (/^org\.matrix\.msc(3757|3779)\b/.exec(this.room.getVersion())) {
return stateKey;
} else {
return `_${stateKey}`;
}
}
}
async function resendIfRateLimited<T>(func: () => Promise<T>, numRetriesAllowed: number = 1): Promise<T> {
// eslint-disable-next-line no-constant-condition
while (true) {
try {
return await func();
} catch (e) {
if (numRetriesAllowed > 0 && e instanceof HTTPError && e.isRateLimitError()) {
numRetriesAllowed--;
let resendDelay: number;
const defaultMs = 5000;
try {
resendDelay = e.getRetryAfterMs() ?? defaultMs;
logger.info(`Rate limited by server, retrying in ${resendDelay}ms`);
} catch (e) {
logger.warn(
`Error while retrieving a rate-limit retry delay, retrying after default delay of ${defaultMs}`,
e,
);
resendDelay = defaultMs;
}
await sleep(resendDelay);
} else {
throw e;
}
}
}
}
+47 -27
View File
@@ -24,13 +24,13 @@ import { CallMembership } from "./CallMembership.ts";
import { RoomStateEvent } from "../models/room-state.ts";
import { type Focus } from "./focus.ts";
import { KnownMembership } from "../@types/membership.ts";
import { MembershipManager } from "./NewMembershipManager.ts";
import { MembershipManager } from "./MembershipManager.ts";
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
import { logDurationSync } from "../utils.ts";
import { type Statistics } from "./types.ts";
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
import type { IMembershipManager } from "./IMembershipManager.ts";
import { RTCEncryptionManager } from "./RTCEncryptionManager.ts";
import {
RoomAndToDeviceEvents,
type RoomAndToDeviceEventsHandlerMap,
@@ -77,6 +77,7 @@ export interface MembershipConfig {
* Use the new Manager.
*
* Default: `false`.
* @deprecated does nothing anymore we always default to the new memberhip manager.
*/
useNewMembershipManager?: boolean;
@@ -387,22 +388,19 @@ export class MatrixRTCSession extends TypedEventEmitter<
return;
} else {
// Create MembershipManager and pass the RTCSession logger (with room id info)
if (joinConfig?.useNewMembershipManager ?? false) {
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.logger,
);
} else {
this.membershipManager = new LegacyMembershipManager(joinConfig, this.roomSubset, this.client, () =>
this.getOldestMembership(),
);
}
this.membershipManager = new MembershipManager(
joinConfig,
this.roomSubset,
this.client,
() => this.getOldestMembership(),
this.logger,
);
// Create Encryption manager
let transport;
if (joinConfig?.useExperimentalToDeviceTransport) {
this.logger.info("Using experimental to-device transport for encryption keys");
this.logger.info("Using to-device with room fallback transport for encryption keys");
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
@@ -413,20 +411,40 @@ export class MatrixRTCSession extends TypedEventEmitter<
// Expose the changes so the ui can display the currently used transport.
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
this.encryptionManager = new RTCEncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
this.logger,
);
} else {
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(
MatrixRTCSessionEvent.EncryptionKeyChanged,
keyBin,
encryptionKeyIndex,
participantId,
);
},
);
}
this.encryptionManager = new EncryptionManager(
this.client.getUserId()!,
this.client.getDeviceId()!,
() => this.memberships,
transport,
this.statistics,
(keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => {
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
},
this.logger,
);
}
// Join!
@@ -577,7 +595,9 @@ export class MatrixRTCSession extends TypedEventEmitter<
oldMemberships.some((m, i) => !CallMembership.equal(m, this.memberships[i]));
if (changed) {
this.logger.info(`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting`);
this.logger.info(
`Memberships for call in room ${this.roomSubset.roomId} have changed: emitting (${this.memberships.length} members)`,
);
logDurationSync(this.logger, "emit MatrixRTCSessionEvent.MembershipsChanged", () => {
this.emit(MatrixRTCSessionEvent.MembershipsChanged, oldMemberships, this.memberships);
});
+7 -3
View File
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { logger as rootLogger, type Logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { type MatrixClient, ClientEvent } from "../client.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room } from "../models/room.ts";
@@ -48,8 +48,12 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
// longer the correct session object for the room.
private roomSessions = new Map<string, MatrixRTCSession>();
private logger: Logger;
public constructor(private client: MatrixClient) {
private readonly logger: Logger;
public constructor(
rootLogger: Logger,
private client: MatrixClient,
) {
super();
this.logger = rootLogger.getChild("[MatrixRTCSessionManager]");
}
@@ -21,13 +21,12 @@ import { UnsupportedDelayedEventsEndpointError } from "../errors.ts";
import { ConnectionError, HTTPError, MatrixError } from "../http-api/errors.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type Room } from "../models/room.ts";
import { defer, type IDeferred } from "../utils.ts";
import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts";
import { type Focus } from "./focus.ts";
import { isMyMembership, Status } from "./types.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { type MembershipConfig } from "./MatrixRTCSession.ts";
import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts";
import { ActionScheduler, type ActionUpdate } from "./MembershipManagerActionScheduler.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import {
MembershipManagerEvent,
@@ -209,14 +208,15 @@ export class MembershipManager
// So we do not check scheduler.actions/scheduler.insertions
if (!this.leavePromiseResolvers) {
// reset scheduled actions so we will not do any new actions.
this.leavePromiseResolvers = defer<boolean>();
this.leavePromiseResolvers = Promise.withResolvers<boolean>();
this.activated = false;
this.scheduler.initiateLeave();
if (timeout) setTimeout(() => this.leavePromiseResolvers?.resolve(false), timeout);
}
return this.leavePromiseResolvers.promise;
}
private leavePromiseResolvers?: IDeferred<boolean>;
private leavePromiseResolvers?: PromiseWithResolvers<boolean>;
public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise<void> {
const userId = this.client.getUserId();
@@ -229,14 +229,15 @@ export class MembershipManager
MembershipActionType.SendJoinEvent,
];
this.logger.warn("Missing own membership: force re-join");
this.state.hasMemberStateEvent = false;
if (this.scheduler.actions.find((a) => sendingMembershipActions.includes(a.type as MembershipActionType))) {
this.logger.error(
"NewMembershipManger tried adding another `SendFirstDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
"tried adding another `SendDelayedEvent` actions even though we already have one in the Queue\nActionQueueOnMemberUpdate:",
this.scheduler.actions,
);
} else {
// Only react to our own membership missing if we have not already scheduled sending a new membership DirectMembershipManagerAction.Join
this.state.hasMemberStateEvent = false;
this.scheduler.initiateJoin();
}
}
@@ -284,7 +285,7 @@ export class MembershipManager
parentLogger?: Logger,
) {
super();
this.logger = (parentLogger ?? rootLogger).getChild(`[NewMembershipManager]`);
this.logger = (parentLogger ?? rootLogger).getChild(`[MembershipManager]`);
const [userId, deviceId] = [this.client.getUserId(), this.client.getDeviceId()];
if (userId === null) throw Error("Missing userId in client");
if (deviceId === null) throw Error("Missing deviceId in client");
@@ -382,13 +383,13 @@ export class MembershipManager
return this.sendOrResendDelayedLeaveEvent(); // Normal case without any previous delayed id.
} else {
// This can happen if someone else (or another client) removes our own membership event.
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendFirstDelayedEvent`.
// It will trigger `onRTCSessionMemberUpdate` queue `MembershipActionType.SendDelayedEvent`.
// We might still have our delayed event from the previous participation and dependent on the server this might not
// get removed automatically if the state changes. Hence, it would remove our membership unexpectedly shortly after the rejoin.
//
// In this block we will try to cancel this delayed event before setting up a new one.
return this.cancelKnownDelayIdBeforeSendFirstDelayedEvent(this.state.delayId);
return this.cancelKnownDelayIdBeforeSendDelayedEvent(this.state.delayId);
}
}
case MembershipActionType.RestartDelayedEvent: {
@@ -488,7 +489,7 @@ export class MembershipManager
});
}
private async cancelKnownDelayIdBeforeSendFirstDelayedEvent(delayId: string): Promise<ActionUpdate> {
private async cancelKnownDelayIdBeforeSendDelayedEvent(delayId: string): Promise<ActionUpdate> {
// Remove all running updates and restarts
return await this.client
._unstable_updateDelayedEvent(delayId, UpdateDelayedEventAction.Cancel)
@@ -1,7 +1,7 @@
import { type Logger, logger as rootLogger } from "../logger.ts";
import { type EmptyObject } from "../matrix.ts";
import { sleep } from "../utils.ts";
import { MembershipActionType } from "./NewMembershipManager.ts";
import { MembershipActionType } from "./MembershipManager.ts";
/** @internal */
export interface Action {
+329
View File
@@ -0,0 +1,329 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { type IEncryptionManager } from "./EncryptionManager.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { type CallMembership } from "./CallMembership.ts";
import { decodeBase64, encodeBase64 } from "../base64.ts";
import { type IKeyTransport, type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts";
import { type Logger } from "../logger.ts";
import { sleep } from "../utils.ts";
import type {
InboundEncryptionSession,
OutboundEncryptionSession,
ParticipantDeviceInfo,
ParticipantId,
Statistics,
} from "./types.ts";
import { getParticipantId, OutdatedKeyFilter } from "./utils.ts";
import {
type EnabledTransports,
RoomAndToDeviceEvents,
RoomAndToDeviceTransport,
} from "./RoomAndToDeviceKeyTransport.ts";
/**
* RTCEncryptionManager is used to manage the encryption keys for a call.
*
* It is responsible for distributing the keys to the other participants and rotating the keys if needed.
*
* This manager when used with to-device transport will share the existing key only to new joiners, and rotate
* if there is a leaver.
*
* XXX In the future we want to distribute a ratcheted key not the current one for new joiners.
*/
export class RTCEncryptionManager implements IEncryptionManager {
// The current per-sender media key for this device
private outboundSession: OutboundEncryptionSession | null = null;
/**
* Ensures that there is only one distribute operation at a time for that call.
*/
private currentKeyDistributionPromise: Promise<void> | null = null;
/**
* The time to wait before using the outbound session after it has been distributed.
* This is to ensure that the key is delivered to all participants before it is used.
* When creating the first key, this is set to 0, so that the key can be used immediately.
*/
private delayRolloutTimeMillis = 1000;
/**
* If a new key distribution is being requested while one is going on, we will set this flag to true.
* This will ensure that a new round is started after the current one.
* @private
*/
private needToEnsureKeyAgain = false;
/**
* There is a possibility that keys arrive in the wrong order.
* For example, after a quick join/leave/join, there will be 2 keys of index 0 distributed, and
* if they are received in the wrong order, the stream won't be decryptable.
* For that reason we keep a small buffer of keys for a limited time to disambiguate.
* @private
*/
private keyBuffer = new OutdatedKeyFilter();
private logger: Logger | undefined = undefined;
public constructor(
private userId: string,
private deviceId: string,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
// Callback to notify the media layer of new keys
private onEncryptionKeysChanged: (
keyBin: Uint8Array,
encryptionKeyIndex: number,
participantId: ParticipantId,
) => void,
parentLogger?: Logger,
) {
this.logger = parentLogger?.getChild(`[EncryptionManager]`);
}
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
// This is deprecated should be ignored. Only used by tests?
return new Map();
}
public join(joinConfig: EncryptionConfig | undefined): void {
this.logger?.info(`Joining room`);
this.delayRolloutTimeMillis = joinConfig?.useKeyDelay ?? 1000;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
// Deprecate RoomKeyTransport: this can get removed.
if (this.transport instanceof RoomAndToDeviceTransport) {
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
}
this.transport.start();
}
public leave(): void {
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
}
// Temporary for backwards compatibility
// TODO: Remove this in the future
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
this.logger?.info("Transport change detected, restarting key distribution");
if (this.currentKeyDistributionPromise) {
this.currentKeyDistributionPromise
.then(() => {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
})
.catch((e) => {
this.logger?.error("Failed to restart key distribution", e);
});
} else {
if (this.outboundSession) {
this.outboundSession.sharedWith = [];
this.ensureKeyDistribution();
}
}
};
/**
* Will ensure that a new key is distributed and used to encrypt our media.
* If there is already a key distribution in progress, it will schedule a new distribution round just after the current one is completed.
* If this function is called repeatedly while a distribution is in progress,
* the calls will be coalesced to a single new distribution (that will start just after the current one has completed).
*/
private ensureKeyDistribution(): void {
if (this.currentKeyDistributionPromise == null) {
this.logger?.debug(`No active rollout, start a new one`);
// start a rollout
this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => {
this.logger?.debug(`Rollout completed`);
this.currentKeyDistributionPromise = null;
if (this.needToEnsureKeyAgain) {
this.logger?.debug(`New Rollout needed`);
this.needToEnsureKeyAgain = false;
// rollout a new one
this.ensureKeyDistribution();
}
});
} else {
// There is a rollout in progress, but a key rotation is requested (could be caused by a membership change)
// Remember that a new rotation is needed after the current one.
this.logger?.debug(`Rollout in progress, a new rollout will be started after the current one`);
this.needToEnsureKeyAgain = true;
}
}
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
this.logger?.debug(`Received key over transport ${userId}:${deviceId} at index ${index}`);
// We received a new key, notify the video layer of this new key so that it can decrypt the frames properly.
const participantId = getParticipantId(userId, deviceId);
const keyBin = decodeBase64(keyBase64Encoded);
const candidateInboundSession: InboundEncryptionSession = {
key: keyBin,
participantId,
keyIndex: index,
creationTS: timestamp,
};
const outdated = this.keyBuffer.isOutdated(participantId, candidateInboundSession);
if (!outdated) {
this.onEncryptionKeysChanged(
candidateInboundSession.key,
candidateInboundSession.keyIndex,
candidateInboundSession.participantId,
);
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
} else {
this.logger?.info(`Received an out of order key for ${userId}:${deviceId}, dropping it`);
}
};
/**
* Called when the membership of the call changes.
* This encryption manager is very basic, it will rotate the key everytime this is called.
* @param oldMemberships
*/
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
this.logger?.trace(`onMembershipsUpdate`);
// Ensure the key is distributed. This will be no-op if the key is already being distributed to everyone.
// If there is an ongoing distribution, it will be completed before a new one is started.
this.ensureKeyDistribution();
}
private async rolloutOutboundKey(): Promise<void> {
const isFirstKey = this.outboundSession == null;
if (isFirstKey) {
// create the first key
this.outboundSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: 0,
};
this.onEncryptionKeysChanged(
this.outboundSession.key,
this.outboundSession.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
// get current memberships
const toShareWith: ParticipantDeviceInfo[] = this.getMemberships()
.filter((membership) => {
return membership.sender != undefined;
})
.map((membership) => {
return {
userId: membership.sender!,
deviceId: membership.deviceId,
membershipTs: membership.createdTs(),
};
});
let alreadySharedWith = this.outboundSession?.sharedWith ?? [];
// Some users might have rotate their membership event (formally called fingerprint) meaning they might have
// clear their key. Reset the `alreadySharedWith` flag for them.
alreadySharedWith = alreadySharedWith.filter(
(x) =>
// If there was a member with same userId and deviceId but different membershipTs, we need to clear it
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs != o.membershipTs,
),
);
const anyLeft = alreadySharedWith.filter(
(x) =>
!toShareWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
const anyJoined = toShareWith.filter(
(x) =>
!alreadySharedWith.some(
(o) => x.userId == o.userId && x.deviceId == o.deviceId && x.membershipTs == o.membershipTs,
),
);
let toDistributeTo: ParticipantDeviceInfo[] = [];
let outboundKey: OutboundEncryptionSession;
let hasKeyChanged = false;
if (anyLeft.length > 0) {
// We need to rotate the key
const newOutboundKey: OutboundEncryptionSession = {
key: this.generateRandomKey(),
creationTS: Date.now(),
sharedWith: [],
keyId: this.nextKeyIndex(),
};
hasKeyChanged = true;
this.logger?.info(`creating new outbound key index:${newOutboundKey.keyId}`);
// Set this new key as the current one
this.outboundSession = newOutboundKey;
// Send
toDistributeTo = toShareWith;
outboundKey = newOutboundKey;
} else if (anyJoined.length > 0) {
// keep the same key
// XXX In the future we want to distribute a ratcheted key not the current one
toDistributeTo = anyJoined;
outboundKey = this.outboundSession!;
} else {
// no changes
return;
}
try {
this.logger?.trace(`Sending key...`);
await this.transport.sendKey(encodeBase64(outboundKey.key), outboundKey.keyId, toDistributeTo);
this.statistics.counters.roomEventEncryptionKeysSent += 1;
outboundKey.sharedWith.push(...toDistributeTo);
this.logger?.trace(
`key index:${outboundKey.keyId} sent to ${outboundKey.sharedWith.map((m) => `${m.userId}:${m.deviceId}`).join(",")}`,
);
if (hasKeyChanged) {
// Delay a bit before using this key
// It is recommended not to start using a key immediately but instead wait for a short time to make sure it is delivered.
this.logger?.trace(`Delay Rollout for key:${outboundKey.keyId}...`);
await sleep(this.delayRolloutTimeMillis);
this.logger?.trace(`...Delayed rollout of index:${outboundKey.keyId} `);
this.onEncryptionKeysChanged(
outboundKey.key,
outboundKey.keyId,
getParticipantId(this.userId, this.deviceId),
);
}
} catch (err) {
this.logger?.error(`Failed to rollout key`, err);
}
}
private nextKeyIndex(): number {
if (this.outboundSession) {
return (this.outboundSession!.keyId + 1) % 256;
}
return 0;
}
private generateRandomKey(): Uint8Array {
const key = new Uint8Array(16);
globalThis.crypto.getRandomValues(key);
return key;
}
}
+2 -2
View File
@@ -16,10 +16,10 @@ limitations under the License.
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type CallMembership } from "./CallMembership.ts";
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
import { NotSupportedError, type ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type ParticipantDeviceInfo } from "./types.ts";
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
export interface EnabledTransports {
@@ -106,7 +106,7 @@ export class RoomAndToDeviceTransport
this.toDeviceTransport.stop();
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
this.logger.debug(
`Sending key with index ${index} to call members (count=${members.length}) via:` +
(this._enabled.room ? "room transport" : "") +
+2 -3
View File
@@ -15,13 +15,12 @@ limitations under the License.
*/
import type { MatrixClient } from "../client.ts";
import type { EncryptionKeysEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { EventType } from "../@types/event.ts";
import { type MatrixError } from "../http-api/errors.ts";
import { logger as rootLogger, type Logger } from "../logger.ts";
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type CallMembership } from "./CallMembership.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type Room, RoomEvent } from "../models/room.ts";
@@ -81,7 +80,7 @@ export class RoomKeyTransport
}
/** implements {@link IKeyTransport#sendKey} */
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
// members not used in room transports as the keys are sent to all room members
const content: EncryptionKeysEventContent = {
keys: [
+8 -14
View File
@@ -19,8 +19,7 @@ import { type WidgetApiResponseError } from "matrix-widget-api";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
import { type Logger, logger as rootLogger } from "../logger.ts";
import type { CallMembership } from "./CallMembership.ts";
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
import { type EncryptionKeysToDeviceEventContent, type ParticipantDeviceInfo, type Statistics } from "./types.ts";
import { ClientEvent, type MatrixClient } from "../client.ts";
import type { MatrixEvent } from "../models/event.ts";
import { EventType } from "../@types/event.ts";
@@ -42,6 +41,7 @@ export class ToDeviceKeyTransport
implements IKeyTransport
{
private logger: Logger = rootLogger;
public setParentLogger(parentLogger: Logger): void {
this.logger = parentLogger.getChild(`[ToDeviceKeyTransport]`);
}
@@ -66,7 +66,7 @@ export class ToDeviceKeyTransport
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
}
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
public async sendKey(keyBase64Encoded: string, index: number, members: ParticipantDeviceInfo[]): Promise<void> {
const content: EncryptionKeysToDeviceEventContent = {
keys: {
index: index,
@@ -81,24 +81,18 @@ export class ToDeviceKeyTransport
application: "m.call",
scope: "m.room",
},
sent_ts: Date.now(),
};
const targets = members
.filter((member) => {
// filter malformed call members
if (member.sender == undefined || member.deviceId == undefined) {
this.logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
return false;
}
// Filter out me
return !(member.sender == this.userId && member.deviceId == this.deviceId);
})
.map((member) => {
return {
userId: member.sender!,
userId: member.userId!,
deviceId: member.deviceId!,
};
});
})
// filter out me
.filter((member) => !(member.userId == this.userId && member.deviceId == this.deviceId));
if (targets.length > 0) {
await this.client
+37 -2
View File
@@ -16,11 +16,44 @@ limitations under the License.
import type { IMentions } from "../matrix.ts";
import type { CallMembership } from "./CallMembership.ts";
export type ParticipantId = string;
export interface EncryptionKeyEntry {
index: number;
key: string;
}
/**
* The mxID, deviceId and membership timestamp of a RTC session participant.
*/
export type ParticipantDeviceInfo = {
userId: string;
deviceId: string;
membershipTs: number;
};
/**
* A type representing the information needed to decrypt video streams.
*/
export type InboundEncryptionSession = {
key: Uint8Array;
participantId: ParticipantId;
keyIndex: number;
creationTS: number;
};
/**
* The information about the key used to encrypt video streams.
*/
export type OutboundEncryptionSession = {
key: Uint8Array;
creationTS: number;
// The devices that this key is shared with.
sharedWith: Array<ParticipantDeviceInfo>;
// This is an index acting as the id of the key
keyId: number;
};
export interface EncryptionKeysEventContent {
keys: EncryptionKeyEntry[];
device_id: string;
@@ -28,13 +61,15 @@ export interface EncryptionKeysEventContent {
sent_ts?: number;
}
/**
* THe content of a to-device event that contains encryption keys.
*/
export interface EncryptionKeysToDeviceEventContent {
keys: { index: number; key: string };
member: {
// id: ParticipantId,
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
// Or add some validation on it based on the encryption info
claimed_device_id: string;
// user_id: string
};
room_id: string;
session: {
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2025 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import type { InboundEncryptionSession, ParticipantId } from "./types.ts";
/**
* Detects when a key for a given index is outdated.
*/
export class OutdatedKeyFilter {
// Map of participantId -> keyIndex -> timestamp
private tsBuffer: Map<ParticipantId, Map<number, number>> = new Map();
public constructor() {}
/**
* Check if there is a recent key with the same keyId (index) and then use the creationTS to decide what to
* do with the key. If the key received is older than the one already in the buffer, it is ignored.
* @param participantId
* @param item
*/
public isOutdated(participantId: ParticipantId, item: InboundEncryptionSession): boolean {
if (!this.tsBuffer.has(participantId)) {
this.tsBuffer.set(participantId, new Map<number, number>());
}
const latestTimestamp = this.tsBuffer.get(participantId)?.get(item.keyIndex);
if (latestTimestamp && latestTimestamp > item.creationTS) {
// The existing key is more recent, ignore this one
return true;
}
this.tsBuffer.get(participantId)!.set(item.keyIndex, item.creationTS);
return false;
}
}
export function getParticipantId(userId: string, deviceId: string): ParticipantId {
return `${userId}:${deviceId}`;
}
+99 -50
View File
@@ -17,15 +17,15 @@ limitations under the License.
import { M_POLL_START, type Optional } from "matrix-events-sdk";
import {
EventTimelineSet,
DuplicateStrategy,
type IAddLiveEventOptions,
EventTimelineSet,
type EventTimelineSetHandlerMap,
type IAddLiveEventOptions,
} from "./event-timeline-set.ts";
import { Direction, EventTimeline } from "./event-timeline.ts";
import { getHttpUriForMxc } from "../content-repo.ts";
import { removeElement } from "../utils.ts";
import { normalize, noUnsafeEventProps } from "../utils.ts";
import * as utils from "../utils.ts";
import { normalize, noUnsafeEventProps, removeElement } from "../utils.ts";
import {
type IEvent,
type IThreadBundledRelationship,
@@ -35,17 +35,17 @@ import {
} from "./event.ts";
import { EventStatus } from "./event-status.ts";
import { RoomMember } from "./room-member.ts";
import { type IRoomSummary, type Hero, RoomSummary } from "./room-summary.ts";
import { type Hero, type IRoomSummary, RoomSummary } from "./room-summary.ts";
import { logger } from "../logger.ts";
import { TypedReEmitter } from "../ReEmitter.ts";
import {
EVENT_VISIBILITY_CHANGE_TYPE,
EventType,
RelationType,
RoomCreateTypeField,
RoomType,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
EVENT_VISIBILITY_CHANGE_TYPE,
RelationType,
UNSIGNED_THREAD_ID_FIELD,
UNSTABLE_ELEMENT_FUNCTIONAL_USERS,
} from "../@types/event.ts";
import { type MatrixClient, PendingEventOrdering } from "../client.ts";
import { type GuestAccess, type HistoryVisibility, type JoinRule, type ResizeMethod } from "../@types/partials.ts";
@@ -53,12 +53,12 @@ import { Filter, type IFilterDefinition } from "../filter.ts";
import { type RoomState, RoomStateEvent, type RoomStateEventHandlerMap } from "./room-state.ts";
import { BeaconEvent, type BeaconEventHandlerMap } from "./beacon.ts";
import {
FILTER_RELATED_BY_REL_TYPES,
FILTER_RELATED_BY_SENDERS,
Thread,
THREAD_RELATION_TYPE,
ThreadEvent,
type ThreadEventHandlerMap as ThreadHandlerMap,
FILTER_RELATED_BY_REL_TYPES,
THREAD_RELATION_TYPE,
FILTER_RELATED_BY_SENDERS,
ThreadFilterType,
} from "./thread.ts";
import {
@@ -74,7 +74,6 @@ import { ReadReceipt, synthesizeReceipt } from "./read-receipt.ts";
import { isPollEvent, Poll, PollEvent } from "./poll.ts";
import { RoomReceipts } from "./room-receipts.ts";
import { compareEventOrdering } from "./compare-event-ordering.ts";
import * as utils from "../utils.ts";
import { KnownMembership, type Membership } from "../@types/membership.ts";
import { type Capabilities, type IRoomVersionsCapability, RoomVersionStability } from "../serverCapabilities.ts";
import { type MSC4186Hero } from "../sliding-sync.ts";
@@ -2469,7 +2468,7 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
* Adds events to a thread's timeline. Will fire "Thread.update"
*/
public processThreadedEvents(events: MatrixEvent[], toStartOfTimeline: boolean): void {
events.forEach(this.applyRedaction);
events.forEach(this.tryApplyRedaction);
const eventsByThread: { [threadId: string]: MatrixEvent[] } = {};
for (const event of events) {
@@ -2580,55 +2579,105 @@ export class Room extends ReadReceipt<RoomEmittedEvents, RoomEventHandlerMap> {
return thread;
}
private applyRedaction = (event: MatrixEvent): void => {
/**
* Applies an event as a redaction of another event, regardless of whether the redacting
* event is actually a redaction.
*
* Callers should use tryApplyRedaction instead.
*
* @param redactionEvent The event which redacts an event.
* @param redactedEvent The event being redacted.
* @private
*/
private applyEventAsRedaction(redactionEvent: MatrixEvent, redactedEvent: MatrixEvent): void {
const threadRootId = redactedEvent.threadRootId;
redactedEvent.makeRedacted(redactionEvent, this);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey()!,
);
if (currentStateEvent?.getId() === redactedEvent.getId()) {
this.currentState.setStateEvents([redactedEvent]);
}
}
this.emit(RoomEvent.Redaction, redactionEvent, this, threadRootId);
// TODO: we stash user displaynames (among other things) in
// RoomMember objects which are then attached to other events
// (in the sender and target fields). We should get those
// RoomMember objects to update themselves when the events that
// they are based on are changed.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactedEvent.getId()!);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(redactionEvent);
}
}
private tryApplyRedaction = (event: MatrixEvent): void => {
// FIXME: apply redactions to notification list
// NB: We continue to add the redaction event to the timeline at the
// end of this function so clients can say "so and so redacted an event"
// if they wish to. Also this may be needed to trigger an update.
if (event.isRedaction()) {
const redactId = event.event.redacts;
// if we know about this event, redact its contents now.
const redactedEvent = redactId ? this.findEventById(redactId) : undefined;
if (redactedEvent) {
const threadRootId = redactedEvent.threadRootId;
redactedEvent.makeRedacted(event, this);
// If this is in the current state, replace it with the redacted version
if (redactedEvent.isState()) {
const currentStateEvent = this.currentState.getStateEvents(
redactedEvent.getType(),
redactedEvent.getStateKey()!,
);
if (currentStateEvent?.getId() === redactedEvent.getId()) {
this.currentState.setStateEvents([redactedEvent]);
}
}
this.emit(RoomEvent.Redaction, event, this, threadRootId);
// TODO: we stash user displaynames (among other things) in
// RoomMember objects which are then attached to other events
// (in the sender and target fields). We should get those
// RoomMember objects to update themselves when the events that
// they are based on are changed.
// Remove any visibility change on this event.
this.visibilityEvents.delete(redactId!);
// If this event is a visibility change event, remove it from the
// list of visibility changes and update any event affected by it.
if (redactedEvent.isVisibilityEvent()) {
this.redactVisibilityChangeEvent(event);
}
this.applyEventAsRedaction(event, redactedEvent);
}
} else if (event.getType() === EventType.RoomMember) {
const membership = event.getContent()["membership"];
if (
membership !== KnownMembership.Ban &&
!(membership === KnownMembership.Leave && event.getStateKey() !== event.getSender())
) {
// Not a ban or kick, therefore not a membership event we care about here.
return;
}
const redactEvents = event.getContent()["org.matrix.msc4293.redact_events"];
if (redactEvents !== true) {
// Invalid or not set - nothing to redact.
return;
}
const state = this.getLiveTimeline().getState(Direction.Forward)!;
if (!state.maySendRedactionForEvent(event, event.getSender()!)) {
// If the sender can't redact the membership event, then they won't be able to
// redact any of the target's events either, so skip.
return;
}
// FIXME: apply redactions to notification list
// NB: We continue to add the redaction event to the timeline so
// clients can say "so and so redacted an event" if they wish to. Also
// this may be needed to trigger an update.
// The redaction is possible, so let's find all the events and apply it.
const events = this.getTimelineSets()
.map((s) => s.getTimelines())
.reduce((p, c) => {
p.push(...c);
return p;
}, [])
.map((t) => t.getEvents().filter((e) => e.getSender() === event.getStateKey()))
.reduce((p, c) => {
p.push(...c);
return c;
}, []);
for (const toRedact of events) {
this.applyEventAsRedaction(event, toRedact);
}
}
};
private processLiveEvent(event: MatrixEvent): void {
this.applyRedaction(event);
this.tryApplyRedaction(event);
// Implement MSC3531: hiding messages.
if (event.isVisibilityEvent()) {
+12 -2
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { deepCompare, escapeRegExp, globToRegexp, isNullOrUndefined } from "./utils.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
import { type MatrixClient } from "./client.ts";
import { type MatrixEvent } from "./models/event.ts";
import {
@@ -170,6 +170,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
/**
* Make sure that each of the rules listed in `defaultRuleIds` is listed in the given set of push rules.
*
* @param logger - A `Logger` to write log messages to.
* @param kind - the kind of push rule set being merged.
* @param incomingRules - the existing set of known push rules for the user.
* @param defaultRules - a lookup table for the default definitions of push rules.
@@ -178,6 +179,7 @@ const EXPECTED_DEFAULT_UNDERRIDE_RULE_IDS: OrderedRules = [
* @returns A copy of `incomingRules`, with any missing default rules inserted in the right place.
*/
function mergeRulesWithDefaults(
logger: Logger,
kind: PushRuleKind,
incomingRules: IPushRule[],
defaultRules: Record<string, IPushRule>,
@@ -276,11 +278,17 @@ export class PushProcessor {
* Rewrites conditions on a client's push rules to match the defaults
* where applicable. Useful for upgrading push rules to more strict
* conditions when the server is falling behind on defaults.
*
* @param logger - A `Logger` to write log messages to.
* @param incomingRules - The client's existing push rules
* @param userId - The Matrix ID of the client.
* @returns The rewritten rules
*/
public static rewriteDefaultRules(incomingRules: IPushRules, userId: string | undefined = undefined): IPushRules {
public static rewriteDefaultRules(
logger: Logger,
incomingRules: IPushRules,
userId: string | undefined = undefined,
): IPushRules {
let newRules: IPushRules = JSON.parse(JSON.stringify(incomingRules)); // deep clone
// These lines are mostly to make the tests happy. We shouldn't run into these
@@ -292,6 +300,7 @@ export class PushProcessor {
// Merge the client-level defaults with the ones from the server
newRules.global.override = mergeRulesWithDefaults(
logger,
PushRuleKind.Override,
newRules.global.override,
DEFAULT_OVERRIDE_RULES,
@@ -299,6 +308,7 @@ export class PushProcessor {
);
newRules.global.underride = mergeRulesWithDefaults(
logger,
PushRuleKind.Underride,
newRules.global.underride,
DEFAULT_UNDERRIDE_RULES,
+15 -14
View File
@@ -22,7 +22,7 @@ import {
import type * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { type BootstrapCrossSigningOpts } from "../crypto-api/index.ts";
import { logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { type OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { type UIAuthCallback } from "../interactive-auth.ts";
import { type ServerSideSecretStorage } from "../secret-storage.ts";
@@ -33,6 +33,7 @@ import { type ServerSideSecretStorage } from "../secret-storage.ts";
*/
export class CrossSigningIdentity {
public constructor(
private readonly logger: Logger,
private readonly olmMachine: OlmMachine,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
private readonly secretStorage: ServerSideSecretStorage,
@@ -61,7 +62,7 @@ export class CrossSigningIdentity {
olmDeviceStatus.hasMaster && olmDeviceStatus.hasUserSigning && olmDeviceStatus.hasSelfSigning;
// Log all relevant state for easier parsing of debug logs.
logger.log("bootstrapCrossSigning: starting", {
this.logger.debug("bootstrapCrossSigning: starting", {
setupNewCrossSigning: opts.setupNewCrossSigning,
olmDeviceHasMaster: olmDeviceStatus.hasMaster,
olmDeviceHasUserSigning: olmDeviceStatus.hasUserSigning,
@@ -71,23 +72,23 @@ export class CrossSigningIdentity {
if (olmDeviceHasKeys) {
if (!(await this.secretStorage.hasKey())) {
logger.warn(
this.logger.warn(
"bootstrapCrossSigning: Olm device has private keys, but secret storage is not yet set up; doing nothing for now.",
);
// the keys should get uploaded to 4S once that is set up.
} else if (!privateKeysInSecretStorage) {
// the device has the keys but they are not in 4S, so update it
logger.log("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
this.logger.debug("bootstrapCrossSigning: Olm device has private keys: exporting to secret storage");
await this.exportCrossSigningKeysToStorage();
} else {
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Olm device has private keys and they are saved in secret storage; doing nothing",
);
}
} /* (!olmDeviceHasKeys) */ else {
if (privateKeysInSecretStorage) {
// they are in 4S, so import from there
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Cross-signing private keys not found locally, but they are available " +
"in secret storage, reading storage and caching locally",
);
@@ -116,7 +117,7 @@ export class CrossSigningIdentity {
device.free();
}
} else {
logger.log(
this.logger.debug(
"bootstrapCrossSigning: Cross-signing private keys not found locally or in secret storage, creating new keys",
);
await this.resetCrossSigning(opts.authUploadDeviceSigningKeys);
@@ -125,7 +126,7 @@ export class CrossSigningIdentity {
// TODO: we might previously have bootstrapped cross-signing but not completed uploading the keys to the
// server -- in which case we should call OlmDevice.bootstrap_cross_signing. How do we know?
logger.log("bootstrapCrossSigning: complete");
this.logger.debug("bootstrapCrossSigning: complete");
}
/** Reset our cross-signing keys
@@ -142,7 +143,7 @@ export class CrossSigningIdentity {
// If 4S is configured we need to update it.
if (!(await this.secretStorage.hasKey())) {
logger.warn(
this.logger.warn(
"resetCrossSigning: Secret storage is not yet set up; not exporting keys to secret storage yet.",
);
// the keys should get uploaded to 4S once that is set up.
@@ -150,11 +151,11 @@ export class CrossSigningIdentity {
// Update 4S before uploading cross-signing keys, to stay consistent with legacy that asks
// 4S passphrase before asking for account password.
// Ultimately should be made atomic and resistant to forgotten password/passphrase.
logger.log("resetCrossSigning: exporting private keys to secret storage");
this.logger.debug("resetCrossSigning: exporting private keys to secret storage");
await this.exportCrossSigningKeysToStorage();
}
logger.log("resetCrossSigning: publishing public keys to server");
this.logger.debug("resetCrossSigning: publishing public keys to server");
for (const req of [
outgoingRequests.uploadKeysRequest,
outgoingRequests.uploadSigningKeysRequest,
@@ -178,17 +179,17 @@ export class CrossSigningIdentity {
if (exported?.masterKey) {
await this.secretStorage.store("m.cross_signing.master", exported.masterKey);
} else {
logger.error(`Cannot export MSK to secret storage, private key unknown`);
this.logger.error(`Cannot export MSK to secret storage, private key unknown`);
}
if (exported?.self_signing_key) {
await this.secretStorage.store("m.cross_signing.self_signing", exported.self_signing_key);
} else {
logger.error(`Cannot export SSK to secret storage, private key unknown`);
this.logger.error(`Cannot export SSK to secret storage, private key unknown`);
}
if (exported?.userSigningKey) {
await this.secretStorage.store("m.cross_signing.user_signing", exported.userSigningKey);
} else {
logger.error(`Cannot export USK to secret storage, private key unknown`);
this.logger.error(`Cannot export USK to secret storage, private key unknown`);
}
}
}
+7 -6
View File
@@ -28,7 +28,7 @@ import {
UploadSigningKeysRequest,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { calculateRetryBackoff, type IHttpOpts, type MatrixHttpApi, Method } from "../http-api/index.ts";
import { logDuration, type QueryDict, sleep } from "../utils.ts";
import { type AuthDict, type UIAuthCallback } from "../interactive-auth.ts";
@@ -49,6 +49,7 @@ import { UnstablePrefix as DehydrationUnstablePrefix } from "./DehydratedDeviceM
*/
export class OutgoingRequestProcessor {
public constructor(
private readonly logger: Logger,
private readonly olmMachine: OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
@@ -100,13 +101,13 @@ export class OutgoingRequestProcessor {
// PutDehydratedDeviceRequest does not implement OutgoingRequest and does not need to be marked as sent.
return;
} else {
logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
this.logger.warn("Unsupported outgoing message", Object.getPrototypeOf(msg));
resp = "";
}
if (msg.id) {
try {
await logDuration(logger, `Mark Request as sent ${msg.type}`, async () => {
await logDuration(this.logger, `Mark Request as sent ${msg.type}`, async () => {
await this.olmMachine.markRequestAsSent(msg.id!, msg.type, resp);
});
} catch (e) {
@@ -116,13 +117,13 @@ export class OutgoingRequestProcessor {
e instanceof Error &&
(e.message === "Attempt to use a moved value" || e.message === "null pointer passed to rust")
) {
logger.log(`Ignoring error '${e.message}': client is likely shutting down`);
this.logger.debug(`Ignoring error '${e.message}': client is likely shutting down`);
} else {
throw e;
}
}
} else {
logger.trace(`Outgoing request type:${msg.type} does not have an ID`);
this.logger.trace(`Outgoing request type:${msg.type} does not have an ID`);
}
}
@@ -143,7 +144,7 @@ export class OutgoingRequestProcessor {
}
}
logger.info(
this.logger.info(
`Sending batch of to-device messages. type=${request.event_type} txnid=${request.txn_id}`,
messageList,
);
+7 -9
View File
@@ -29,7 +29,7 @@ import {
import { EventType } from "../@types/event.ts";
import { type IContent, type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type Logger, logger, LogSpan } from "../logger.ts";
import { type Logger, LogSpan } from "../logger.ts";
import { type KeyClaimManager } from "./KeyClaimManager.ts";
import { type RoomMember } from "../models/room-member.ts";
import { HistoryVisibility } from "../@types/partials.ts";
@@ -44,8 +44,6 @@ import { type DeviceIsolationMode, DeviceIsolationModeKind } from "../crypto-api
* @internal
*/
export class RoomEncryptor {
private readonly prefixedLogger: Logger;
/** whether the room members have been loaded and tracked for the first time */
private lazyLoadedMembersResolved = false;
@@ -57,6 +55,7 @@ export class RoomEncryptor {
private currentEncryptionPromise: Promise<void> = Promise.resolve();
/**
* @param prefixedLogger - A logger to use for log messages.
* @param olmMachine - The rust-sdk's OlmMachine
* @param keyClaimManager - Our KeyClaimManager, which manages the queue of one-time-key claim requests
* @param outgoingRequestManager - The OutgoingRequestManager, which manages the queue of outgoing requests.
@@ -64,14 +63,13 @@ export class RoomEncryptor {
* @param encryptionSettings - body of the m.room.encryption event currently in force in this room
*/
public constructor(
private readonly prefixedLogger: Logger,
private readonly olmMachine: OlmMachine,
private readonly keyClaimManager: KeyClaimManager,
private readonly outgoingRequestManager: OutgoingRequestsManager,
private readonly room: Room,
private encryptionSettings: IContent,
) {
this.prefixedLogger = logger.getChild(`[${room.roomId} encryption]`);
// start tracking devices for any users already known to be in this room.
// Do not load members here, would defeat lazy loading.
const members = room.getJoinedMembers();
@@ -212,7 +210,7 @@ export class RoomEncryptor {
// This could end up being racy (if two calls to ensureEncryptionSession happen at the same time), but that's
// not a particular problem, since `OlmMachine.updateTrackedUsers` just adds any users that weren't already tracked.
if (!this.lazyLoadedMembersResolved) {
await logDuration(this.prefixedLogger, "loadMembersIfNeeded: updateTrackedUsers", async () => {
await logDuration(logger, "loadMembersIfNeeded: updateTrackedUsers", async () => {
await this.olmMachine.updateTrackedUsers(members.map((u) => new RustSdkCryptoJs.UserId(u.userId)));
});
logger.debug(`Updated tracked users`);
@@ -229,7 +227,7 @@ export class RoomEncryptor {
// XXX future improvement process only KeysQueryRequests for the users that have never been queried.
logger.debug(`Processing outgoing requests`);
await logDuration(this.prefixedLogger, "doProcessOutgoingRequests", async () => {
await logDuration(logger, "doProcessOutgoingRequests", async () => {
await this.outgoingRequestManager.doProcessOutgoingRequests();
});
} else {
@@ -250,7 +248,7 @@ export class RoomEncryptor {
const userList = members.map((u) => new UserId(u.userId));
await logDuration(this.prefixedLogger, "ensureSessionsForUsers", async () => {
await logDuration(logger, "ensureSessionsForUsers", async () => {
await this.keyClaimManager.ensureSessionsForUsers(logger, userList);
});
@@ -289,7 +287,7 @@ export class RoomEncryptor {
break;
}
await logDuration(this.prefixedLogger, "shareRoomKey", async () => {
await logDuration(logger, "shareRoomKey", async () => {
const shareMessages: ToDeviceRequest[] = await this.olmMachine.shareRoomKey(
new RoomId(this.room.roomId),
// safe to pass without cloning, as it's not reused here (before or after)
+58 -51
View File
@@ -28,7 +28,7 @@ import {
type KeyBackupRestoreResult,
type KeyBackupRoomSessions,
} from "../crypto-api/keybackup.ts";
import { logger } from "../logger.ts";
import { type Logger } from "../logger.ts";
import { ClientPrefix, type IHttpOpts, MatrixError, type MatrixHttpApi, Method } from "../http-api/index.ts";
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { encodeUri, logDuration } from "../utils.ts";
@@ -81,6 +81,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
private backupKeysLoopRunning = false;
public constructor(
private readonly logger: Logger,
private readonly olmMachine: OlmMachine,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
private readonly outgoingRequestProcessor: OutgoingRequestProcessor,
@@ -131,7 +132,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
const backupKeys: RustSdkCryptoJs.BackupKeys = await this.olmMachine.getBackupKeys();
const decryptionKey = backupKeys?.decryptionKey;
const backupMatchesSavedPrivateKey =
!!decryptionKey && backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
!!decryptionKey && this.backupInfoMatchesBackupDecryptionKey(info, decryptionKey);
return {
matchesDecryptionKey: backupMatchesSavedPrivateKey,
trusted: signatureVerification.trusted(),
@@ -171,14 +172,14 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
try {
latestBackupInfo = await this.requestKeyBackupVersion();
} catch (e) {
logger.warn("handleBackupSecretReceived: Error checking for latest key backup", e);
this.logger.warn("handleBackupSecretReceived: Error checking for latest key backup", e);
return false;
}
if (!latestBackupInfo?.version) {
// There is no server-side key backup.
// This decryption key is useless to us.
logger.warn(
this.logger.warn(
"handleBackupSecretReceived: Received a backup decryption key, but there is no trusted server-side key backup",
);
return false;
@@ -186,21 +187,21 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
try {
const backupDecryptionKey = RustSdkCryptoJs.BackupDecryptionKey.fromBase64(secret);
const privateKeyMatches = backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
const privateKeyMatches = this.backupInfoMatchesBackupDecryptionKey(latestBackupInfo, backupDecryptionKey);
if (!privateKeyMatches) {
logger.warn(
this.logger.warn(
`handleBackupSecretReceived: Private decryption key does not match the public key of the current remote backup.`,
);
// just ignore the secret
return false;
}
logger.info(
this.logger.info(
`handleBackupSecretReceived: A valid backup decryption key has been received and stored in cache.`,
);
await this.saveBackupDecryptionKey(backupDecryptionKey, latestBackupInfo.version);
return true;
} catch (e) {
logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
this.logger.warn("handleBackupSecretReceived: Invalid backup decryption key", e);
}
return false;
@@ -282,19 +283,19 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
/** Helper for `checkKeyBackup` */
private async doCheckKeyBackup(): Promise<KeyBackupCheck | null> {
logger.log("Checking key backup status...");
this.logger.debug("Checking key backup status...");
let backupInfo: KeyBackupInfo | null | undefined;
try {
backupInfo = await this.requestKeyBackupVersion();
} catch (e) {
logger.warn("Error checking for active key backup", e);
this.logger.warn("Error checking for active key backup", e);
this.serverBackupInfo = undefined;
return null;
}
this.checkedForBackup = true;
if (backupInfo && !backupInfo.version) {
logger.warn("active backup lacks a useful 'version'; ignoring it");
this.logger.warn("active backup lacks a useful 'version'; ignoring it");
backupInfo = undefined;
}
this.serverBackupInfo = backupInfo;
@@ -303,10 +304,10 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
if (!backupInfo) {
if (activeVersion !== null) {
logger.log("No key backup present on server: disabling key backup");
this.logger.debug("No key backup present on server: disabling key backup");
await this.disableKeyBackup();
} else {
logger.log("No key backup present on server: not enabling key backup");
this.logger.debug("No key backup present on server: not enabling key backup");
}
return null;
}
@@ -317,23 +318,25 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
// (b) the public key matches the private decryption key that we have received from 4S.
if (!trustInfo.matchesDecryptionKey && !trustInfo.trusted) {
if (activeVersion !== null) {
logger.log("Key backup present on server but not trusted: disabling key backup");
this.logger.debug("Key backup present on server but not trusted: disabling key backup");
await this.disableKeyBackup();
} else {
logger.log("Key backup present on server but not trusted: not enabling key backup");
this.logger.debug("Key backup present on server but not trusted: not enabling key backup");
}
} else {
if (activeVersion === null) {
logger.log(`Found usable key backup v${backupInfo.version}: enabling key backups`);
this.logger.debug(`Found usable key backup v${backupInfo.version}: enabling key backups`);
await this.enableKeyBackup(backupInfo);
} else if (activeVersion !== backupInfo.version) {
logger.log(`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`);
this.logger.debug(
`On backup version ${activeVersion} but found version ${backupInfo.version}: switching.`,
);
// This will remove any pending backup request, remove the backup key and reset the backup state of each room key we have.
await this.disableKeyBackup();
// Enabling will now trigger re-upload of all the keys
await this.enableKeyBackup(backupInfo);
} else {
logger.log(`Backup version ${backupInfo.version} still current`);
this.logger.debug(`Backup version ${backupInfo.version} still current`);
}
}
return { backupInfo, trustInfo };
@@ -374,12 +377,12 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
private async backupKeysLoop(maxDelay = 10000): Promise<void> {
if (this.backupKeysLoopRunning) {
logger.log(`Backup loop already running`);
this.logger.debug(`Backup loop already running`);
return;
}
this.backupKeysLoopRunning = true;
logger.log(`Backup: Starting keys upload loop for backup version:${this.activeBackupVersion}.`);
this.logger.debug(`Backup: Starting keys upload loop for backup version:${this.activeBackupVersion}.`);
// wait between 0 and `maxDelay` seconds, to avoid backup
// requests from different clients hitting the server all at
@@ -401,18 +404,18 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
let request: RustSdkCryptoJs.KeysBackupRequest | undefined = undefined;
try {
request = await logDuration(
logger,
this.logger,
"BackupRoomKeys: Get keys to backup from rust crypto-sdk",
async () => {
return await this.olmMachine.backupRoomKeys();
},
);
} catch (err) {
logger.error("Backup: Failed to get keys to backup from rust crypto-sdk", err);
this.logger.error("Backup: Failed to get keys to backup from rust crypto-sdk", err);
}
if (!request || this.stopped || !this.activeBackupVersion) {
logger.log(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
this.logger.debug(`Backup: Ending loop for version ${this.activeBackupVersion}.`);
if (!request) {
// nothing more to upload
this.emit(CryptoEvent.KeyBackupSessionsRemaining, 0);
@@ -439,7 +442,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
const keyCount = await this.olmMachine.roomKeyCounts();
remainingToUploadCount = keyCount.total - keyCount.backedUp;
} catch (err) {
logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
this.logger.error("Backup: Failed to get key counts from rust crypto-sdk", err);
}
}
@@ -455,15 +458,15 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
}
} catch (err) {
numFailures++;
logger.error("Backup: Error processing backup request for rust crypto-sdk", err);
this.logger.error("Backup: Error processing backup request for rust crypto-sdk", err);
if (err instanceof MatrixError) {
const errCode = err.data.errcode;
if (errCode == "M_NOT_FOUND" || errCode == "M_WRONG_ROOM_KEYS_VERSION") {
logger.log(`Backup: Failed to upload keys to current vesion: ${errCode}.`);
this.logger.debug(`Backup: Failed to upload keys to current vesion: ${errCode}.`);
try {
await this.disableKeyBackup();
} catch (error) {
logger.error("Backup: An error occurred while disabling key backup:", error);
this.logger.error("Backup: An error occurred while disabling key backup:", error);
}
this.emit(CryptoEvent.KeyBackupFailed, err.data.errcode!);
// There was an active backup and we are out of sync with the server
@@ -480,7 +483,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
continue;
}
} catch (error) {
logger.warn(
this.logger.warn(
"Backup: An error occurred while retrieving a rate-limit retry delay",
error,
);
@@ -590,7 +593,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
* @param version - The backup version to delete.
*/
public async deleteKeyBackupVersion(version: string): Promise<void> {
logger.debug(`deleteKeyBackupVersion v:${version}`);
this.logger.debug(`deleteKeyBackupVersion v:${version}`);
const path = encodeUri("/room_keys/version/$version", { $version: version });
await this.http.authedRequest<void>(Method.Delete, path, undefined, undefined, {
prefix: ClientPrefix.V3,
@@ -607,7 +610,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
* @param decryptionKey - The private key to use for decryption.
*/
public createBackupDecryptor(decryptionKey: RustSdkCryptoJs.BackupDecryptionKey): BackupDecryptor {
return new RustBackupDecryptor(decryptionKey);
return new RustBackupDecryptor(this.logger, decryptionKey);
}
/**
@@ -707,7 +710,7 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
totalFailures += currentChunk.length;
// We failed to import some keys, but we should still try to import the rest?
// Log the error and continue
logger.error("Error importing keys from backup", e);
this.logger.error("Error importing keys from backup", e);
}
opts?.progressCallback?.({
@@ -755,27 +758,28 @@ export class RustBackupManager extends TypedEventEmitter<RustBackupCryptoEvents,
return { total: totalKeyCount, imported: totalImported };
}
}
/**
* Checks if the provided backup info matches the given private key.
*
* @param info - The backup info to check.
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
*/
function backupInfoMatchesBackupDecryptionKey(
info: KeyBackupInfo,
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
): boolean {
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
return false;
/**
* Checks if the provided backup info matches the given private key.
*
* @param info - The backup info to check.
* @param backupDecryptionKey - The `BackupDecryptionKey` private key to check against.
* @returns `true` if the private key can decrypt the backup, `false` otherwise.
*/
private backupInfoMatchesBackupDecryptionKey(
info: KeyBackupInfo,
backupDecryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
): boolean {
if (info.algorithm !== "m.megolm_backup.v1.curve25519-aes-sha2") {
this.logger.warn("backupMatchesPrivateKey: Unsupported backup algorithm", info.algorithm);
return false;
}
return (
(info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64
);
}
return (info.auth_data as Curve25519AuthData)?.public_key === backupDecryptionKey.megolmV1PublicKey.publicKeyBase64;
}
/**
* Implementation of {@link BackupDecryptor} for the rust crypto backend.
*/
@@ -783,7 +787,10 @@ export class RustBackupDecryptor implements BackupDecryptor {
private decryptionKey: RustSdkCryptoJs.BackupDecryptionKey;
public sourceTrusted: boolean;
public constructor(decryptionKey: RustSdkCryptoJs.BackupDecryptionKey) {
public constructor(
private readonly logger: Logger,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey,
) {
this.decryptionKey = decryptionKey;
this.sourceTrusted = false;
}
@@ -807,7 +814,7 @@ export class RustBackupDecryptor implements BackupDecryptor {
decrypted.session_id = sessionId;
keys.push(decrypted);
} catch (e) {
logger.log("Failed to decrypt megolm session from backup", e, sessionData);
this.logger.debug("Failed to decrypt megolm session from backup", e, sessionData);
}
}
return keys;
+59 -24
View File
@@ -19,7 +19,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
import { KnownMembership } from "../@types/membership.ts";
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts";
import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts";
import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
@@ -35,7 +35,7 @@ import { type IHttpOpts, type MatrixHttpApi, Method } from "../http-api/index.ts
import { RoomEncryptor } from "./RoomEncryptor.ts";
import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { KeyClaimManager } from "./KeyClaimManager.ts";
import { logDuration, MapWithDefault } from "../utils.ts";
import { MapWithDefault } from "../utils.ts";
import {
type BackupTrustInfo,
type BootstrapCrossSigningOpts,
@@ -163,7 +163,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
private readonly cryptoCallbacks: CryptoCallbacks,
) {
super();
this.outgoingRequestProcessor = new OutgoingRequestProcessor(olmMachine, http);
this.outgoingRequestProcessor = new OutgoingRequestProcessor(logger, olmMachine, http);
this.outgoingRequestsManager = new OutgoingRequestsManager(
this.logger,
olmMachine,
@@ -172,7 +172,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
this.keyClaimManager = new KeyClaimManager(olmMachine, this.outgoingRequestProcessor);
this.backupManager = new RustBackupManager(olmMachine, http, this.outgoingRequestProcessor);
this.backupManager = new RustBackupManager(logger, olmMachine, http, this.outgoingRequestProcessor);
this.perSessionBackupDownloader = new PerSessionKeyBackupDownloader(
this.logger,
this.olmMachine,
@@ -206,7 +206,12 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
CryptoEvent.DehydratedDeviceRotationError,
]);
this.crossSigningIdentity = new CrossSigningIdentity(olmMachine, this.outgoingRequestProcessor, secretStorage);
this.crossSigningIdentity = new CrossSigningIdentity(
logger,
olmMachine,
this.outgoingRequestProcessor,
secretStorage,
);
// Check and start in background the key backup connection
this.checkKeyBackupAndEnable();
@@ -1481,7 +1486,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @param oneTimeKeysCounts - the received one time key counts
* @param unusedFallbackKeys - the received unused fallback keys
* @param devices - the received device list updates
* @returns A list of preprocessed to-device messages.
* @returns A list of processed to-device messages.
*/
private async receiveSyncChanges({
events,
@@ -1493,18 +1498,13 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
oneTimeKeysCounts?: Map<string, number>;
unusedFallbackKeys?: Set<string>;
devices?: RustSdkCryptoJs.DeviceLists;
}): Promise<IToDeviceEvent[]> {
const result = await logDuration(logger, "receiveSyncChanges", async () => {
return await this.olmMachine.receiveSyncChanges(
events ? JSON.stringify(events) : "[]",
devices,
oneTimeKeysCounts,
unusedFallbackKeys,
);
});
// receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
return JSON.parse(result);
}): Promise<RustSdkCryptoJs.ProcessedToDeviceEvent[]> {
return await this.olmMachine.receiveSyncChanges(
events ? JSON.stringify(events) : "[]",
devices,
oneTimeKeysCounts,
unusedFallbackKeys,
);
}
/** called by the sync loop to preprocess incoming to-device messages
@@ -1512,22 +1512,56 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
* @param events - the received to-device messages
* @returns A list of preprocessed to-device messages.
*/
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<ReceivedToDeviceMessage[]> {
// send the received to-device messages into receiveSyncChanges. We have no info on device-list changes,
// one-time-keys, or fallback keys, so just pass empty data.
const processed = await this.receiveSyncChanges({ events });
// look for interesting to-device messages
const received: ReceivedToDeviceMessage[] = [];
for (const message of processed) {
if (message.type === EventType.KeyVerificationRequest) {
const sender = message.sender;
const transactionId = message.content.transaction_id;
const parsedMessage: IToDeviceEvent = JSON.parse(message.rawEvent);
// look for interesting to-device messages
if (parsedMessage.type === EventType.KeyVerificationRequest) {
const sender = parsedMessage.sender;
const transactionId = parsedMessage.content.transaction_id;
if (transactionId && sender) {
this.onIncomingKeyVerificationRequest(sender, transactionId);
}
}
switch (message.type) {
case RustSdkCryptoJs.ProcessedToDeviceEventType.Decrypted: {
const encryptionInfo = (message as RustSdkCryptoJs.DecryptedToDeviceEvent).encryptionInfo;
received.push({
message: parsedMessage,
encryptionInfo: {
sender: encryptionInfo.sender.toString(),
senderDevice: encryptionInfo.senderDevice?.toString(),
senderCurve25519KeyBase64: encryptionInfo.senderCurve25519Key,
senderVerified: encryptionInfo.isSenderVerified(),
},
});
break;
}
case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: {
received.push({
message: parsedMessage,
encryptionInfo: null,
});
break;
}
case RustSdkCryptoJs.ProcessedToDeviceEventType.UnableToDecrypt:
// ignore messages we cannot decrypt
break;
case RustSdkCryptoJs.ProcessedToDeviceEventType.Invalid:
// ignore invalid messages
break;
}
}
return processed;
return received;
}
/** called by the sync loop to process one time key counts and unused fallback keys
@@ -1596,6 +1630,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
existingEncryptor.onCryptoEvent(config);
} else {
this.roomEncryptors[room.roomId] = new RoomEncryptor(
this.logger.getChild(`[${room.roomId} encryption]`),
this.olmMachine,
this.keyClaimManager,
this.outgoingRequestsManager,
+7 -4
View File
@@ -15,7 +15,7 @@ limitations under the License.
*/
import { type IHttpOpts, type MatrixHttpApi, Method } from "./http-api/index.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
// How often we update the server capabilities.
// 6 hours - an arbitrary value, but they should change very infrequently.
@@ -78,7 +78,10 @@ export class ServerCapabilities {
private retryTimeout?: ReturnType<typeof setTimeout>;
private refreshTimeout?: ReturnType<typeof setInterval>;
public constructor(private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>) {}
public constructor(
private readonly logger: Logger,
private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
) {}
/**
* Starts periodically fetching the server capabilities.
@@ -117,12 +120,12 @@ export class ServerCapabilities {
await this.fetchCapabilities();
this.clearTimeouts();
this.refreshTimeout = setTimeout(this.poll, CAPABILITIES_CACHE_MS);
logger.debug("Fetched new server capabilities");
this.logger.debug("Fetched new server capabilities");
} catch (e) {
this.clearTimeouts();
const howLong = Math.floor(CAPABILITIES_RETRY_MS + Math.random() * 5000);
this.retryTimeout = setTimeout(this.poll, howLong);
logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
this.logger.warn(`Failed to refresh capabilities: retrying in ${howLong}ms`, e);
}
};
+33 -57
View File
@@ -28,7 +28,7 @@ import {
defaultClientOpts,
defaultSyncApiOpts,
type SetPresence,
mapToDeviceEvent,
processToDeviceMessages,
} from "./sync.ts";
import { type MatrixEvent } from "./models/event.ts";
import {
@@ -37,6 +37,7 @@ import {
type IStateEvent,
type IStrippedState,
type ISyncResponse,
type ReceivedToDeviceMessage,
} from "./sync-accumulator.ts";
import { MatrixError } from "./http-api/index.ts";
import {
@@ -150,51 +151,20 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
}
public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
const cancelledKeyVerificationTxns: string[] = [];
let events = data["events"] || [];
if (events.length > 0 && this.cryptoCallbacks) {
events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
const events = data["events"] || [];
let receivedToDeviceMessages: ReceivedToDeviceMessage[];
if (this.cryptoCallbacks) {
receivedToDeviceMessages = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
} else {
receivedToDeviceMessages = events.map((rawEvent) =>
// Crypto is not enabled, so we just return the events.
({
message: rawEvent,
encryptionInfo: null,
}),
);
}
events
.map(mapToDeviceEvent)
.map((toDeviceEvent) => {
// map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
// if there's an accompanying m.key.verification.cancel event, so
// we pull out the transaction IDs from the cancellation events
// so we can flag the verification events as cancelled in the loop
// below.
if (toDeviceEvent.getType() === "m.key.verification.cancel") {
const txnId: string | undefined = toDeviceEvent.getContent()["transaction_id"];
if (txnId) {
cancelledKeyVerificationTxns.push(txnId);
}
}
// as mentioned above, .map is a cheap inline forEach, so return
// the unmodified event.
return toDeviceEvent;
})
.forEach((toDeviceEvent) => {
const content = toDeviceEvent.getContent();
if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
// the mapper already logged a warning.
logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
return;
}
if (
toDeviceEvent.getType() === "m.key.verification.start" ||
toDeviceEvent.getType() === "m.key.verification.request"
) {
const txnId = content["transaction_id"];
if (cancelledKeyVerificationTxns.includes(txnId)) {
toDeviceEvent.flagCancelled();
}
}
this.client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
});
processToDeviceMessages(receivedToDeviceMessages, this.client);
this.nextBatch = data.next_batch;
}
@@ -356,8 +326,8 @@ export class SlidingSyncSdk {
public constructor(
private readonly slidingSync: SlidingSync,
private readonly client: MatrixClient,
opts?: IStoredClientOpts,
syncOpts?: SyncApiOptions,
opts: IStoredClientOpts | undefined,
syncOpts: SyncApiOptions,
) {
this.opts = defaultClientOpts(opts);
this.syncOpts = defaultSyncApiOpts(syncOpts);
@@ -386,7 +356,11 @@ export class SlidingSyncSdk {
let room = this.client.store.getRoom(roomId);
if (!room) {
if (!roomData.initial) {
logger.debug("initial flag not set but no stored room exists for room ", roomId, roomData);
this.syncOpts.logger.debug(
"initial flag not set but no stored room exists for room ",
roomId,
roomData,
);
return;
}
room = _createAndReEmitRoom(this.client, roomId, this.opts);
@@ -396,7 +370,7 @@ export class SlidingSyncSdk {
private onLifecycle(state: SlidingSyncState, resp: MSC3575SlidingSyncResponse | null, err?: Error): void {
if (err) {
logger.debug("onLifecycle", state, err);
this.syncOpts.logger.debug("onLifecycle", state, err);
}
switch (state) {
case SlidingSyncState.Complete:
@@ -437,7 +411,9 @@ export class SlidingSyncSdk {
}
} else {
this.failCount = 0;
logger.log(`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`);
this.syncOpts.logger.debug(
`SlidingSyncState.RequestFinished with ${Object.keys(resp?.rooms || []).length} rooms`,
);
}
break;
}
@@ -556,7 +532,7 @@ export class SlidingSyncSdk {
private shouldAbortSync(error: MatrixError): boolean {
if (error.errcode === "M_UNKNOWN_TOKEN") {
// The logout already happened, we just need to stop.
logger.warn("Token no longer valid - assuming logout");
this.syncOpts.logger.warn("Token no longer valid - assuming logout");
this.stop();
this.updateSyncState(SyncState.Error, { error });
return true;
@@ -684,7 +660,7 @@ export class SlidingSyncSdk {
for (let i = timelineEvents.length - 1; i >= 0; i--) {
const eventId = timelineEvents[i].getId();
if (room.getTimelineForEvent(eventId)) {
logger.debug("Already have event " + eventId + " in limited " +
this.syncOpts.logger.debug("Already have event " + eventId + " in limited " +
"sync - not resetting");
limited = false;
@@ -893,19 +869,19 @@ export class SlidingSyncSdk {
* Main entry point. Blocks until stop() is called.
*/
public async sync(): Promise<void> {
logger.debug("Sliding sync init loop");
this.syncOpts.logger.debug("Sliding sync init loop");
// 1) We need to get push rules so we can check if events should bing as we get
// them from /sync.
while (!this.client.isGuest()) {
try {
logger.debug("Getting push rules...");
this.syncOpts.logger.debug("Getting push rules...");
const result = await this.client.getPushRules();
logger.debug("Got push rules");
this.syncOpts.logger.debug("Got push rules");
this.client.pushRules = result;
break;
} catch (err) {
logger.error("Getting push rules failed", err);
this.syncOpts.logger.error("Getting push rules failed", err);
if (this.shouldAbortSync(<MatrixError>err)) {
return;
}
@@ -920,7 +896,7 @@ export class SlidingSyncSdk {
* Stops the sync object from syncing.
*/
public stop(): void {
logger.debug("SyncApi.stop");
this.syncOpts.logger.debug("SyncApi.stop");
this.slidingSync.stop();
}
+20
View File
@@ -25,6 +25,7 @@ import { type IRoomSummary } from "./models/room-summary.ts";
import { type EventType } from "./@types/event.ts";
import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
import { ReceiptAccumulator } from "./receipt-accumulator.ts";
import { type OlmEncryptionInfo } from "./crypto-api/index.ts";
interface IOpts {
/**
@@ -134,12 +135,31 @@ interface IAccountData {
events: IMinimalEvent[];
}
/** A to-device message as received from the sync. */
export interface IToDeviceEvent {
content: IContent;
sender: string;
type: string;
}
/**
* A (possibly decrypted) to-device message after it has been successfully processed by the sdk.
*
* If the message was encrypted, the `encryptionInfo` field will contain the encryption information.
* If the message was sent in clear, this field will be null.
*
* The `message` field contains the message `type`, `content`, and `sender` as if the message was sent in clear.
*/
export interface ReceivedToDeviceMessage {
/** The message type, content, and sender as if the message was sent in clear. */
message: IToDeviceEvent;
/**
* Information about the encryption of the message.
* Will be null if the message was sent in clear
*/
encryptionInfo: OlmEncryptionInfo | null;
}
interface IToDevice {
events: IToDeviceEvent[];
}
+123 -100
View File
@@ -31,7 +31,7 @@ import { NotificationCountType, Room, RoomEvent } from "./models/room.ts";
import { deepCopy, noUnsafeEventProps, promiseMapSeries, unsafeProp } from "./utils.ts";
import { Filter } from "./filter.ts";
import { EventTimeline } from "./models/event-timeline.ts";
import { logger } from "./logger.ts";
import { type Logger } from "./logger.ts";
import {
ClientEvent,
type IStoredClientOpts,
@@ -44,8 +44,8 @@ import {
type IInvitedRoom,
type IInviteState,
type IJoinedRoom,
type ILeftRoom,
type IKnockedRoom,
type ILeftRoom,
type IMinimalEvent,
type IRoomEvent,
type IStateEvent,
@@ -53,13 +53,14 @@ import {
type ISyncResponse,
type ITimeline,
type IToDeviceEvent,
type ReceivedToDeviceMessage,
} from "./sync-accumulator.ts";
import { MatrixEvent, type IEvent } from "./models/event.ts";
import { MatrixEvent } from "./models/event.ts";
import { type MatrixError, Method } from "./http-api/index.ts";
import { type ISavedSync } from "./store/index.ts";
import { EventType } from "./@types/event.ts";
import { type IPushRules } from "./@types/PushRules.ts";
import { RoomStateEvent, type IMarkerFoundOptions } from "./models/room-state.ts";
import { type IMarkerFoundOptions, RoomStateEvent } from "./models/room-state.ts";
import { RoomMemberEvent } from "./models/room-member.ts";
import { BeaconEvent } from "./models/beacon.ts";
import { type IEventsResponse } from "./@types/requests.ts";
@@ -67,8 +68,6 @@ import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts";
import { Feature, ServerSupport } from "./feature.ts";
import { KnownMembership } from "./@types/membership.ts";
const DEBUG = true;
// /sync requests allow you to set a timeout= but the request may continue
// beyond that and wedge forever, so we need to track how long we are willing
// to keep open the connection. This constant is *ADDED* to the timeout= value
@@ -111,12 +110,6 @@ function getFilterName(userId: string, suffix?: string): string {
return `FILTER_SYNC_${userId}` + (suffix ? "_" + suffix : "");
}
/* istanbul ignore next */
function debuglog(...params: any[]): void {
if (!DEBUG) return;
logger.log(...params);
}
/**
* Options passed into the constructor of SyncApi by MatrixClient
*/
@@ -133,6 +126,9 @@ export interface SyncApiOptions {
* there are other references to the timelines for this room.
*/
canResetEntireTimeline?: ResetTimelineCallback;
/** Logger instance to use for writing debug logs. */
logger: Logger;
}
interface ISyncOptions {
@@ -201,7 +197,7 @@ export function defaultClientOpts(opts?: IStoredClientOpts): IStoredClientOpts {
};
}
export function defaultSyncApiOpts(syncOpts?: SyncApiOptions): SyncApiOptions {
export function defaultSyncApiOpts(syncOpts: SyncApiOptions): SyncApiOptions {
return {
canResetEntireTimeline: (_roomId): boolean => false,
...syncOpts,
@@ -235,8 +231,8 @@ export class SyncApi {
*/
public constructor(
private readonly client: MatrixClient,
opts?: IStoredClientOpts,
syncOpts?: SyncApiOptions,
opts: IStoredClientOpts | undefined,
syncOpts: SyncApiOptions,
) {
this.opts = defaultClientOpts(opts);
this.syncOpts = defaultSyncApiOpts(syncOpts);
@@ -276,7 +272,7 @@ export class SyncApi {
// 2. If it's from the first state we're seeing after joining the room
// 3. Or whether it's coming from `syncFromCache`
if (timelineWasEmpty) {
logger.debug(
this.syncOpts.logger.debug(
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} ` +
`because the timeline was empty before the marker arrived which means there is nothing to refresh.`,
);
@@ -308,14 +304,14 @@ export class SyncApi {
if (isValidMsc2716Event) {
// Saw new marker event, let's let the clients know they should
// refresh the timeline.
logger.debug(
this.syncOpts.logger.debug(
`MarkerState: Timeline needs to be refreshed because ` +
`a new markerEventId=${markerEvent.getId()} was sent in roomId=${room.roomId}`,
);
room.setTimelineNeedsRefresh(true);
room.emit(RoomEvent.HistoryImportedWithinTimeline, markerEvent, room);
} else {
logger.debug(
this.syncOpts.logger.debug(
`MarkerState: Ignoring markerEventId=${markerEvent.getId()} in roomId=${room.roomId} because ` +
`MSC2716 is not supported in the room version or for any room version, the marker wasn't sent ` +
`by the room creator.`,
@@ -488,7 +484,7 @@ export class SyncApi {
*/
private peekPoll(peekRoom: Room, token?: string): void {
if (this._peekRoom !== peekRoom) {
debuglog("Stopped peeking in room %s", peekRoom.roomId);
this.syncOpts.logger.debug("Stopped peeking in room %s", peekRoom.roomId);
return;
}
@@ -511,7 +507,7 @@ export class SyncApi {
.then(
async (res) => {
if (this._peekRoom !== peekRoom) {
debuglog("Stopped peeking in room %s", peekRoom.roomId);
this.syncOpts.logger.debug("Stopped peeking in room %s", peekRoom.roomId);
return;
}
// We have a problem that we get presence both from /events and /sync
@@ -553,7 +549,7 @@ export class SyncApi {
this.peekPoll(peekRoom, res.end);
},
(err) => {
logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
this.syncOpts.logger.error("[%s] Peek poll failed: %s", peekRoom.roomId, err);
setTimeout(() => {
this.peekPoll(peekRoom, token);
}, 30 * 1000);
@@ -594,7 +590,7 @@ export class SyncApi {
private shouldAbortSync(error: MatrixError): boolean {
if (error.errcode === "M_UNKNOWN_TOKEN") {
// The logout already happened, we just need to stop.
logger.warn("Token no longer valid - assuming logout");
this.syncOpts.logger.warn("Token no longer valid - assuming logout");
this.stop();
this.updateSyncState(SyncState.Error, { error });
return true;
@@ -604,17 +600,17 @@ export class SyncApi {
private getPushRules = async (): Promise<void> => {
try {
debuglog("Getting push rules...");
this.syncOpts.logger.debug("Getting push rules...");
const result = await this.client.getPushRules();
debuglog("Got push rules");
this.syncOpts.logger.debug("Got push rules");
this.client.pushRules = result;
} catch (err) {
logger.error("Getting push rules failed", err);
this.syncOpts.logger.error("Getting push rules failed", err);
if (this.shouldAbortSync(<MatrixError>err)) return;
// wait for saved sync to complete before doing anything else,
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying push rules...");
this.syncOpts.logger.debug("Waiting for saved sync before retrying push rules...");
await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
return this.getPushRules(); // try again
}
@@ -629,12 +625,12 @@ export class SyncApi {
};
private prepareLazyLoadingForSync = async (): Promise<void> => {
debuglog("Prepare lazy loading for sync...");
this.syncOpts.logger.debug("Prepare lazy loading for sync...");
if (this.client.isGuest()) {
this.opts.lazyLoadMembers = false;
}
if (this.opts.lazyLoadMembers) {
debuglog("Enabling lazy load on sync filter...");
this.syncOpts.logger.debug("Enabling lazy load on sync filter...");
if (!this.opts.filter) {
this.opts.filter = this.buildDefaultFilter();
}
@@ -644,11 +640,11 @@ export class SyncApi {
private storeClientOptions = async (): Promise<void> => {
try {
debuglog("Storing client options...");
this.syncOpts.logger.debug("Storing client options...");
await this.client.storeClientOptions();
debuglog("Stored client options");
this.syncOpts.logger.debug("Stored client options");
} catch (err) {
logger.error("Storing client options failed", err);
this.syncOpts.logger.error("Storing client options failed", err);
throw err;
}
};
@@ -657,7 +653,7 @@ export class SyncApi {
filterId?: string;
filter?: Filter;
}> => {
debuglog("Getting filter...");
this.syncOpts.logger.debug("Getting filter...");
let filter: Filter;
if (this.opts.filter) {
filter = this.opts.filter;
@@ -669,11 +665,11 @@ export class SyncApi {
try {
filterId = await this.client.getOrCreateFilter(getFilterName(this.client.credentials.userId!), filter);
} catch (err) {
logger.error("Getting filter failed", err);
this.syncOpts.logger.error("Getting filter failed", err);
if (this.shouldAbortSync(<MatrixError>err)) return {};
// wait for saved sync to complete before doing anything else,
// otherwise the sync state will end up being incorrect
debuglog("Waiting for saved sync before retrying filter...");
this.syncOpts.logger.debug("Waiting for saved sync before retrying filter...");
await this.recoverFromSyncStartupError(this.savedSyncPromise, <Error>err);
return this.getFilter(); // try again
}
@@ -699,22 +695,22 @@ export class SyncApi {
// Pull the saved sync token out first, before the worker starts sending
// all the sync data which could take a while. This will let us send our
// first incremental sync request before we've processed our saved data.
debuglog("Getting saved sync token...");
this.syncOpts.logger.debug("Getting saved sync token...");
const savedSyncTokenPromise = this.client.store.getSavedSyncToken().then((tok) => {
debuglog("Got saved sync token");
this.syncOpts.logger.debug("Got saved sync token");
return tok;
});
this.savedSyncPromise = this.client.store
.getSavedSync()
.then((savedSync) => {
debuglog(`Got reply from saved sync, exists? ${!!savedSync}`);
this.syncOpts.logger.debug(`Got reply from saved sync, exists? ${!!savedSync}`);
if (savedSync) {
return this.syncFromCache(savedSync);
}
})
.catch((err) => {
logger.error("Getting saved sync failed", err);
this.syncOpts.logger.error("Getting saved sync failed", err);
});
// We need to do one-off checks before we can begin the /sync loop.
@@ -746,9 +742,9 @@ export class SyncApi {
const savedSyncToken = await savedSyncTokenPromise;
if (savedSyncToken) {
debuglog("Sending first sync request...");
this.syncOpts.logger.debug("Sending first sync request...");
} else {
debuglog("Sending initial sync request...");
this.syncOpts.logger.debug("Sending initial sync request...");
const initialFilter = this.buildDefaultFilter();
initialFilter.setDefinition(filter.getDefinition());
initialFilter.setTimelineLimit(this.opts.initialSyncLimit!);
@@ -762,7 +758,7 @@ export class SyncApi {
}
// Now wait for the saved sync to finish...
debuglog("Waiting for saved sync before starting sync processing...");
this.syncOpts.logger.debug("Waiting for saved sync before starting sync processing...");
await this.savedSyncPromise;
// process the first sync request and continue syncing with the normal filterId
return this.doSync({ filter: filterId });
@@ -772,7 +768,7 @@ export class SyncApi {
* Stops the sync object from syncing.
*/
public stop(): void {
debuglog("SyncApi.stop");
this.syncOpts.logger.debug("SyncApi.stop");
// It is necessary to check for the existance of
// globalThis.window AND globalThis.window.removeEventListener.
// Some platforms (e.g. React Native) register globalThis.window,
@@ -804,7 +800,7 @@ export class SyncApi {
* should have been acquired via client.store.getSavedSync().
*/
private async syncFromCache(savedSync: ISavedSync): Promise<void> {
debuglog("sync(): not doing HTTP hit, instead returning stored /sync data");
this.syncOpts.logger.debug("sync(): not doing HTTP hit, instead returning stored /sync data");
const nextSyncToken = savedSync.nextBatch;
@@ -829,7 +825,7 @@ export class SyncApi {
try {
await this.processSyncResponse(syncEventData, data);
} catch (e) {
logger.error("Error processing cached sync", e);
this.syncOpts.logger.error("Error processing cached sync", e);
}
// Don't emit a prepared if we've bailed because the store is invalid:
@@ -880,7 +876,7 @@ export class SyncApi {
} catch (e) {
// log the exception with stack if we have it, else fall back
// to the plain description
logger.error("Caught /sync error", e);
this.syncOpts.logger.error("Caught /sync error", e);
// Emit the exception for client handling
this.client.emit(ClientEvent.SyncUnexpectedError, <Error>e);
@@ -915,7 +911,7 @@ export class SyncApi {
}
if (!this.running) {
debuglog("Sync no longer running: exiting.");
this.syncOpts.logger.debug("Sync no longer running: exiting.");
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.reject();
this.connectionReturnedResolvers = undefined;
@@ -998,7 +994,7 @@ export class SyncApi {
private async onSyncError(err: MatrixError): Promise<boolean> {
if (!this.running) {
debuglog("Sync no longer running: exiting");
this.syncOpts.logger.debug("Sync no longer running: exiting");
if (this.connectionReturnedResolvers) {
this.connectionReturnedResolvers.reject();
this.connectionReturnedResolvers = undefined;
@@ -1007,16 +1003,16 @@ export class SyncApi {
return true; // abort
}
logger.error("/sync error %s", err);
this.syncOpts.logger.error("/sync error %s", err);
if (this.shouldAbortSync(err)) {
return true; // abort
}
this.failedSyncCount++;
logger.log("Number of consecutive failed sync requests:", this.failedSyncCount);
this.syncOpts.logger.debug("Number of consecutive failed sync requests:", this.failedSyncCount);
debuglog("Starting keep-alive");
this.syncOpts.logger.debug("Starting keep-alive");
// Note that we do *not* mark the sync connection as
// lost yet: we only do this if a keepalive poke
// fails, since long lived HTTP connections will
@@ -1144,53 +1140,23 @@ export class SyncApi {
// handle to-device events
if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) {
let toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
const toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
let receivedToDeviceMessages: ReceivedToDeviceMessage[];
if (this.syncOpts.cryptoCallbacks) {
toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
receivedToDeviceMessages =
await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
} else {
receivedToDeviceMessages = toDeviceMessages.map((rawEvent) =>
// Crypto is not enabled, so we just return the events.
({
message: rawEvent,
encryptionInfo: null,
}),
);
}
const cancelledKeyVerificationTxns: string[] = [];
toDeviceMessages
.map(mapToDeviceEvent)
.map((toDeviceEvent) => {
// map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
// if there's an accompanying m.key.verification.cancel event, so
// we pull out the transaction IDs from the cancellation events
// so we can flag the verification events as cancelled in the loop
// below.
if (toDeviceEvent.getType() === "m.key.verification.cancel") {
const txnId: string = toDeviceEvent.getContent()["transaction_id"];
if (txnId) {
cancelledKeyVerificationTxns.push(txnId);
}
}
// as mentioned above, .map is a cheap inline forEach, so return
// the unmodified event.
return toDeviceEvent;
})
.forEach(function (toDeviceEvent) {
const content = toDeviceEvent.getContent();
if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") {
// the mapper already logged a warning.
logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender());
return;
}
if (
toDeviceEvent.getType() === "m.key.verification.start" ||
toDeviceEvent.getType() === "m.key.verification.request"
) {
const txnId = content["transaction_id"];
if (cancelledKeyVerificationTxns.includes(txnId)) {
toDeviceEvent.flagCancelled();
}
}
client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent);
});
processToDeviceMessages(receivedToDeviceMessages, client);
} else {
// no more to-device events: we can stop polling with a short timeout.
this.catchingUp = false;
@@ -1353,7 +1319,7 @@ export class SyncApi {
for (let i = timelineEvents.length - 1; i >= 0; i--) {
const eventId = timelineEvents[i].getId()!;
if (room.getTimelineForEvent(eventId)) {
debuglog(`Already have event ${eventId} in limited sync - not resetting`);
this.syncOpts.logger.debug(`Already have event ${eventId} in limited sync - not resetting`);
limited = false;
// we might still be missing some of the events before i;
@@ -1413,7 +1379,7 @@ export class SyncApi {
await this.injectRoomEvents(room, stateEvents, undefined, timelineEvents, syncEventData.fromCache);
}
} catch (e) {
logger.error(`Failed to process events on room ${room.roomId}:`, e);
this.syncOpts.logger.error(`Failed to process events on room ${room.roomId}:`, e);
}
// set summary after processing events,
@@ -1894,7 +1860,7 @@ export class SyncApi {
* but this might help us reconnect a little faster.
*/
private onOnline = (): void => {
debuglog("Browser thinks we are back online");
this.syncOpts.logger.debug("Browser thinks we are back online");
this.startKeepAlives(0);
};
}
@@ -1946,8 +1912,65 @@ export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts:
return room;
}
export function mapToDeviceEvent(plainOldJsObject: Partial<IEvent>): MatrixEvent {
// to-device events should not have a `room_id` property, but let's be sure
delete plainOldJsObject.room_id;
return new MatrixEvent(plainOldJsObject);
/**
* Process a list of (decrypted, where possible) received to-device events.
*
* Emits the appropriate {@link ClientEvent.ReceivedToDeviceMessage} event.
* Also converts the events into `MatrixEvent`s, and emits the now deprecated {@link ClientEvent.ToDeviceEvent} events for compatibility.
* */
export function processToDeviceMessages(toDeviceMessages: ReceivedToDeviceMessage[], client: MatrixClient): void {
const cancelledKeyVerificationTxns: string[] = [];
toDeviceMessages
.map((processedMessage) => {
// map is a cheap inline forEach
// We want to flag m.key.verification.start events as cancelled
// if there's an accompanying m.key.verification.cancel event, so
// we pull out the transaction IDs from the cancellation events
// so we can flag the verification events as cancelled in the loop
// below.
if (processedMessage.message.type === "m.key.verification.cancel") {
const txnId: string = processedMessage.message.content["transaction_id"];
if (txnId) {
cancelledKeyVerificationTxns.push(txnId);
}
}
// as mentioned above, .map is a cheap inline forEach, so return
// the unmodified event.
return processedMessage;
})
.forEach(function (processedEvent) {
// For backwards compatibility, we also emit the event as a `MatrixEvent` using `ClientEvent.ToDeviceEvent`.
{
const toDeviceEvent = processedEvent.message;
const content = toDeviceEvent.content;
// The message is cloned before being passed to the MatrixEvent constructor, because
// the `makeEncrypted` method will mutate the type and content properties of the original message and will interfere
// with the emitted event for `ReceivedToDeviceMessage`.
const deprecatedCompatibilityEvent = new MatrixEvent(Object.assign({}, toDeviceEvent));
if (
toDeviceEvent.type === "m.key.verification.start" ||
toDeviceEvent.type === "m.key.verification.request"
) {
const txnId = content["transaction_id"];
if (cancelledKeyVerificationTxns.includes(txnId)) {
deprecatedCompatibilityEvent.flagCancelled();
}
}
if (processedEvent.encryptionInfo) {
// Restore partially the legacy behavior to detect encrypted messages.
// Now `event.isEncrypted()` will return true.
deprecatedCompatibilityEvent.makeEncrypted(
EventType.RoomMessageEncrypted,
{ ciphertext: "" },
processedEvent.encryptionInfo.senderCurve25519KeyBase64,
"",
);
}
client.emit(ClientEvent.ToDeviceEvent, deprecatedCompatibilityEvent);
}
client.emit(ClientEvent.ReceivedToDeviceMessage, processedEvent);
});
}
+3 -1
View File
@@ -435,7 +435,9 @@ export function immediate(): Promise<void> {
export function isNullOrUndefined(val: any): boolean {
return val === null || val === undefined;
}
/**
* @deprecated use {@link PromiseWithResolvers} instead.
*/
export type IDeferred<T> = PromiseWithResolvers<T>;
/**
+3 -1
View File
@@ -10,7 +10,9 @@
"declaration": true,
"strict": true,
"allowImportingTsExtensions": true,
"lib": ["es2024"]
"lib": ["es2024"],
"baseUrl": ".",
"typeRoots": ["./node_modules/@types"]
},
"include": ["./src/**/*.ts", "./spec/**/*.ts"]
}
+577 -434
View File
File diff suppressed because it is too large Load Diff