Compare commits

...

253 Commits

Author SHA1 Message Date
RiotRobot da3d5c4a43 v31.5.0 2024-03-12 18:33:38 +00:00
RiotRobot ee8413beff v31.5.0-rc.0 2024-03-05 14:03:49 +00:00
Michael Telatynski e4d4628cc8 When merging release notes, allow considering later versions in the same release cycle (#4085)
* When merging release notes, allow considering later versions in the same major.minor.patch set

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

* Tweak comments

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-03-05 09:34:09 +00:00
David Langley b2e09250d9 Add job to automate adding new issues to the new project (#4087)
* add job to automate adding new issues to the new project

* missing jobs:
2024-02-29 12:20:53 +00:00
RiotRobot 6176faef48 Resetting package fields for development 2024-02-27 12:51:20 +00:00
RiotRobot 453cdd9eda Merge branch 'master' into develop 2024-02-27 12:51:18 +00:00
RiotRobot 6529f02c28 v31.4.0 2024-02-27 12:50:27 +00:00
Valere d3dfcd9242 Add basic retry for rust crypto outgoing requests (#4061)
* Add basic retry for outgoing requests

* Update doc

* Remove 504 from retryable

* Retry all 5xx and clarify client timeouts

* code review cleaning

* do not retry rust request if M_TOO_LARGE

* refactor use common retry alg between scheduler and rust requests

* Code review, cleaning and doc
2024-02-26 14:07:28 +00:00
Michael Telatynski a26fc46ed4 Update MSC2965 OIDC Discovery implementation (#4064) 2024-02-23 16:43:11 +00:00
renovate[bot] be3913e8a5 Update typedoc (#3958)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 17:30:14 +00:00
renovate[bot] c1e0192baf Update dependency eslint-plugin-unicorn to v51 (#4083)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 17:06:12 +00:00
Andy Balaam 8123e9a3f1 Bump matrix-react-sdk-crypto-wasm to v4.6.0 (#4082)
To reduce memory usage during export - see https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/105
2024-02-22 11:55:27 +00:00
Michael Telatynski 0425f4e5c8 Merge remote-tracking branch 'origin/develop' into develop 2024-02-22 10:49:07 +00:00
Michael Telatynski 5b74b446d4 Prune broken docs symlinks
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-22 10:48:50 +00:00
renovate[bot] 624914a565 Update dependency husky to v9 (#4051)
* Update dependency husky to v9

* Iterate

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-22 09:46:30 +00:00
renovate[bot] 4da9627727 Update all non-major dependencies (#4076)
* Update all non-major dependencies

* Prettier

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-22 09:35:20 +00:00
renovate[bot] 1cb30bfe9b Update typescript-eslint monorepo to v7 (#4078)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 09:04:17 +00:00
renovate[bot] 12308b4c07 Update dependency @types/node to v18.19.17 (#4075)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-22 09:02:45 +00:00
Michael Telatynski 7f25162725 Update pull_request.yaml 2024-02-21 21:31:43 +00:00
David Baker 4826868a8f Update stale comment (#4080)
And remove line that set it for it to just get overwritten
2024-02-21 19:01:16 +00:00
renovate[bot] 91bde6afa1 Update dependency typedoc-plugin-coverage to v3 (#4077)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-21 18:45:39 +00:00
RiotRobot 95842c2b91 v31.4.0-rc.0 2024-02-21 17:56:16 +00:00
Michael Telatynski c27c357688 Validate account_management_uri and account_management_actions_supported from OIDC Issuer well-known (#4074)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-21 14:56:11 +00:00
renovate[bot] b474439256 Update dependency oidc-client-ts to v3 (#4052)
* Update dependency oidc-client-ts to v3

* Update jwt-decode so that oidc-client-ts doesn't run its own and thus we can mock

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

* Merge

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

* delint

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

* Sort package.json

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

* Ensure oidc-client-ts 3.0.1 to drop crypto-js

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-19 17:20:56 +00:00
Michael Telatynski 42dc498359 Update pull_request.yaml 2024-02-19 13:49:48 +00:00
Michael Telatynski ca914c97e0 Allow specifying OIDC url state parameter for passing data to callback (#4068)
* Allow specifying more OIDC client metadata for dynamic registration

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

* Allow specifying url_state for dynamic oidc client registration

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

* Export NonEmptyArray type

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

* Allow specifying more OIDC client metadata for dynamic registration

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

* Export NonEmptyArray type

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

* Fix test

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-19 13:03:53 +00:00
Michael Telatynski f96dac1e5b Saner releases clean up (#4072)
* Remove allchange dependency

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

* Remove stale release scripts

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

* Update pull request template to remove allchange behaviours

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

* Update label check automation

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

* mheap

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

* Re-add node-fetch which was previously transitive via allchange

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

* Use node-fetch@^2

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-19 12:32:26 +00:00
Michael Telatynski 7e0d92cbe0 Add getAuthIssuer method for MSC2965 (#4071)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-19 12:25:24 +00:00
Michael Telatynski fe46fec161 Allow specifying more OIDC client metadata for dynamic registration (#4070) 2024-02-16 14:43:52 +00:00
David Baker 2cf7d819d9 Add unread marker event type (#4069)
* Add unread marker event type

To support setting the 'marked unread' flag

* Await encrypted messages (#4063)

* await encrypted messages
+ fix comments

Signed-off-by: Timo K <toger5@hotmail.de>

* fix Tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* make sonar happy

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>

* Ignore memberships of users that are not in the call (#4065)

* ignore memberships of users that are not in the call

Signed-off-by: Timo K <toger5@hotmail.de>

* recompute memberships on room member change.

Signed-off-by: Timo K <toger5@hotmail.de>

* fix Tests and add test for left member

Signed-off-by: Timo K <toger5@hotmail.de>

* fix event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix import desaster

Signed-off-by: Timo K <toger5@hotmail.de>

* fix mocks

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Timo <16718859+toger5@users.noreply.github.com>
2024-02-16 10:34:15 +00:00
Timo 74c109adac Ignore memberships of users that are not in the call (#4065)
* ignore memberships of users that are not in the call

Signed-off-by: Timo K <toger5@hotmail.de>

* recompute memberships on room member change.

Signed-off-by: Timo K <toger5@hotmail.de>

* fix Tests and add test for left member

Signed-off-by: Timo K <toger5@hotmail.de>

* fix event type

Signed-off-by: Timo K <toger5@hotmail.de>

* fix import desaster

Signed-off-by: Timo K <toger5@hotmail.de>

* fix mocks

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2024-02-14 13:04:40 +00:00
Timo 5d7218476a Await encrypted messages (#4063)
* await encrypted messages
+ fix comments

Signed-off-by: Timo K <toger5@hotmail.de>

* fix Tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test

Signed-off-by: Timo K <toger5@hotmail.de>

* make sonar happy

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2024-02-14 11:25:43 +00:00
RiotRobot d03db17405 Resetting package fields for development 2024-02-13 14:56:34 +00:00
RiotRobot 7520340c46 Merge branch 'master' into develop 2024-02-13 14:56:33 +00:00
RiotRobot b63845a413 v31.3.0 2024-02-13 14:55:35 +00:00
Andy Balaam 1b7695cdca Add AsJson forms of the key import/export methods (#4057) 2024-02-08 13:25:22 +00:00
Valere f4a796ca2f ElementR | Ensure own user and device trust are updated after migration before giving back control to the app. (#4059)
* Ensure own trust after olm migration

* Check legacy store contains data
2024-02-07 16:28:17 +00:00
Andy Balaam 58a5d09aed Bump matrix-sdk-crypto-wasm to 4.5.0 (#4060)
Fixes https://github.com/element-hq/element-web/issues/26948
2024-02-07 15:11:04 +00:00
RiotRobot bc620796c3 v31.3.0-rc.4 2024-02-06 15:36:22 +00:00
David Baker b1cfed1b21 Merge pull request #4056 from matrix-org/backport-4055-to-staging
[Backport staging] Add utility to check for non migrated legacy db
2024-02-06 14:09:14 +00:00
Valere c700d8daa2 Add utility to check for non migrated legacy db (#4055)
* Add utility to check for non migrated legacy db

* code review changes

* add unit tests for existsAndIsNotMigrated

* ensure indexeddb is clean for each state

(cherry picked from commit f94dbdec0f)
2024-02-06 13:18:48 +00:00
Valere f94dbdec0f Add utility to check for non migrated legacy db (#4055)
* Add utility to check for non migrated legacy db

* code review changes

* add unit tests for existsAndIsNotMigrated

* ensure indexeddb is clean for each state
2024-02-05 14:59:02 +00:00
renovate[bot] 173d9c331a Update dependency @types/jest to v29.5.12 (#4049)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 17:39:51 +00:00
renovate[bot] 04ebcf7be7 Update peter-evans/repository-dispatch action to v3 (#4053)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 17:10:27 +00:00
renovate[bot] 5e185ae1e7 Update typescript-eslint monorepo to v6.20.0 (#4050)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 17:08:12 +00:00
renovate[bot] 322cc6da10 Update babel monorepo to v7.23.9 (#4047)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 16:56:15 +00:00
renovate[bot] 014e674a4e Update definitelyTyped (#4048)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 16:55:59 +00:00
renovate[bot] 87acd9dd88 Update all non-major dependencies (#4046)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-02-02 16:55:36 +00:00
Michael Telatynski bbccb98c06 Fix tag_name
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 16:26:40 +00:00
Michael Telatynski ca835a7cf7 Work around github actions id clash issue on release
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 16:22:15 +00:00
Michael Telatynski b8fb10a1d1 Fix merge-release-notes.js
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 16:09:44 +00:00
Michael Telatynski 5e9d2e064e Debug logging
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 16:07:20 +00:00
Michael Telatynski d2753a9aea Debug logging
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 16:03:52 +00:00
Michael Telatynski c6eda55110 Fix badly typed output
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 15:58:48 +00:00
Michael Telatynski 7ce243110f github-script is broken =(
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 15:55:14 +00:00
Michael Telatynski 20d26db37d Roll back to github-script v6 due to bugs
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 15:48:58 +00:00
Michael Telatynski a2a25e71ac Revert "Use ELEMENT_BOT_TOKEN for release-drafter-workflow.yml"
This reverts commit 1e7bc2f31c.
2024-02-02 15:44:43 +00:00
Michael Telatynski 1e7bc2f31c Use ELEMENT_BOT_TOKEN for release-drafter-workflow.yml
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-02 15:41:16 +00:00
Michael Telatynski eec5040bd0 Update release-drafter-workflow.yml 2024-02-02 15:36:57 +00:00
Michael Telatynski 24174c9233 Update release-drafter-workflow.yml 2024-02-02 15:33:38 +00:00
Michael Telatynski 8a2cd3f43c Update release-drafter-workflow.yml 2024-02-02 15:31:36 +00:00
Michael Telatynski eebf40590f Update release-drafter-workflow.yml 2024-02-02 15:21:54 +00:00
Michael Telatynski f5e0b3007b Update release-drafter-workflow.yml 2024-02-02 15:18:37 +00:00
RiotRobot 0d5b6138ae v31.3.0-rc.3 2024-02-02 15:00:42 +00:00
Michael Telatynski 45b02fed5a Update release-npm.yml 2024-02-02 14:59:30 +00:00
RiotRobot 4f63b47134 v31.3.0-rc.2 2024-02-02 14:53:13 +00:00
Michael Telatynski 6edf3990f6 Update release-make.yml 2024-02-02 14:51:49 +00:00
RiotRobot c89f220e52 v31.3.0-rc.1 2024-02-02 14:50:08 +00:00
Michael Telatynski 9675a1584d Update release-make.yml 2024-02-02 14:48:19 +00:00
RiotRobot c81199b9d5 v31.3.0-rc.0 2024-02-02 14:45:44 +00:00
Michael Telatynski 6bdb087883 Update release-drafter.yml 2024-02-02 14:40:39 +00:00
Michael Telatynski 7da620c5be Update release-drafter.yml 2024-02-02 14:40:28 +00:00
Michael Telatynski c4f00895b1 Update release-drafter.yml 2024-02-02 14:38:34 +00:00
Michael Telatynski f8c3973efd Add waits for post-release steps for improved visibility (#4045) 2024-02-02 14:29:43 +00:00
Michael Telatynski 0c0775c0bf Saner releases: improve drafts, handle offcycle releases, bump downstream projects (#4044)
* Switch prepublishOnly to prepack to catch errors earlier

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

* Fix merge-release-notes.js parsing

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

* Improve release drafts and make release-drafter handle offcycle releases better

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

* Tweak release-drafter config

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

* Automate downstream dependency bumping

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

* Remove duplicated docs

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

* Delint

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-01 17:48:44 +00:00
Valere 70edf0f34d WebR: migrate the megolm session imported flag (#4037)
* WebR: migrate the megolm session imported flag

* review: Better doc

Co-authored-by: Denis Kasak <dkasak@termina.org.uk>

---------

Co-authored-by: Denis Kasak <dkasak@termina.org.uk>
2024-02-01 15:40:07 +00:00
Michael Telatynski b46b31563e Saner Releases - improve changelog merging & allow pre-public testing (pack) (#4043)
* Switch prepublishOnly to prepack to catch errors earlier

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

* Fix merge-release-notes.js parsing

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

* Discard changes to yarn.lock

* Update package.json

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-02-01 11:26:01 +00:00
Hubert Chathi 8007bc5fe8 ElementR: fix emoji verification stalling when both ends hit start at the same time (#4004)
* Rust crypto: handle the SAS verifier being replaced

* lint

* make changes from review

* apply changes from code review

* remove useless assertions

* wrap acceptance inside a try-catch, and factor out acceptance into a function

* fix bugs

* we don't actually need the .accept variable

* move setInner to inside SAS class, and rename to replaceInner

* use defer to avoid using a closure

* lint

* prettier

* use the right name

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* combine onChangeCallback with onChange

* apply changes from review

* add test for QR code verification, and try changing order in onChange

* lint

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-31 20:07:34 +00:00
Hubert Chathi d178fbf9cd Element R: emit events when devices have changed (#4019)
* emit events when Rust crypto wasm tells us devices have changed

* lint

* add missing stub function

* apply workaround for queueMicrotask
2024-01-31 14:31:28 +00:00
Valere f81036346f bump wasm bindings version (#4042) 2024-01-31 14:25:42 +00:00
RiotRobot 1a364c93c3 Resetting package fields for development 2024-01-31 14:38:47 +00:00
RiotRobot 1cd6fe7775 Merge branch 'master' into develop 2024-01-31 14:38:46 +00:00
RiotRobot 89d0133c61 v31.2.0 2024-01-31 14:37:57 +00:00
Hugh Nimmo-Smith a8b3369dd0 Sign in with QR (MSC3906) compatibility with Rust Crypto (#3761)
* Make MSC3906 implementation compatible with Rust Crypto

* Verify using CryptoApi but no cross-signing (yet)

* Use new crossSignDevice() function

* Mock crossSignDevice() function

* Fix type of parameter in mock

* review:  cleaning

* review: Remove unneeded defensive coding

* review: fix outdated documentation

* QR login: review, cleaning

* QR login | review: use getSafeUserId

---------

Co-authored-by: Valere <bill.carson@valrsoft.com>
2024-01-30 12:25:45 +00:00
Timo 99600e87f1 Add expire_ts compatibility to matrixRTC (#4032)
* add expire_ts compatibility to matrixRTC

Signed-off-by: Timo K <toger5@hotmail.de>

* add expire_ts

Signed-off-by: Timo K <toger5@hotmail.de>

* rename expire_ts -> expires_ts

Signed-off-by: Timo K <toger5@hotmail.de>

* allow events without `expires`

Signed-off-by: Timo K <toger5@hotmail.de>

* fix test for expires_ts

Signed-off-by: Timo K <toger5@hotmail.de>

* comment clarification

Signed-off-by: Timo K <toger5@hotmail.de>

* add comment where one needs to use the origin_server_ts

Signed-off-by: Timo K <toger5@hotmail.de>

* add additional expires_ts tests

Signed-off-by: Timo K <toger5@hotmail.de>

* fix fake timer

Signed-off-by: Timo K <toger5@hotmail.de>

* change priority order to favor expires

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2024-01-29 14:46:02 +00:00
Richard van der Hoff 7cf59d64e6 Element-R: support for migration of the room list from legacy crypto (#4036)
* Support for migration of the room list from legacy crypto

* fix migration for empty legacy store
2024-01-26 17:24:33 +00:00
Hubert Chathi 5967c670d8 Element R: Add test that requests are encoded properly (#4033)
* add test that requests are encoded properly

* fix variable name
2024-01-26 17:06:01 +00:00
Hubert Chathi 2fe35fed13 ElementR: report invalid keys rather than failing to restore from backup (#4006)
* rust-crypto: allow reporting failures when restoring keys

* add test and catch more invalid keys

* remove checks for room_id and session_id as they are guaranteed to be set

* remove obsolete comment
2024-01-26 16:46:35 +00:00
Florian Duros 2d1308c733 Make timeline a getter (#4022) 2024-01-26 13:40:45 +00:00
Richard van der Hoff 11348f9532 Element-R: check persistent room list for encryption config (#4035)
* crypto.spec: make `keyResponder` a local var

it is never used between functions, so making it external was confusing

* Persist encryption state to the rust room list.

* `MatrixClient.shouldEncryptEventForRoom`: fix for rust crypto

Previously, we were not bothering to ask the Rust Crypto stack if it thought we
should be encrypting for a given room. This adds a new method to `CryptoApi`,
wires it up for legacy and Rust crypto, and calls it.

* Tests for persistent room list
2024-01-26 12:41:18 +00:00
Richard van der Hoff 869576747c Refactor MatrixClient.encryptAndSendEvent (#4031)
* Replace `pendingEventEncryption` with a Set

We don't actually need the promise, so no need to save it.

This also fixes a resource leak, where we would leak a Promise and a HashMap
entry on each encrypted event.

* Convert `encryptEventIfNeeded` to async function

This means that it will always return a promise, so `encryptAndSendEvent` can't
tell if we are actually encrypting or not. Hence, also move the
`updatePendingEventStatus` into `encryptEventIfNeeded`.

* Simplify `encryptAndSendEvent`

Rewrite this as async.

* Factor out `MatrixClient.shouldEncryptEventForRoom`

* Inline a call to `isRoomEncrypted`

I want to deprecate this thing
2024-01-26 10:21:33 +00:00
Richard van der Hoff 35ea144bca Update matrix-rust-sdk-crypto-wasm to 4.1.0 (#4034)
I have some other changes in the pipeline which will depend on this.
2024-01-26 09:47:49 +00:00
Jan Jurzitza 5bf29ef543 fix IndexedDBStore API documentation (#3987)
* fix IndexedDBStore API documentation

changes the changelog entry to include since when this change is needed

fix #3986

Signed-off-by: Jan Jurzitza <gh@webfreak.org>

* retroactively add breaking change note to changelog entry

Signed-off-by: Jan Jurzitza <gh@webfreak.org>

---------

Signed-off-by: Jan Jurzitza <gh@webfreak.org>
2024-01-25 09:47:27 +00:00
Richard van der Hoff 99b3cf2279 Introduce Room.hasEncryptionStateEvent (#4030)
... and replace a lot of calls to `MatrixClient.isRoomEncrypted` with it.

This is a lesser check (since it can be tricked by servers withholding the
state event), but for most cases it is sufficient. At the end of the day, if
the server witholds the state, the room is pretty much bricked anyway. The one
thing we *mustn't* do is allow users to send *unencrypted* events to the room.
2024-01-25 08:38:02 +00:00
Hubert Chathi 5e2acb558b Implement getting verification cancellation info in Rust crypto (#3947)
* implement verification cancellation info in Rust crypto

* fix type info

* use string cancel code and add test

* simplify code

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-25 03:43:50 +00:00
Richard van der Hoff 19494e093b Fix crypto migration for megolm sessions with no sender key (#4024)
Fixes https://github.com/element-hq/element-web/issues/26894

Requires https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/89 (or
rather, an update to a version of matrix-rust-sdk-crypto-wasm) which includes
it).
2024-01-24 14:47:13 +00:00
Travis Ralston ab217bdc35 Support optional MSC3860 redirects (#4007)
* Support optional MSC3860 redirects

See `allow_redirect` across the media endpoints: https://spec.matrix.org/v1.9/client-server-api/#client-behaviour-7

* Update the tests

* Appease the linter

* Add test to appease SonarCloud

* Only add `allow_redirect` if the parameter is specified rather than defaulting to `false`

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-24 10:30:51 +00:00
Richard van der Hoff c4d32a3292 Bump matrix-sdk-crypto-wasm to 4.0.1 (#4025)
* Bump matrix-sdk-crypto-wasm to 4.0.1

* Fix some tests

* more test fixes

* yet more fixes

* update comments
2024-01-24 09:35:35 +00:00
renovate[bot] 8e01b654bc Update all non-major dependencies (#4027)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-24 09:23:28 +00:00
renovate[bot] dc406ee2e8 Update dependency @types/node to v18.19.8 (#4028)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-23 21:10:43 +00:00
renovate[bot] be8b769542 Update typescript-eslint monorepo to v6.19.0 (#4029)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-23 19:34:25 +00:00
RiotRobot 5973a15f68 v31.2.0-rc.0 2024-01-23 18:26:23 +00:00
Richard van der Hoff 3c28cfc96a Fix type error introduced by crypto-wasm 4.0.0 (#4026)
* Fix type error introduced by crypto-wasm 4.0.0

* fix imports
2024-01-23 12:52:53 +00:00
Valere c99378501b ElementR | backup: call expensive roomKeyCounts less often (#4015)
* ElementR | backup: call expensive `roomKeyCounts` less often

* review: Improve doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: Improve loop

* review: Add comment regarding slightly outdated remaining count

* Review: doc fix typo

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: refactor code order, count after doing the request

* review: Missing await on sleep for limit exceeded

* review: Comment | add a note for when performance drops

* Backup: add upload loop test for rust

* test: quick fix backup loop tests

* test: quick fix imports backup loop tests

* review: improve comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review improve comment

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Review: Clean and improve tests

* fix: wrong test name

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-22 19:06:22 +00:00
Valere b10a804a03 Element R: Bump matrix-rust-sdk-crypto-wasm to version 4.0.0 (#4021)
* bump wasm bindings version 4.0.0

* fix test compilation with initFromStore

* Fix test due to change in wasm handling of Vec<>

* review: Better doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: Better doc

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* review: revert userIdentity free removal

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-22 15:17:53 +00:00
RiotRobot 2337d5a7af Merge branch 'master' into develop 2024-01-19 13:44:53 +00:00
RiotRobot 5a49ed4ebb v31.1.0 2024-01-19 13:44:03 +00:00
Valere 22db9eb245 logs: improve logging (#4018) 2024-01-19 12:22:41 +00:00
Valere 4cddc7397d Decrypt and Import full backups in chunk with progress (#4005)
* Decrypt and Import full backups in chunk with progress

* backup chunk decryption jsdoc

* Review: fix capitalization

* review: better var name

* review: fix better iterate on object

* review: extract utility function

* review: Improve test, ensure mock calls

* review: Add more test for decryption or import failures

* Review: fix typo

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>

---------

Co-authored-by: Andy Balaam <andy.balaam@matrix.org>
2024-01-19 10:08:45 +00:00
Michael Telatynski 418b69914a Fix issues caused by the artifacts v4 upgrade
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:30:15 +00:00
Michael Telatynski 0082964345 Iterate sonarcloud reusable action
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:27:05 +00:00
Michael Telatynski 96b3c79566 Iterate sonarcloud reusable action
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:12:43 +00:00
Michael Telatynski 41a6f18125 Fix Sonarcloud artifact downloading
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-19 09:03:07 +00:00
Michael Telatynski a2b2e8dbdf Use Github Artifacts v4 (#4011) 2024-01-19 08:54:45 +00:00
ElementRobot abd920f0f4 [Backport staging] Broaden spec version support (#4016)
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
Fixes https://github.com/matrix-org/matrix-js-sdk/issues/3915.
2024-01-18 17:40:07 +00:00
Matthew Hodgson 5333d0e0ba drop 'malformed member event' log level, given all TR rooms are malformed like this (#3975) 2024-01-18 16:55:28 +00:00
Richard van der Hoff c885542628 Broaden spec version support (#4014)
This commit does two things:

 * It puts the "minimum supported matrix version" from v1.5 back down to
   v1.1. In other words, it is a partial revert of
   https://github.com/matrix-org/matrix-js-sdk/pull/3970. (Partial, because we
   don't need to update the tests.)

   We're doing this largely because
   https://github.com/matrix-org/matrix-js-sdk/pull/3970 was introduced without
   a suitable announcement and deprecation policy. We haven't yet decided if
   the js-sdk's spec support policy needs to change, or if we will re-introduce
   this change in future in a more graceful manner.

 * It increases the "maximum supported matrix version" from v1.5 up to
   v1.9. Previously, the two concepts were tied together, but as discussed at
   length in
   https://github.com/matrix-org/matrix-js-sdk/issues/3915#issuecomment-1865221366,
   this is incorrect.

   Unfortunately, we have no real way of testing whether it is true that the
   js-sdk actually works with a server which supports *only* v1.9, but as per
   the comment above, we can't do much about that.

Fixes https://github.com/matrix-org/matrix-js-sdk/issues/3915.
2024-01-18 16:34:01 +00:00
David Baker 81b58388ee Fix new threads not appearing. (#4009)
* Fix new threads not appearing.

We try to update the thread roots when creating a thread, but a thread
can take some time to be ready after being created so we were calling it
too soon. Add a listener for the Update event to update the thread roots
once it's ready.

Fixes https://github.com/element-hq/element-web/issues/26799

* Don't recreate the event when we update

and also add a comment to the test

* Hopefully make sonarcloud happy
2024-01-17 15:20:11 +00:00
Richard van der Hoff 0d486eaade Add CODEOWNERS entries for crypot stuff (#4012) 2024-01-17 10:49:03 +00:00
RiotRobot 76b9c3950b Resetting package fields for development 2024-01-16 17:37:37 +00:00
RiotRobot 6176cb6d7b Merge branch 'master' into develop 2024-01-16 17:37:35 +00:00
RiotRobot b9a107f9ff v31.0.0 2024-01-16 17:36:41 +00:00
Richard van der Hoff 06e8cea63d Emit events during migration from libolm (#3982)
* Fix `CryptoStore.countEndToEndSessions`

This was apparently never tested, and was implemented incorrectly.

* Add `CryptoStore.countEndToEndInboundGroupSessions`

* Emit events to indicate migration progress
2024-01-16 13:31:21 +00:00
Richard van der Hoff 815c36e075 Support for migration from from libolm (#3978)
* Use a `StoreHandle` to init OlmMachine

This will be faster if we need to prepare the store.

* Include "needsBackup" flag in inbound group session batches

* On startup, import data from libolm cryptostore

* ISessionExtended -> SessionExtended
2024-01-16 12:00:22 +00:00
Michael Telatynski d355073d10 Stop running react-sdk Cypress tests (#4008)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-16 08:19:14 +00:00
renovate[bot] 2ef3ebb466 Update dependency eslint-plugin-jest to v27.6.2 (#4003)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-11 11:16:21 +00:00
renovate[bot] 92f7481fdd Update crazy-max/ghaction-import-gpg digest to 01dd5d3 (#4002)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-11 11:10:35 +00:00
Andy Balaam 8df30ed068 Revert "Revert "Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989)" (#3991)" (#4001)
This reverts commit a597a9d660.
2024-01-10 16:30:59 +00:00
renovate[bot] 49624d5d73 Update all non-major dependencies (#3995)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 10:19:12 +00:00
renovate[bot] 8e5128ad3c Update dependency eslint-plugin-unicorn to v50 (#4000)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:22 +00:00
renovate[bot] 8ac2f2a78d Update dependency eslint-plugin-jsdoc to v48 (#3999)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:21 +00:00
renovate[bot] 630440c59c Update babel monorepo to v7.23.7 (#3993)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:17 +00:00
renovate[bot] 6932437360 Update dawidd6/action-download-artifact action to v3 (#3998)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:14 +00:00
renovate[bot] f381dfe991 Update dependency @types/node to v18.19.4 (#3994)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:11 +00:00
renovate[bot] 6a98b835a8 Update typescript-eslint monorepo to v6.18.0 (#3996)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2024-01-10 02:20:09 +00:00
RiotRobot ae01c0915c v31.0.0-rc.0 2024-01-09 17:46:58 +00:00
Andy Balaam a597a9d660 Revert "Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989)" (#3991)
This reverts commit 533070c603.
2024-01-08 16:35:30 +00:00
Richard van der Hoff 9661cdecf2 bump fake-indexeddb (#3990)
To pick up fix to https://github.com/dumbmatter/fakeIndexedDB/issues/94
2024-01-08 15:34:49 +00:00
Richard van der Hoff 533070c603 Bump matrix-sdk-crypto-wasm to 3.6.0 (#3989) 2024-01-08 14:13:51 +00:00
David Langley eae1c2d48b Update teams names in CODEOWNERS (#3988)
* update teams names in CODEOWNERS

* Revert unintentional commit to client.ts
2024-01-08 14:08:47 +00:00
Richard van der Hoff 070a89d89d Bump minimum spec version to v1.5 (#3970)
* Update minimum spec version

* Update README.md

* fix autodiscovery tests
2024-01-08 12:33:13 +00:00
Rashmit Pankhania ffc9fb34d0 #22606 Fix "Remove" button to users without "m.room.redaction" (#3981)
* #22606 Fix "Remove" button  to users without "m.room.redaction" permission

This change makes the remove button NOT available to users without permissions

* Fix lint

Signed-off-by: Rashmit Pankhania <rashmitpankhania@gmail.com>

---------

Signed-off-by: Rashmit Pankhania <rashmitpankhania@gmail.com>
2024-01-03 12:21:38 +00:00
Richard van der Hoff d030c83cee Groundwork for supporting migration from libolm to rust crypto. (#3977)
* `getOwnDeviceKeys`: use `olmMachine.identityKeys`

This is simpler, and doesn't rely on us having done a device query to work.

* Factor out `requestKeyBackupVersion` utility

* Factor out `makeMatrixHttpApi` function

* Convert `initRustCrypto` to take a params object

* Improve logging in startup

... to help figure out what is taking so long.
2024-01-03 11:09:17 +00:00
renovate[bot] c115e055c6 Update typescript-eslint monorepo to v6 (major) (#3984)
* Update typescript-eslint monorepo to v6

* Fix typo

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-02 17:49:30 +00:00
renovate[bot] 0f65088fd9 Update dependency prettier to v3 (#3983)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2024-01-02 17:56:06 +00:00
Valere a1ff63adcb ElementR: Ensure Encryption order per room (#3973)
* add test for order bug

* Ensure encryption order per room

* Remove unneeded fake timers

* review

* put back log duration

* fix wrong call

* code review

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/RoomEncryptor.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/RoomEncryptor.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* fix link syntax

* remove xxx comment

* fix comment order

* Improve comment

* add log duration

* fix comment

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2024-01-02 12:50:46 +00:00
Lars Karbo 31fc5f23be Use enums for event listeners in the readme (#3979) 2024-01-02 11:32:07 +00:00
Hubert Chathi febef3fc7c Element-R: fix bootstrapSecretStorage not resetting key backup when requested (#3976)
* pull resetKeyBackup outside of if statement

* fix broken conflict resolution

* prettier
2023-12-29 14:19:11 +00:00
Michael Telatynski f2625348d8 Move sonarcloud shard support into reusable workflow (#3962)
* Fix typo in jest CI

Caused versions to clobber each other's LCOV

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

* Move sonarcloud shard support into reusable workflow

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-12-20 08:56:48 +00:00
David Baker 6d1d04782a Send authenticated /versions request (#3968)
* Send authenticated /versions request

Implements [MSC4026](https://github.com/matrix-org/matrix-spec-proposals/pull/4026).

I believe this probably is as simple as this: it will mean that the versions
response can obviously change after logging in, but since the client is
constructed again with an access token, this should just work (?)

A remaining question is whether this needs to be optional. Opening the PR
to prompt the discussion. Apps might not expect it, but it's just the same
auth that we're sending to other endpoints on the same server.

* Fix tests

* Clear /versions cache on access token set
2023-12-19 17:27:08 +00:00
Richard van der Hoff 5e67a173c8 Add new methods to CryptoStore for Rust Crypto migration (#3969)
* Add `CryptoStore.containsData`

* add `CryptoStore.{get,set}MigrationState`

* Implement `CryptoStore.getEndToEnd{,InboundGroup}SessionsBatch`

* Implement `CryptoStore.deleteEndToEnd{,InboundGroup}SessionsBatch`

* fix typedoc errors
2023-12-19 15:25:54 +00:00
RiotRobot 9780643ce7 Resetting package fields for development 2023-12-19 15:48:49 +00:00
RiotRobot 608c6ece56 Merge branch 'master' into develop 2023-12-19 15:48:48 +00:00
RiotRobot 3a55efb476 v30.3.0 2023-12-19 15:47:50 +00:00
Valere 48d4f1b0cc ElementR: Fix missing key check values in 4S key storage (#3950)
* fix missing key check in key storage

* code review

* fix tests

* add recovery keys test for both backends

* fix api break on GeneratedSecretStorageKey

* fix test

* fix test

* Update src/crypto-api.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update spec/unit/rust-crypto/rust-crypto.spec.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/crypto-api.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-12-18 15:05:28 +00:00
Valere a80e90b42d Add some perfomance logs (#3965)
* Add some perfomance logs

* missing return
2023-12-18 14:35:45 +00:00
Valere 2c13e133b7 ElementR: Ensure there is only one call to shareRoomKeys in flight at once (#3948)
* shareRoomKeys lock

* cleaning

* add test for lock
2023-12-18 09:26:00 +00:00
Andy Balaam 68898aeff2 Bump matrix-sdk-crypto-wasm to 3.5.0 (#3961) 2023-12-15 14:47:37 +00:00
David Baker f604ab2f63 Remove m.thread filter from relations API call (#3959)
* Remove m.thread filter from relations API call

We used MSC3981 to pass the recurse param to the /relations
endpoint so that we could get relations to events in a thread, but
we kept the rel_type filter on (as m.thread) so no second-order relations
would ever have been returned (a nested thread isn't a thing).

This removes the filter and does some filtering on the client side to
remove any events that shouldn't live in the threaded timeline (ie.
non-thread relations to the thread root event).

This should help fix stuck unreads because it will avoid the event that
the receipt refers to going missing (but only on HSes that support MSC3981).

For https://github.com/vector-im/element-web/issues/26718

* Fix import cycle

* Remove params from expected calls in tests to match

* Unused import
2023-12-14 10:39:43 +00:00
renovate[bot] b7d45e83f8 Update all non-major dependencies (#3955)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 20:25:50 +00:00
renovate[bot] db0e3cfbb0 Update babel monorepo to v7.23.5 (#3953)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-13 08:55:36 +00:00
renovate[bot] 87b90cc983 Update dawidd6/action-download-artifact digest to f29d1b6 (#3952)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:29:52 +00:00
renovate[bot] cc9545e313 Update dependency @types/jest to v29.5.11 (#3954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:28:48 +00:00
renovate[bot] ed2792e6d8 Update dependency @types/node to v18.19.3 (#3956)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-12 17:27:07 +00:00
RiotRobot dd53ec722f v30.3.0-rc.0 2023-12-12 16:56:48 +00:00
Andy Balaam b03dc6ac43 Move roomList out of MatrixClient, into legacy Crypto (#3944)
* Comment explaining the purpose of RoomList

* Fix incorrect return type declaration on RoomList.getRoomEncryption

* Move RoomList out of MatrixClient, into legacy Crypto

* Initialise RoomList inside Crypto.init to allow us to await it
2023-12-11 10:30:27 +00:00
Valere 13c7e0ebda Element-R: Refactor per-session key backup download (#3929)
* initial commit

* new interation test

* more comments

* fix test, quick refactor on request version

* cleaning and logs

* fix type

* cleaning

* remove delegate stuff

* remove events and use timer mocks

* fix import

* ts ignore in tests

* Quick cleaning

* code review

* Use Errors instead of Results

* cleaning

* review

* remove forceCheck as not useful

* bad naming

* inline pauseLoop

* mark as paused in finally

* code review

* post merge fix

* rename KeyDownloadRateLimit

* use same config in loop and pass along
2023-12-08 14:21:07 +00:00
David Baker 2cd63ca4b9 Fix notifications appearing for old events (#3946)
A method that we use for fetching recursive related events on homeservers
without MSC3981 support injects events into the timeline in timestamp
order using a special method on event-timeline-set. Injecting events using
this method could cause on-screen notifications because it incorrectly set
the 'liveEvent' flag to true if the events were added tio the live timeline.
These events are never live though as the point is that we're fetching them.
2023-12-07 17:03:01 +00:00
Richard van der Hoff 479c4278a6 Element-R: disable sending room key requests (#3939) 2023-12-07 16:17:21 +00:00
David Baker 636fc3daaa Include event & room ID in log line (#3945)
...for when we ignore events that don't appear in the room timeline
2023-12-07 13:41:43 +00:00
Hubert Chathi 1d1309870a Don't back up keys that we got from backup (#3934)
* don't back up keys that we got from backup

* lint

* lint again

* remove key source struct and add function for importing from backup

* apply changes from review

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-12-07 11:32:27 +00:00
Jakub Onderka 13b8f01062 Fix upload with empty Content-Type (#3918)
Fixes #3917

Signed-off-by: Jakub Onderka <ahoj@jakubonderka.cz>
Co-authored-by: Florian Duros <florianduros@element.io>
2023-12-06 17:07:19 +00:00
David Baker cd672ec4cf Log if event ID is not foudn in the room (#3943) 2023-12-06 16:30:48 +00:00
David Baker 2363703b64 Prevent phantom notifications from events not in a room's timeline (#3942)
* Test whether an event not in a room's timeline causes notification count increase

Commited separately to demonstrate test failing before.

* Don't fix up notification counts if event isn't in the room

As explained by the comment, hopefully.

* Fix other test
2023-12-06 16:25:10 +00:00
Andy Balaam 1250bb8833 Call scheduleAllGroupSessionsForBackup during resetKeyBackup (#3935)
since its equivalent is done automatically in Rust crypto when we call
resetKeyBackup.
2023-12-06 15:09:31 +00:00
Richard van der Hoff 016ef12c4a Fix "mark_skipped" action again
Yet another go at this. The name is actually coming from an explicit
`github-status-action` action in the called workflows.
2023-12-06 15:00:41 +00:00
Richard van der Hoff 84d193a9a2 Fix "mark_skipped" action again
Turns out that the name we need is the key of the job in the workflow
definition; *not* the `name` property.
2023-12-06 14:53:11 +00:00
Richard van der Hoff 9d5f1bb4fc Fix "mark_skipped" action for end-to-end tests (#3940)
This seems to have been broken by
https://github.com/matrix-org/matrix-js-sdk/pull/3914, which changed the name
of the status check that is updated.
2023-12-06 11:41:50 +00:00
Richard van der Hoff 228131edf3 Bump matrix-sdk-crypto-wasm to 3.4.0 (#3938) 2023-12-05 16:57:38 +00:00
Michael Telatynski 23ad637aad Update cypress.yml 2023-12-05 15:01:08 +00:00
RiotRobot 103617c70e Resetting package fields for development 2023-12-05 13:35:58 +00:00
RiotRobot 8d84621b07 Merge branch 'master' into develop 2023-12-05 13:35:57 +00:00
RiotRobot 6d018826f4 v30.2.0 2023-12-05 13:35:06 +00:00
Richard van der Hoff 41878c7a43 Element-R: await /keys/query during Verification requests (#3932) 2023-12-05 11:18:12 +00:00
Michael Telatynski f31e83fd03 Run matrix-react-sdk playwright tests downstream (#3914)
* Run matrix-react-sdk playwright tests downstream

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

* Update .github/workflows/cypress.yml

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

---------

Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
Co-authored-by: R Midhun Suresh <hi@midhun.dev>
2023-12-04 10:55:57 +00:00
Richard van der Hoff b515cdbdbb Rust-crypto: fix bootstrapCrossSigning on second call (#3912)
* Rust-crypto: fix `bootstrapCrossSigning` on second call

Currently, `bootstrapCrossSigning` raises an exception if it is called a second
time before secret storage is set up. It is easily fixed by checking that 4S is
set up before trying to export to 4S.

Also a few logging fixes while we're in the area.

* Factor out an `AccountDataAccumulator`

* Another test for bootstrapCrossSigning
2023-12-01 14:39:04 +00:00
Richard van der Hoff f4b6f91ee2 Bump matrix-rust-sdk-crypto-wasm to v3.2.0 (#3933)
* Bump `matrix-rust-sdk-crypto-wasm` to v3.2.0

* Reinstate timeout on `getUserDevices` call

Turns out that this used to have a timeout of 1 second in the wasm
bindings, which it no longer does. Reinstate it here.
2023-12-01 12:05:13 +00:00
Michael Telatynski df4536492c Update Sibz/github-status-action to use node16 to silence warning (#3910)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-12-01 10:08:10 +00:00
Valere 2e98da4224 Signal key backup in cache (#3928)
* Signal key backup in cache

* code review

* quick doc

* code review
2023-11-30 08:15:37 +00:00
Valere 48d9d9b4c9 move get device key API from client to crypto (#3899)
MatrixClient API was exposing two methods that only worked for legacy crypto:
- getDeviceEd25519Key
- getDeviceCurve25519Key

=> These are used in the react-sdk for some functionality (rageshake, sentry, rendez-vous).

I have deprecated those calls from MatrixClient and created a new API in CryptoApi (where it belongs):

getOwnDeviceKeys(): Promise<OwnDeviceKeys>
2023-11-29 17:54:06 +00:00
Richard van der Hoff d90ae11e2b Expose new method CryptoApi.crossSignDevice (#3930) 2023-11-29 14:45:26 +00:00
Valere 3f246c6080 fix uncaught exceptions in Backup Loop for rust sdk (#3907)
* fix uncaught exceptions

* Update src/rust-crypto/backup.ts

Co-authored-by: Florian Duros <florianduros@element.io>

---------

Co-authored-by: Florian Duros <florianduros@element.io>
2023-11-29 09:07:56 +00:00
renovate[bot] 68911520d3 Update babel monorepo to v7.23.4 (#3921)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:37:12 +00:00
renovate[bot] 393a8d0cdb Update dependency @types/node to v18.18.13 (#3923)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:36:35 +00:00
renovate[bot] 51b63092b4 Update all non-major dependencies (#3920)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:36:30 +00:00
renovate[bot] b49c9639b9 Update dependency @types/jest to v29.5.10 (#3922)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:36:25 +00:00
renovate[bot] c588611fc0 Update matrix-org/netlify-pr-preview action to v3 (#3926)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:36:21 +00:00
renovate[bot] 5b34e4beaf Update typedoc (#3925)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-29 08:35:46 +00:00
Johannes Marbach 91f16e5e8e Merge pull request #3927 from matrix-org/midhun/fix-broken-ci 2023-11-29 08:37:33 +01:00
R Midhun Suresh 9cf257da0e Use new commit hash 2023-11-29 12:36:00 +05:30
R Midhun Suresh 188de3c4c8 Use new secret 2023-11-29 11:15:19 +05:30
renovate[bot] 67019a3486 Update matrix-org/matrix-react-sdk digest to e76a37e (#3919)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-28 18:21:56 +00:00
Richard van der Hoff a39b1203f2 Add guards against MatrixClient.stopClient calls (#3913)
If we call methods on `OlmMachine` after `MatrixClient.stopClient` is called,
we will end up with a "use of moved value" error. We can turn these into
something more useful with judicious use of `getOlmMachineOrThrow`.

Alternatively, we can sidestep the issue by bailing out sooner.
2023-11-28 16:30:18 +00:00
RiotRobot df1a6a583a v30.2.0-rc.0 2023-11-28 16:11:15 +00:00
Andy Balaam c49a527e5e Rewrite receipt-handling code (#3901)
* Rewrite receipt-handling code

* Add tests around dangling receipts

* Fix mark as read for some rooms

* Add missing word

---------

Co-authored-by: Florian Duros <florian.duros@ormaz.fr>
Co-authored-by: Florian Duros <florianduros@element.io>
2023-11-28 14:43:48 +00:00
Richard van der Hoff a7496627fc Explicitly free some Rust-side objects (#3911)
* Explicitly `free` stuff returned by `OlmMachine.getIdentity()`

* Explicitly `free` stuff returned by `OlmMachine.getDevice()`

* one more
2023-11-28 13:14:53 +00:00
Richard van der Hoff 8ef2ca681c Update to matrix-sdk-crypto-wasm 3.1.0 (#3909) 2023-11-27 15:15:58 +00:00
Johannes Marbach 0c7342cb20 Set up CI to lint workflows with action-validator (#3905)
* Set up CI to lint workflows with action-validator

* Rename release-action workflow
2023-11-24 14:41:19 +00:00
Will Hunt 429c05ba85 TimestampToEventResponse.origin_server_ts should be a number (#3906) 2023-11-23 16:46:01 +00:00
Andy Balaam af9993a710 Remove 'Ignoring receipt' log line that logs very often' (#3904)
* Remove 'Ignoring receipt' log line that logs very often'

* Fix test expecting the log line I removed
2023-11-22 12:14:42 +00:00
Valere ff501834e6 Only await key query after lazy members resolved (#3902)
* Only await key query after lazy members resolved

* code review

* Update src/rust-crypto/RoomEncryptor.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

---------

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-11-22 09:19:13 +00:00
Michael Telatynski ef9157b37a Fix Ingest upstream changes for downstreams with missing sections
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-21 12:31:40 +00:00
Michael Telatynski da0a55cea4 Merge remote-tracking branch 'origin/develop' into develop 2023-11-21 11:46:06 +00:00
Michael Telatynski d644f111ea Fix Ingest upstream changes for element-desktop
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-21 11:45:55 +00:00
RiotRobot b2018ef81b Resetting package fields for development 2023-11-21 11:00:05 +00:00
RiotRobot a4faab6155 Merge branch 'master' into develop 2023-11-21 11:00:04 +00:00
RiotRobot 4ab226e580 v30.1.0 2023-11-21 10:59:16 +00:00
Richard van der Hoff 1889f8dad5 Reduce console log spam (#3896)
* Reduce console log spam

A couple of different things:

 * Increase the `MaxListeners` setting on `MatrixClient` and `Thread`, so that
   we don't get "possible EventEmitter leak" warnings

 * Disable a couple of warnings/info lines that are just part of regular
   operation and are logged in large volumes.

* another noisy log line

* Reinstate warning about receipts for missing events

Apparently this is being worked on
2023-11-20 18:17:06 +00:00
Timo e98ce78027 Better fallback for the event localTimestamp calculation. (#3900)
* better fallback to localTimestamp calculation

Signed-off-by: Timo K <toger5@hotmail.de>

* make `isExpired` impl simpler to read

Signed-off-by: Timo K <toger5@hotmail.de>

* update tests

Signed-off-by: Timo K <toger5@hotmail.de>

* refactor to use localTimestamp in the mocks

Signed-off-by: Timo K <toger5@hotmail.de>

* Update src/models/event.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update src/models/event.ts

Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>

* Update and clarify comments.
So that the localTimestamp and localAge behavior is easier to understand.

Signed-off-by: Timo K <toger5@hotmail.de>

* Replace localTimestamp biding
with binding the whole roomState

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com>
2023-11-20 17:20:04 +00:00
Richard van der Hoff 83ba0fbb49 Improve logging of event encryption in RustCrypto (#3898)
* Improve logging of event encryption in `RustCrypto`

* fix tests
2023-11-19 21:16:41 +00:00
Richard van der Hoff 757c5e1d71 Stop logging decryptions as retries (#3897)
Somebody seems to have decided that `isRetry` needs setting whenever we try to decrypt an event. This is nonsense, and leads to confusing logs.
2023-11-17 14:30:27 +00:00
Johannes Marbach eca651c0c2 Explicitly forward ELEMENT_BOT_TOKEN (#3894)
`inherit` doesn't work across orgs, sadly.
2023-11-16 14:46:56 +00:00
Andy Balaam 2205445a50 Specify the correct environment for the docs builder workflow (#3893) 2023-11-16 13:40:02 +00:00
renovate[bot] f168144c84 Update matrix-org/sonarcloud-workflow-action action to v2.7 (#3892)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-16 10:58:00 +00:00
Michael Telatynski eb288d125f Remove unused dependencies and add transitive dep (#3874)
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-16 09:47:30 +00:00
Michael Telatynski 4a72364fe3 Merge remote-tracking branch 'origin/develop' into develop 2023-11-16 08:33:20 +00:00
Michael Telatynski c2fa579fb2 Fix merge-release-notes.js script
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-16 08:33:08 +00:00
renovate[bot] f71735d0c2 Update babel monorepo to v7.23.3 (#3881)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-15 22:26:23 +00:00
renovate[bot] e5ccfa86fe Update actions/github-script action to v7 (#3886)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-15 22:25:24 +00:00
renovate[bot] 97c531aa42 Update all non-major dependencies (#3884)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-15 22:24:58 +00:00
renovate[bot] 44487078fb Update dependency @types/jest to v29.5.8 (#3883)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-15 22:24:49 +00:00
renovate[bot] e3c70a3ee4 Update actions/setup-node action to v4 (#3887)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 22:19:02 +00:00
renovate[bot] feb60a54b2 Update definitelyTyped (#3882)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 22:10:00 +00:00
David Baker c6e6248cd6 Don't rotate keys if not managing media keys (#3877)
This could have caused weirdness in non per-user key calling mode.
2023-11-15 14:44:58 +00:00
Timo 10cd84a653 Add CallNotifyEvent to support matrixRTC ringing (#3878)
* Add CallNotifyEvent to support matrix rtc ringing

Signed-off-by: Timo K <toger5@hotmail.de>

* test SessionId

Signed-off-by: Timo K <toger5@hotmail.de>

* docs + sessionId->callId

Signed-off-by: Timo K <toger5@hotmail.de>

---------

Signed-off-by: Timo K <toger5@hotmail.de>
2023-11-15 11:20:05 +00:00
Andy Balaam c36166d156 Merge pull request #3869 from matrix-org/andybalaam/unrevert-deletion-move-prs
Unrevert "Move redacted messages out of any thread, into main thread"
2023-11-15 08:23:50 +00:00
renovate[bot] 3a2cf14a68 Update matrix-org/matrix-react-sdk digest to f6ef476 (#3879)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-15 00:58:24 +00:00
Andy Balaam dd94f67a4f Merge branch 'develop' into andybalaam/unrevert-deletion-move-prs 2023-11-14 16:13:56 +00:00
Michael Telatynski 138281c620 Fix RELEASE_ID
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-14 17:12:49 +01:00
RiotRobot f75abecc92 v30.1.0-rc.1 2023-11-14 15:37:50 +00:00
Michael Telatynski 378a91fe10 Fix yarn version call in release-action.yml
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2023-11-14 16:31:58 +01:00
Michael Telatynski 300635e3ee Update package.json 2023-11-14 15:31:26 +00:00
Michael Telatynski 37ba169abf Update release-drafter.yml 2023-11-14 14:52:56 +00:00
RiotRobot e6e7798389 v30.1.0-rc.0 2023-11-14 14:29:48 +00:00
RiotRobot 41c8c40d47 v30.1.0-rc.1 2023-11-14 14:17:37 +00:00
RiotRobot 9faff0dbff v30.1.0-rc.0 2023-11-14 14:04:14 +00:00
Andy Balaam 0261868eb6 Revert "Revert "Move the redaction event to main at the same time we move redacted""
This reverts commit 11755f5a0a1486fa6ad3cb9e4b8959ddc7e1d276.
2023-11-09 14:30:41 +00:00
Andy Balaam 6ba4b35526 Revert "Revert "Don't remove thread info from a thread root when it is redacted""
This reverts commit 4dbff2a837cbc5ba37424c65ccdc833a1843deb2.
2023-11-09 14:30:41 +00:00
Andy Balaam f5ad4d0a73 Revert "Revert "Move all related messages into main timeline on redaction""
This reverts commit 257b40bceb304001c03aaec7b140a1fd05c96d9e.
2023-11-09 14:30:41 +00:00
Andy Balaam 582ea68c31 Revert "Revert "Factor out the code for moving an event to the main timeline""
This reverts commit 272be48a54a45df89603a27fbbe6e26da88b95ba.
2023-11-09 14:30:41 +00:00
Andy Balaam 304c2b12bf Revert "Revert "Factor out utils in redaction tests""
This reverts commit 2525c82049dc1a958446b66cc85656c1b57a5271.
2023-11-09 14:30:41 +00:00
Andy Balaam a3762c8e22 Revert "Revert "Move redaction event tests into their own describe block""
This reverts commit 2e24481df335411ee489ac7046c5514821afa4fa.
2023-11-09 14:30:41 +00:00
Andy Balaam 8b2a334ac4 Revert "Revert "Move redacted messages out of any thread, into main timeline.""
This reverts commit 46114a025c5ec5b2658803c8c86d5e855b55a4fb.
2023-11-09 14:30:41 +00:00
174 changed files with 83541 additions and 3865 deletions
+11 -4
View File
@@ -1,8 +1,15 @@
* @matrix-org/element-web
/.github/workflows/** @matrix-org/element-web-app-team
/package.json @matrix-org/element-web-app-team
/yarn.lock @matrix-org/element-web-app-team
* @matrix-org/element-web-reviewers
/.github/workflows/** @matrix-org/element-web-team
/package.json @matrix-org/element-web-team
/yarn.lock @matrix-org/element-web-team
/src/webrtc @matrix-org/element-call-reviewers
/src/matrixrtc @matrix-org/element-call-reviewers
/spec/*/webrtc @matrix-org/element-call-reviewers
/spec/*/matrixrtc @matrix-org/element-call-reviewers
/src/crypto @matrix-org/element-crypto-web-reviewers
/src/rust-crypto @matrix-org/element-crypto-web-reviewers
/spec/integ/crypto @matrix-org/element-crypto-web-reviewers
/spec/unit/crypto.spec.ts @matrix-org/element-crypto-web-reviewers
/spec/unit/crypto @matrix-org/element-crypto-web-reviewers
/spec/unit/rust-crypto @matrix-org/element-crypto-web-reviewers
-6
View File
@@ -5,9 +5,3 @@
- [ ] Tests written for new code (and old code if feasible)
- [ ] Linter and other CI checks pass
- [ ] Sign-off given on the changes (see [CONTRIBUTING.md](https://github.com/matrix-org/matrix-js-sdk/blob/develop/CONTRIBUTING.md))
<!--
If you would like to specify text for the changelog entry other than your PR title, add the following:
Notes: Add super cool feature
-->
+6
View File
@@ -22,8 +22,14 @@ version-resolver:
exclude-labels:
- "T-Task"
- "X-Reverted"
- "backport staging"
exclude-contributors:
- "RiotRobot"
template: |
$CHANGES
#no-changes-template: ""
prerelease: true
prerelease-identifier: rc
include-pre-releases: false
stable-ref: master
staging-ref: staging
+5 -6
View File
@@ -11,18 +11,16 @@ jobs:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
steps:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
uses: actions/download-artifact@v4
with:
workflow: static_analysis.yml
run_id: ${{ github.event.workflow_run.id }}
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: docs
path: docs
- name: 📤 Deploy to Netlify
uses: matrix-org/netlify-pr-preview@v2
uses: matrix-org/netlify-pr-preview@v3
with:
path: docs
owner: ${{ github.event.workflow_run.head_repository.owner.login }}
@@ -32,3 +30,4 @@ jobs:
site_id: ${{ secrets.NETLIFY_SITE_ID }}
desc: Documentation preview
deployment_env: PR Documentation Preview
environment: PR Documentation Preview
+2 -6
View File
@@ -5,13 +5,9 @@ on:
pull_request: {}
# For now at least, we don't run this or the cypress-tests against pushes
# For now at least, we don't run this or the downstream-end-to-end-tests against pushes
# to develop or master.
#
# Note that if we later choose to do so, we'll need to find a way to stop
# the results in Cypress Cloud from clobbering those from the 'develop'
# branch of matrix-react-sdk.
#
#push:
# branches: [develop, master]
concurrency:
@@ -20,7 +16,7 @@ concurrency:
jobs:
build-element-web:
name: Build element-web
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.84.0
uses: matrix-org/matrix-react-sdk/.github/workflows/element-web.yaml@v3.92.0
with:
matrix-js-sdk-sha: ${{ github.sha }}
react-sdk-repository: matrix-org/matrix-react-sdk
@@ -1,7 +1,7 @@
# Triggers after the "Downstream artifacts" build has finished, to run the
# cypress tests (with access to repo secrets)
# matrix-react-sdk playwright tests (with access to repo secrets)
name: matrix-react-sdk Cypress End to End Tests
name: matrix-react-sdk End to End Tests
on:
workflow_run:
workflows: ["Build downstream artifacts"]
@@ -13,35 +13,31 @@ concurrency:
cancel-in-progress: ${{ github.event.workflow_run.event == 'pull_request' }}
jobs:
cypress:
name: Cypress
# We only want to run the cypress tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume 4 concurrent runners.
playwright:
name: Playwright
# We only want to run the playwright tests on merge queue to prevent regressions
# from creeping in. They take a long time to run and consume multiple concurrent runners.
if: github.event.workflow_run.event == 'merge_group'
uses: matrix-org/matrix-react-sdk/.github/workflows/cypress.yaml@03b01b4a50d0f3fbbfa6c1a9314ef2d346d089d4
uses: matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml@develop
permissions:
actions: read
issues: read
statuses: write
pull-requests: read
secrets:
# secrets are not automatically shared with called workflows, so share the cypress dashboard key, and the Kiwi login details
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
TCMS_USERNAME: ${{ secrets.TCMS_USERNAME }}
TCMS_PASSWORD: ${{ secrets.TCMS_PASSWORD }}
deployments: write
with:
react-sdk-repository: matrix-org/matrix-react-sdk
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
# We want to make the cypress tests a required check for the merge queue.
# We want to make the Playwright tests a required check for the merge queue.
#
# Unfortunately, github doesn't distinguish between "checks needed for branch
# Unfortunately, GitHub doesn't distinguish between "checks needed for branch
# protection" (ie, the things that must pass before the PR will even be added
# to the merge queue) and "checks needed in the merge queue". We just have to add
# the check to the branch protection list.
#
# Ergo, if we know we're not going to run the cypress tests, we need to add a
# Ergo, if we know we're not going to run the Playwright tests, we need to add a
# passing status check manually.
mark_skipped:
if: github.event.workflow_run.event != 'merge_group'
@@ -49,10 +45,14 @@ jobs:
statuses: write
runs-on: ubuntu-latest
steps:
- uses: Sibz/github-status-action@650dd1a882a76dbbbc4576fb5974b8d22f29847f # v1.1.6
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: "${{ secrets.GITHUB_TOKEN }}"
state: success
description: Cypress skipped
context: "${{ github.workflow }} / cypress"
description: Playwright skipped
# Keep in step with the `context` that is updated by `Sibz/github-status-action`
# in matrix-org/matrix-react-sdk/.github/workflows/end-to-end-tests.yaml.
context: "${{ github.workflow }} / end-to-end-tests"
sha: "${{ github.event.workflow_run.head_sha }}"
+1 -1
View File
@@ -20,7 +20,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Notify matrix-react-sdk repo that a new SDK build is on develop so it can CI against it
uses: peter-evans/repository-dispatch@bf47d102fdb849e755b0b0023ea3e81a44b6f570 # v2
uses: peter-evans/repository-dispatch@ff45666b9427631e3450c54a1bcbee4d9ff4d7c0 # v3
with:
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
repository: ${{ matrix.repo }}
+15 -7
View File
@@ -14,11 +14,18 @@ jobs:
name: Preview Changelog
runs-on: ubuntu-latest
steps:
- uses: matrix-org/allchange@main
- uses: mheap/github-action-required-labels@80a96a4863886addcbc9f681b5b295ba7f5424e1 # v5
if: github.event_name != 'merge_group'
with:
ghToken: ${{ secrets.GITHUB_TOKEN }}
requireLabel: true
labels: |
X-Breaking-Change
T-Deprecation
T-Enhancement
T-Defect
T-Task
Dependencies
mode: minimum
count: 1
prevent-blocked:
name: Prevent Blocked
@@ -27,7 +34,7 @@ jobs:
pull-requests: read
steps:
- name: Add notice
uses: actions/github-script@v6
uses: actions/github-script@v7
if: contains(github.event.pull_request.labels.*.name, 'X-Blocked')
with:
script: |
@@ -39,6 +46,7 @@ jobs:
if: github.event.action == 'opened'
steps:
- name: Check membership
if: github.event.pull_request.user.login != 'renovate[bot]'
uses: tspascoal/get-user-teams-membership@ba78054988f58bea69b7c6136d563236f8ed2fc0 # v3
id: teams
with:
@@ -48,8 +56,8 @@ jobs:
GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Add label
if: ${{ steps.teams.outputs.isTeamMember == 'false' }}
uses: actions/github-script@v6
if: steps.teams.outputs.isTeamMember == 'false'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.addLabels({
@@ -68,7 +76,7 @@ jobs:
github.event.pull_request.head.repo.full_name != github.repository
steps:
- name: Close pull request
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
@@ -0,0 +1,88 @@
name: Release Drafter
on:
workflow_call:
inputs:
include-changes:
description: Project to include changelog entries from in this release.
type: string
required: false
concurrency: release-drafter-action
jobs:
draft:
runs-on: ubuntu-latest
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v4
with:
ref: staging
fetch-depth: 0
- uses: actions/setup-node@v4
with:
node-version-file: package.json
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
- uses: t3chguy/release-drafter@105e541c2c3d857f032bd522c0764694758fabad
id: draft-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
disable-autolabeler: true
- name: Get actions scripts
uses: actions/checkout@v4
with:
repository: matrix-org/matrix-js-sdk
persist-credentials: false
path: .action-repo
sparse-checkout: |
.github/actions
scripts/release
- name: Ingest upstream changes
if: inputs.include-changes
uses: actions/github-script@v7
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
DEPENDENCY: ${{ inputs.include-changes }}
VERSION: ${{ steps.draft-release.outputs.tag_name }}
with:
retries: 3
script: |
const { RELEASE_ID: releaseId, DEPENDENCY, VERSION } = process.env;
const { owner, repo } = context.repo;
const script = require("./.action-repo/scripts/release/merge-release-notes.js");
let deps = [];
if (DEPENDENCY.includes("/")) {
deps.push(DEPENDENCY.replace("$VERSION", VERSION))
} else {
const fromVersion = JSON.parse((await github.request(`https://raw.githubusercontent.com/${owner}/${repo}/master/package.json`)).data).dependencies[DEPENDENCY];
const toVersion = require("./package.json").dependencies[DEPENDENCY];
if (toVersion.endsWith("#develop")) {
core.warning(`${DEPENDENCY} will be kept at ${fromVersion}`, { title: "Develop dependency found" });
} else {
deps.push([DEPENDENCY, fromVersion, toVersion]);
}
}
if (deps.length) {
const notes = await script({
github,
releaseId,
dependencies: deps,
});
await github.rest.repos.updateRelease({
owner,
repo,
release_id: releaseId,
body: notes,
tag_name: VERSION,
});
}
+2 -14
View File
@@ -2,20 +2,8 @@ name: Release Drafter
on:
push:
branches: [staging]
workflow_dispatch:
inputs:
previous-version:
description: What release to use as a base for release note purposes
required: false
type: string
workflow_dispatch: {}
concurrency: ${{ github.workflow }}
jobs:
draft:
runs-on: ubuntu-latest
steps:
- uses: release-drafter/release-drafter@e64b19c4c46173209ed9f2e5a2f4ca7de89a0e86 # v5
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
disable-autolabeler: true
previous-version: ${{ inputs.previous-version }}
uses: matrix-org/matrix-js-sdk/.github/workflows/release-drafter-workflow.yml@develop
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
sparse-checkout: |
scripts/release
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
cache: "yarn"
@@ -1,4 +1,4 @@
name: Release Action
name: Release Make
on:
workflow_call:
secrets:
@@ -20,10 +20,8 @@ on:
description: Publish to npm
type: boolean
default: false
dependencies:
description: |
List of dependencies to update in `npm-dep=version` format.
`version` can be `"current"` to leave it at the current version.
downstreams:
description: List of github projects (owner/repo) which should have their dependency bumped to the newly released version (in JSON string array string syntax)
type: string
required: false
include-changes:
@@ -53,14 +51,14 @@ jobs:
- name: Load GPG key
id: gpg
if: inputs.gpg-fingerprint
uses: crazy-max/ghaction-import-gpg@82a020f1f7f605c65dd2449b392a52c3fcfef7ef # v6
uses: crazy-max/ghaction-import-gpg@01dd5d3ca463c7f10f7f4f7b4f177225ac661ee4 # v6
with:
gpg_private_key: ${{ secrets.GPG_PRIVATE_KEY }}
passphrase: ${{ secrets.GPG_PASSPHRASE }}
fingerprint: ${{ inputs.gpg-fingerprint }}
- name: Get draft release
id: release
id: draft-release
uses: cardinalby/git-get-release-action@cedef2faf69cb7c55b285bad07688d04430b7ada # v1
env:
GITHUB_TOKEN: ${{ github.token }}
@@ -88,25 +86,19 @@ jobs:
id: prepare
run: |
echo "VERSION=$VERSION" >> $GITHUB_ENV
{
echo "RELEASE_NOTES<<EOF"
echo "$BODY"
echo "EOF"
} >> $GITHUB_ENV
HAS_DIST=0
jq -e .scripts.dist package.json >/dev/null 2>&1 && HAS_DIST=1
echo "has-dist-script=$HAS_DIST" >> $GITHUB_OUTPUT
env:
BODY: ${{ steps.release.outputs.body }}
VERSION: ${{ steps.release.outputs.tag_name }}
VERSION: ${{ steps.draft-release.outputs.tag_name }}
- name: Finalise version
if: inputs.final
run: echo "VERSION=$(echo $VERSION | cut -d- -f1)" >> $GITHUB_ENV
- name: Check version number not in use
uses: actions/github-script@v6
uses: actions/github-script@v7
with:
script: |
const { VERSION } = process.env;
@@ -125,83 +117,29 @@ jobs:
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install dependencies
run: "yarn install --frozen-lockfile"
- name: Update dependencies
id: update-dependencies
if: inputs.dependencies
run: |
UPDATED=()
while IFS= read -r DEPENDENCY; do
[ -z "$DEPENDENCY" ] && continue
IFS="=" read -r PACKAGE UPDATE_VERSION <<< "$DEPENDENCY"
CURRENT_VERSION=$(cat package.json | jq -r .dependencies[\"$PACKAGE\"])
echo "Current $PACKAGE version is $CURRENT_VERSION"
if [ "$CURRENT_VERSION" == "null" ]
then
echo "Unable to find $PACKAGE in package.json"
exit 1
fi
if [ "$UPDATE_VERSION" == "current" ] || [ "$UPDATE_VERSION" == "$CURRENT_VERSION" ]
then
echo "Not updating dependency $PACKAGE"
continue
fi
echo "Upgrading $PACKAGE to $UPDATE_VERSION..."
yarn upgrade "$PACKAGE@$UPDATE_VERSION" --exact
git add -u
git commit -m "Upgrade $PACKAGE to $UPDATE_VERSION"
UPDATED+=("$PACKAGE")
done <<< "$DEPENDENCIES"
JSON=$(jq --compact-output --null-input '$ARGS.positional' --args -- "${UPDATED[@]}")
echo "updated=$JSON" >> $GITHUB_OUTPUT
env:
DEPENDENCIES: ${{ inputs.dependencies }}
- name: Prevent develop dependencies
if: inputs.dependencies
- name: Handle develop dependencies
run: |
ret=0
cat package.json | jq '.dependencies[]' | grep -q '#develop' || ret=$?
if [ "$ret" -eq 0 ]; then
echo "package.json contains develop dependencies. Refusing to release."
exit
fi
cat package.json | jq -r '.dependencies | to_entries | .[] | "\(.key) \(.value)"' | grep '#develop$' | while read -r dep ; do
IFS=" "
PACKAGE=${dep[0]}
VERSION=${dep[1]}
echo "::warning title=Develop dependency found::$DEPENDENCY will be kept at $VERSION"
yarn upgrade "$PACKAGE@$VERSION" --exact
git add -u
git commit -m "Keep $PACKAGE at $VERSION"
done
- name: Bump package.json version
run: yarn version --no-git-tag-version --new-version "$VERSION"
- name: Ingest upstream changes
if: |
inputs.dependencies &&
inputs.include-changes &&
contains(fromJSON(steps.update-dependencies.outputs.updated), inputs.include-changes)
uses: actions/github-script@v6
env:
RELEASE_ID: ${{ steps.upstream-release.outputs.body }}
DEPENDENCY: ${{ inputs.include-changes }}
with:
retries: 3
script: |
const { RELEASE_ID: releaseId, DEPENDENCY, VERSION } = process.env;
const { owner, repo } = context.repo;
const script = require("./.action-repo/scripts/release/merge-release-notes.js");
const notes = await script({
github,
releaseId,
dependencies: [DEPENDENCY.replace("$VERSION", VERSION)],
});
core.exportVariable("RELEASE_NOTES", notes);
run: yarn version --no-git-tag-version --new-version "${VERSION#v}"
- name: Add to CHANGELOG.md
if: inputs.final
@@ -220,6 +158,8 @@ jobs:
cat CHANGELOG.md.old >> CHANGELOG.md
rm CHANGELOG.md.old
git add CHANGELOG.md
env:
RELEASE_NOTES: ${{ steps.draft-release.outputs.body }}
- name: Run pre-release script to update package.json fields
run: |
@@ -238,7 +178,7 @@ jobs:
uses: ./.action-repo/.github/actions/upload-release-assets
with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.release.outputs.upload_url }}
upload-url: ${{ steps.draft-release.outputs.upload_url }}
asset-path: ${{ inputs.asset-path }}
- name: Create signed tag
@@ -253,7 +193,7 @@ jobs:
uses: ./.action-repo/.github/actions/sign-release-tarball
with:
gpg-fingerprint: ${{ inputs.gpg-fingerprint }}
upload-url: ${{ steps.release.outputs.upload_url }}
upload-url: ${{ steps.draft-release.outputs.upload_url }}
# We defer pushing changes until after the release assets are built,
# signed & uploaded to improve the atomicity of this action.
@@ -272,9 +212,9 @@ jobs:
- name: Validate release has expected assets
if: inputs.expected-asset-count
uses: actions/github-script@v6
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
EXPECTED_ASSET_COUNT: ${{ inputs.expected-asset-count }}
with:
retries: 3
@@ -300,9 +240,9 @@ jobs:
git push origin master
- name: Publish release
uses: actions/github-script@v6
uses: actions/github-script@v7
env:
RELEASE_ID: ${{ steps.release.outputs.id }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
FINAL: ${{ inputs.final }}
with:
retries: 3
@@ -336,15 +276,16 @@ jobs:
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
update-labels:
name: Advance release blocker labels
post-release:
name: Post release steps
needs: release
runs-on: ubuntu-latest
steps:
- id: repository
run: echo "REPO=${GITHUB_REPOSITORY#*/}" >> $GITHUB_OUTPUT
- uses: garganshu/github-label-updater@3770d15ebfed2fe2cb06a241047bc340f774a7d1 # v1.0.0
- name: Advance release blocker labels
uses: garganshu/github-label-updater@3770d15ebfed2fe2cb06a241047bc340f774a7d1 # v1.0.0
with:
owner: ${{ github.repository_owner }}
repo: ${{ steps.repository.outputs.REPO }}
@@ -352,3 +293,39 @@ jobs:
filter-labels: X-Upcoming-Release-Blocker
remove-labels: X-Upcoming-Release-Blocker
add-labels: X-Release-Blocker
- name: Wait for master->develop gitflow merge
if: inputs.final
uses: t3chguy/wait-on-check-action@18541021811b56544d90e0f073401c2b99e249d6 # fork
with:
ref: master
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
check-name: merge
allowed-conclusions: success
bump-downstreams:
name: Update npm dependency in downstream projects
needs: npm
runs-on: ubuntu-latest
if: inputs.downstreams
strategy:
matrix:
repo: ${{ fromJSON(inputs.downstreams) }}
steps:
- uses: actions/checkout@v4
with:
repository: ${{ matrix.repo }}
ref: staging
token: ${{ secrets.ELEMENT_BOT_TOKEN }}
- name: Bump dependency
env:
DEPENDENCY: ${{ needs.npm.outputs.id }}
run: |
git config --global user.email "releases@riot.im"
git config --global user.name "RiotRobot"
yarn upgrade "$DEPENDENCY" --exact
git add package.json yarn.lock
git commit -am"Upgrade dependency to $DEPENDENCY"
git push origin staging
+6
View File
@@ -4,10 +4,16 @@ on:
secrets:
NPM_TOKEN:
required: true
outputs:
id:
description: "The npm package@version string we published"
value: ${{ jobs.npm.outputs.id }}
jobs:
npm:
name: Publish to npm
runs-on: ubuntu-latest
outputs:
id: ${{ steps.npm-publish.outputs.id }}
steps:
- name: 🧮 Checkout code
uses: actions/checkout@v4
+3 -1
View File
@@ -23,11 +23,12 @@ on:
concurrency: ${{ github.workflow }}
jobs:
release:
uses: matrix-org/matrix-js-sdk/.github/workflows/release-action.yml@develop
uses: matrix-org/matrix-js-sdk/.github/workflows/release-make.yml@develop
secrets: inherit
with:
final: ${{ inputs.mode == 'final' }}
npm: ${{ inputs.npm }}
downstreams: '["matrix-org/matrix-react-sdk", "element-hq/element-web"]'
docs:
name: Publish Documentation
@@ -62,6 +63,7 @@ jobs:
yarn tpv purge --yes --out _docs --stale --major 10
yarn gendoc
symlinks -rc _docs
find _docs -xtype l -delete
- name: 🔨 Set up git
run: |
+50 -9
View File
@@ -5,7 +5,13 @@ on:
secrets:
SONAR_TOKEN:
required: true
ELEMENT_BOT_TOKEN:
required: true
inputs:
sharded:
type: boolean
required: false
description: "Whether to combine multiple LCOV and jest-sonar-report files in coverage artifact"
extra_args:
type: string
required: false
@@ -13,11 +19,13 @@ on:
jobs:
sonarqube:
runs-on: ubuntu-latest
if: github.event.workflow_run.conclusion == 'success'
if: |
github.event.workflow_run.conclusion == 'success' &&
github.event.workflow_run.event != 'merge_group'
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 Cypress is done.
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
# This provides an easy link to this workflow_run from the PR before Sonarcloud is done.
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: pending
@@ -25,24 +33,57 @@ jobs:
sha: ${{ github.event.workflow_run.head_sha }}
target_url: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}
- name: "🧮 Checkout code"
uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4
with:
repository: ${{ github.event.workflow_run.head_repository.full_name }}
ref: ${{ github.event.workflow_run.head_branch }} # checkout commit that triggered this workflow
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: 📥 Download artifact
uses: actions/download-artifact@v4
if: ${{ !inputs.sharded }}
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
name: coverage
path: coverage
- name: 📥 Download sharded artifacts
uses: actions/download-artifact@v4
if: inputs.sharded
with:
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: coverage-*
path: coverage
merge-multiple: true
- id: extra_args
run: |
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
- name: "🩻 SonarCloud Scan"
id: sonarcloud
uses: matrix-org/sonarcloud-workflow-action@v2.6
uses: matrix-org/sonarcloud-workflow-action@v2.7
# workflow_run fails report against the develop commit always, we don't want that for PRs
continue-on-error: ${{ github.event.workflow_run.head_branch != 'develop' }}
with:
skip_checkout: true
repository: ${{ github.event.workflow_run.head_repository.full_name }}
is_pr: ${{ github.event.workflow_run.event == 'pull_request' }}
version_cmd: "cat package.json | jq -r .version"
branch: ${{ github.event.workflow_run.head_branch }}
revision: ${{ github.event.workflow_run.head_sha }}
token: ${{ secrets.SONAR_TOKEN }}
coverage_run_id: ${{ github.event.workflow_run.id }}
coverage_workflow_name: tests.yml
coverage_extract_path: coverage
extra_args: ${{ inputs.extra_args }}
extra_args: |
${{ inputs.extra_args }}
-Dsonar.javascript.lcov.reportPaths=${{ steps.extra_args.outputs.reportPaths }}
-Dsonar.testExecutionReportPaths=${{ steps.extra_args.outputs.testExecutionReportPaths }}
- uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
- uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
if: always()
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
+2 -28
View File
@@ -8,38 +8,12 @@ concurrency:
group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }}
cancel-in-progress: true
jobs:
# This is a workaround for https://github.com/SonarSource/SonarJS/issues/578
prepare:
name: Prepare
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
runs-on: ubuntu-latest
outputs:
reportPaths: ${{ steps.extra_args.outputs.reportPaths }}
testExecutionReportPaths: ${{ steps.extra_args.outputs.testExecutionReportPaths }}
steps:
# There's a 'download artifact' action, but it hasn't been updated for the workflow_run action
# (https://github.com/actions/download-artifact/issues/60) so instead we get this mess:
- name: 📥 Download artifact
uses: dawidd6/action-download-artifact@246dbf436b23d7c49e21a7ab8204ca9ecd1fe615 # v2
with:
workflow: tests.yaml
run_id: ${{ github.event.workflow_run.id }}
name: coverage
path: coverage
- id: extra_args
run: |
coverage=$(find coverage -type f -name '*lcov.info' | tr '\n' ',' | sed 's/,$//g')
echo "reportPaths=$coverage" >> $GITHUB_OUTPUT
reports=$(find coverage -type f -name 'jest-sonar-report*.xml' | tr '\n' ',' | sed 's/,$//g')
echo "testExecutionReportPaths=$reports" >> $GITHUB_OUTPUT
sonarqube:
name: 🩻 SonarQube
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event != 'merge_group'
needs: prepare
uses: matrix-org/matrix-js-sdk/.github/workflows/sonarcloud.yml@develop
secrets:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
with:
extra_args: -Dsonar.javascript.lcov.reportPaths=${{ needs.prepare.outputs.reportPaths }} -Dsonar.testExecutionReportPaths=${{ needs.prepare.outputs.testExecutionReportPaths }}
sharded: true
+17 -1
View File
@@ -51,6 +51,22 @@ jobs:
- name: Run Linter
run: "yarn run lint:js"
workflow_lint:
name: "Workflow Lint"
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
cache: "yarn"
- name: Install Deps
run: "yarn install --frozen-lockfile"
- name: Run Linter
run: "yarn lint:workflows"
docs:
name: "JSDoc Checker"
runs-on: ubuntu-latest
@@ -75,7 +91,7 @@ jobs:
find _docs -mindepth 1 -maxdepth 1 -type d -execdir mv {} stable \; -quit
- name: Upload Artifact
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: docs
path: _docs
+4 -4
View File
@@ -52,13 +52,13 @@ jobs:
- name: Move coverage files into place
if: env.ENABLE_COVERAGE == 'true'
run: mv coverage/lcov.info coverage/${{ steps.setupNode.output.node-version }}-${{ matrix.specs }}.lcov.info
run: mv coverage/lcov.info coverage/${{ steps.setupNode.outputs.node-version }}-${{ matrix.specs }}.lcov.info
- name: Upload Artifact
if: env.ENABLE_COVERAGE == 'true'
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
with:
name: coverage
name: coverage-${{ matrix.specs }}-${{ matrix.node == 'lts/*' && 'lts' || matrix.node }}
path: |
coverage
!coverage/lcov-report
@@ -82,7 +82,7 @@ jobs:
steps:
- name: Skip SonarCloud on merge queues
if: env.ENABLE_COVERAGE == 'false'
uses: Sibz/github-status-action@faaa4d96fecf273bd762985e0e7f9f933c774918 # v1
uses: Sibz/github-status-action@071b5370da85afbb16637d6eed8524a06bc2053e # v1
with:
authToken: ${{ secrets.GITHUB_TOKEN }}
state: success
+14
View File
@@ -0,0 +1,14 @@
name: Move new issues into Issue triage board
on:
issues:
types: [opened]
jobs:
automate-project-columns-next:
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@main
with:
project-url: https://github.com/orgs/element-hq/projects/120
github-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
+2 -1
View File
@@ -7,4 +7,5 @@ on:
jobs:
call-triage-labelled:
uses: vector-im/element-web/.github/workflows/triage-labelled.yml@develop
secrets: inherit
secrets:
ELEMENT_BOT_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }}
-1
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env sh
. "$(dirname "$0")/_/husky.sh"
npx lint-staged
+2 -1
View File
@@ -25,5 +25,6 @@ out
# This file is owned, parsed, and generated by allchange, which doesn't comply with prettier
/CHANGELOG.md
# This file is also autogenerated
# These files are also autogenerated
/spec/test-utils/test-data/index.ts
/spec/test-utils/test_indexeddb_cryptostore_dump/dump.json
+133
View File
@@ -1,3 +1,135 @@
Changes in [31.5.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.5.0) (2024-03-12)
==================================================================================================
## ✨ Features
* Update MSC2965 OIDC Discovery implementation ([#4064](https://github.com/matrix-org/matrix-js-sdk/pull/4064)). Contributed by @t3chguy.
## 🐛 Bug Fixes
* Add basic retry for rust crypto outgoing requests ([#4061](https://github.com/matrix-org/matrix-js-sdk/pull/4061)). Contributed by @BillCarsonFr.
Changes in [31.4.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.4.0) (2024-02-27)
==================================================================================================
## ✨ Features
* Validate `account_management_uri` and `account_management_actions_supported` from OIDC Issuer well-known ([#4074](https://github.com/matrix-org/matrix-js-sdk/pull/4074)). Contributed by @t3chguy.
* Allow specifying OIDC url state parameter for passing data to callback ([#4068](https://github.com/matrix-org/matrix-js-sdk/pull/4068)). Contributed by @t3chguy.
* Add getAuthIssuer method for MSC2965 ([#4071](https://github.com/matrix-org/matrix-js-sdk/pull/4071)). Contributed by @t3chguy.
* Allow specifying more OIDC client metadata for dynamic registration ([#4070](https://github.com/matrix-org/matrix-js-sdk/pull/4070)). Contributed by @t3chguy.
* Add unread marker event type ([#4069](https://github.com/matrix-org/matrix-js-sdk/pull/4069)). Contributed by @dbkr.
* Add "AsJson" forms of the key import/export methods ([#4057](https://github.com/matrix-org/matrix-js-sdk/pull/4057)). Contributed by @andybalaam.
## 🐛 Bug Fixes
* Ignore memberships of users that are not in the call ([#4065](https://github.com/matrix-org/matrix-js-sdk/pull/4065)). Contributed by @toger5.
* Await encrypted messages ([#4063](https://github.com/matrix-org/matrix-js-sdk/pull/4063)). Contributed by @toger5.
* ElementR | Ensure own user and device trust are updated after migration before giving back control to the app. ([#4059](https://github.com/matrix-org/matrix-js-sdk/pull/4059)). Contributed by @BillCarsonFr.
* Bump matrix-sdk-crypto-wasm to 4.5.0 ([#4060](https://github.com/matrix-org/matrix-js-sdk/pull/4060)). Contributed by @andybalaam.
Changes in [31.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.3.0) (2024-02-13)
==================================================================================================
## ✨ Features
* Add expire\_ts compatibility to matrixRTC ([#4032](https://github.com/matrix-org/matrix-js-sdk/pull/4032)). Contributed by @toger5.
* Element-R: support for migration of the room list from legacy crypto ([#4036](https://github.com/matrix-org/matrix-js-sdk/pull/4036)). Contributed by @richvdh.
* Element-R: check persistent room list for encryption config ([#4035](https://github.com/matrix-org/matrix-js-sdk/pull/4035)). Contributed by @richvdh.
* Support optional MSC3860 redirects ([#4007](https://github.com/matrix-org/matrix-js-sdk/pull/4007)). Contributed by @turt2live.
## 🐛 Bug Fixes
* WebR: migrate the megolm session imported flag ([#4037](https://github.com/matrix-org/matrix-js-sdk/pull/4037)). Contributed by @BillCarsonFr.
* ElementR: fix emoji verification stalling when both ends hit start at the same time ([#4004](https://github.com/matrix-org/matrix-js-sdk/pull/4004)). Contributed by @uhoreg.
* Dependencies: Bump wasm bindings version to 4.3.0 ([#4042](https://github.com/matrix-org/matrix-js-sdk/pull/4042)). Contributed by @BillCarsonFr.
* Element R: emit events when devices have changed ([#4019](https://github.com/matrix-org/matrix-js-sdk/pull/4019)). Contributed by @uhoreg.
* ElementR: report invalid keys rather than failing to restore from backup ([#4006](https://github.com/matrix-org/matrix-js-sdk/pull/4006)). Contributed by @uhoreg.
* Make `timeline` a getter ([#4022](https://github.com/matrix-org/matrix-js-sdk/pull/4022)). Contributed by @florianduros.
* Implement getting verification cancellation info in Rust crypto ([#3947](https://github.com/matrix-org/matrix-js-sdk/pull/3947)). Contributed by @uhoreg.
* Fix crypto migration for megolm sessions with no sender key ([#4024](https://github.com/matrix-org/matrix-js-sdk/pull/4024)). Contributed by @richvdh.
Changes in [31.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.2.0) (2024-01-31)
==================================================================================================
## ✨ Features
* Emit events during migration from libolm ([#3982](https://github.com/matrix-org/matrix-js-sdk/pull/3982)). Contributed by @richvdh.
* Support for migration from from libolm ([#3978](https://github.com/matrix-org/matrix-js-sdk/pull/3978)). Contributed by @richvdh.
## 🐛 Bug Fixes
* ElementR | backup: call expensive `roomKeyCounts` less often ([#4015](https://github.com/matrix-org/matrix-js-sdk/pull/4015)). Contributed by @BillCarsonFr.
* Decrypt and Import full backups in chunk with progress ([#4005](https://github.com/matrix-org/matrix-js-sdk/pull/4005)). Contributed by @BillCarsonFr.
* Fix new threads not appearing. ([#4009](https://github.com/matrix-org/matrix-js-sdk/pull/4009)). Contributed by @dbkr.
Changes in [31.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.1.0) (2024-01-19)
==================================================================================================
## ✨ Features
* Broaden spec version support ([#4016](https://github.com/matrix-org/matrix-js-sdk/pull/4016)). Contributed by @RiotRobot.
Changes in [31.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v31.0.0) (2024-01-16)
==================================================================================================
## 🚨 BREAKING CHANGES
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
## ✨ Features
* Bump minimum spec version to v1.5 ([#3970](https://github.com/matrix-org/matrix-js-sdk/pull/3970)). Contributed by @richvdh.
* Send authenticated /versions request ([#3968](https://github.com/matrix-org/matrix-js-sdk/pull/3968)). Contributed by @dbkr.
## 🐛 Bug Fixes
* Revert "Bump matrix-sdk-crypto-wasm to 3.6.0" ([#3991](https://github.com/matrix-org/matrix-js-sdk/pull/3991)). Contributed by @andybalaam.
* #22606 Fix "Remove" button to users without "m.room.redaction" ([#3981](https://github.com/matrix-org/matrix-js-sdk/pull/3981)). Contributed by @rashmitpankhania.
* ElementR: Ensure Encryption order per room ([#3973](https://github.com/matrix-org/matrix-js-sdk/pull/3973)). Contributed by @BillCarsonFr.
* Element-R: fix `bootstrapSecretStorage` not resetting key backup when requested ([#3976](https://github.com/matrix-org/matrix-js-sdk/pull/3976)). Contributed by @uhoreg.
Changes in [30.3.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.3.0) (2023-12-19)
==================================================================================================
## ✨ Features
* Element-R: disable sending room key requests ([#3939](https://github.com/matrix-org/matrix-js-sdk/pull/3939)). Contributed by @richvdh.
## 🐛 Bug Fixes
* Fix notifications appearing for old events ([#3946](https://github.com/matrix-org/matrix-js-sdk/pull/3946)). Contributed by @dbkr.
* Don't back up keys that we got from backup ([#3934](https://github.com/matrix-org/matrix-js-sdk/pull/3934)). Contributed by @uhoreg.
* Fix upload with empty Content-Type ([#3918](https://github.com/matrix-org/matrix-js-sdk/pull/3918)). Contributed by @JakubOnderka.
* Prevent phantom notifications from events not in a room's timeline ([#3942](https://github.com/matrix-org/matrix-js-sdk/pull/3942)). Contributed by @dbkr.
Changes in [30.2.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.2.0) (2023-12-05)
==================================================================================================
## ✨ Features
* Only await key query after lazy members resolved ([#3902](https://github.com/matrix-org/matrix-js-sdk/pull/3902)). Contributed by @BillCarsonFr.
## 🐛 Bug Fixes
* Rewrite receipt-handling code ([#3901](https://github.com/matrix-org/matrix-js-sdk/pull/3901)). Contributed by @andybalaam.
* Explicitly free some Rust-side objects ([#3911](https://github.com/matrix-org/matrix-js-sdk/pull/3911)). Contributed by @richvdh.
* Fix type for TimestampToEventResponse.origin\_server\_ts ([#3906](https://github.com/matrix-org/matrix-js-sdk/pull/3906)). Contributed by @Half-Shot.
Changes in [30.1.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.1.0) (2023-11-21)
==================================================================================================
## ✨ Features
* Rotate per-participant keys when a member leaves ([#3833](https://github.com/matrix-org/matrix-js-sdk/pull/3833)). Contributed by @dbkr.
* Add E2EE for embedded mode of Element Call ([#3667](https://github.com/matrix-org/matrix-js-sdk/pull/3667)). Contributed by @SimonBrandner.
## 🐛 Bug Fixes
* Shorten TimelineWindow when an event is removed ([#3862](https://github.com/matrix-org/matrix-js-sdk/pull/3862)). Contributed by @andybalaam.
* Ignore receipts pointing at missing or invalid events ([#3817](https://github.com/matrix-org/matrix-js-sdk/pull/3817)). Contributed by @andybalaam.
* Fix members being loaded from server on initial sync (defeating lazy loading) ([#3830](https://github.com/matrix-org/matrix-js-sdk/pull/3830)). Contributed by @BillCarsonFr.
Changes in [30.0.1](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30.0.1) (2023-11-13)
==================================================================================================
@@ -9,6 +141,7 @@ Changes in [30.0.0](https://github.com/matrix-org/matrix-js-sdk/releases/tag/v30
## 🚨 BREAKING CHANGES
* Refactor & make base64 functions browser-safe ([\#3818](https://github.com/matrix-org/matrix-js-sdk/pull/3818)).
* `IndexedDBStore.startup()` must be called after using it on `sdk.createClient` now.
## 🦖 Deprecations
* Deprecate `MatrixEvent.toJSON` ([\#3801](https://github.com/matrix-org/matrix-js-sdk/pull/3801)).
+7 -7
View File
@@ -58,7 +58,7 @@ await client.startClient({ initialSyncLimit: 10 });
You can perform a call to `/sync` to get the current state of the client:
```javascript
client.once("sync", function (state, prevState, res) {
client.once(ClientEvent.sync, function (state, prevState, res) {
if (state === "PREPARED") {
console.log("prepared");
} else {
@@ -83,7 +83,7 @@ client.sendEvent("roomId", "m.room.message", content, "", (err, res) => {
To listen for message events:
```javascript
client.on("Room.timeline", function (event, room, toStartOfTimeline) {
client.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
if (event.getType() !== "m.room.message") {
return; // only use messages
}
@@ -144,12 +144,12 @@ are updated.
```javascript
// Listen for low-level MatrixEvents
client.on("event", function (event) {
client.on(ClientEvent.Event, function (event) {
console.log(event.getType());
});
// Listen for typing changes
client.on("RoomMember.typing", function (event, member) {
client.on(RoomMemberEvent.Typing, function (event, member) {
if (member.typing) {
console.log(member.name + " is typing...");
} else {
@@ -211,7 +211,7 @@ const matrixClient = sdk.createClient({
### Automatically join rooms when invited
```javascript
matrixClient.on("RoomMember.membership", function (event, member) {
matrixClient.on(RoomMemberEvent.Membership, function (event, member) {
if (member.membership === "invite" && member.userId === myUserId) {
matrixClient.joinRoom(member.roomId).then(function () {
console.log("Auto-joined %s", member.roomId);
@@ -225,7 +225,7 @@ matrixClient.startClient();
### Print out messages for all rooms
```javascript
matrixClient.on("Room.timeline", function (event, room, toStartOfTimeline) {
matrixClient.on(RoomEvent.Timeline, function (event, room, toStartOfTimeline) {
if (toStartOfTimeline) {
return; // don't print paginated results
}
@@ -257,7 +257,7 @@ Output:
### Print out membership lists whenever they are changed
```javascript
matrixClient.on("RoomState.members", function (event, state, member) {
matrixClient.on(RoomStateEvent.Members, function (event, state, member) {
const room = matrixClient.getRoom(state.roomId);
if (!room) {
return;
-1
View File
@@ -4,6 +4,5 @@
# Deep dive
- [Release Process](release.md)
- [Storage notes](storage-notes.md)
- [Unverified devices](warning-on-unverified-devices.md)
-24
View File
@@ -1,24 +0,0 @@
# Release Process
## Hotfix and off-cycle releases
1. Prepare the `staging` branch by using the backport automation and manually merging
2. Go to [Releasing](#Releasing)
## Release candidates
1. Prepare the `staging` branch by running the [branch cut automation](https://github.com/vector-im/element-web/actions/workflows/release_prepare.yml)
2. Go to [Releasing](#Releasing)
## Releasing
1. Open the [Releases page](https://github.com/matrix-org/matrix-js-sdk/releases) and inspect the draft release there
2. Make any modifications to the release notes and tag/version as required
3. Run [workflow](https://github.com/matrix-org/matrix-js-sdk/actions/workflows/release.yml) with the type set appropriately
## Artifacts
Releasing the Matrix JS SDK has just two artifacts:
- Package published to [npm](https://github.com/matrix-org/matrix-js-sdk)
- Docs published to [Github Pages](https://matrix-org.github.io/matrix-js-sdk/)
+26 -24
View File
@@ -1,12 +1,12 @@
{
"name": "matrix-js-sdk",
"version": "30.0.1",
"version": "31.5.0",
"description": "Matrix Client-Server SDK for Javascript",
"engines": {
"node": ">=18.0.0"
},
"scripts": {
"prepublishOnly": "yarn build",
"prepack": "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",
@@ -14,10 +14,11 @@
"build:types": "tsc -p tsconfig-build.json --emitDeclarationOnly",
"build:compile": "babel -d lib --verbose --extensions \".ts,.js\" src",
"gendoc": "typedoc",
"lint": "yarn lint:types && yarn lint:js",
"lint": "yarn lint:types && yarn lint:js && yarn lint:workflows",
"lint:js": "eslint --max-warnings 0 src spec && prettier --check .",
"lint:js-fix": "prettier --loglevel=warn --write . && eslint --fix src spec",
"lint:js-fix": "prettier --log-level=warn --write . && eslint --fix src spec",
"lint:types": "tsc --noEmit",
"lint:workflows": "find .github/workflows -type f \\( -iname '*.yaml' -o -iname '*.yml' \\) | xargs -I {} sh -c 'echo \"Linting {}\"; action-validator \"{}\"'",
"test": "jest",
"test:watch": "jest --watch",
"coverage": "yarn test --coverage"
@@ -29,8 +30,8 @@
"keywords": [
"matrix-org"
],
"main": "./src/index.ts",
"browser": "./src/browser-index.ts",
"main": "./lib/index.js",
"browser": "./lib/browser-index.js",
"matrix_src_main": "./src/index.ts",
"matrix_src_browser": "./src/browser-index.ts",
"matrix_lib_main": "./lib/index.js",
@@ -51,21 +52,23 @@
],
"dependencies": {
"@babel/runtime": "^7.12.5",
"@matrix-org/matrix-sdk-crypto-wasm": "^3.0.1",
"@matrix-org/matrix-sdk-crypto-wasm": "^4.6.0",
"another-json": "^0.2.0",
"bs58": "^5.0.0",
"content-type": "^1.0.4",
"jwt-decode": "^3.1.2",
"jwt-decode": "^4.0.0",
"loglevel": "^1.7.1",
"matrix-events-sdk": "0.0.1",
"matrix-widget-api": "^1.6.0",
"oidc-client-ts": "^2.2.4",
"oidc-client-ts": "^3.0.1",
"p-retry": "4",
"sdp-transform": "^2.14.1",
"unhomoglyph": "^1.0.6",
"uuid": "9"
},
"devDependencies": {
"@action-validator/cli": "^0.5.3",
"@action-validator/core": "^0.5.3",
"@babel/cli": "^7.12.10",
"@babel/core": "^7.12.10",
"@babel/eslint-parser": "^7.12.10",
@@ -80,6 +83,7 @@
"@babel/register": "^7.12.10",
"@casualbot/jest-sonar-reporter": "2.2.7",
"@matrix-org/olm": "3.2.15",
"@peculiar/webcrypto": "^1.4.5",
"@types/bs58": "^4.0.1",
"@types/content-type": "^1.1.5",
"@types/debug": "^4.1.7",
@@ -88,40 +92,37 @@
"@types/node": "18",
"@types/sdp-transform": "^2.4.5",
"@types/uuid": "9",
"@typescript-eslint/eslint-plugin": "^5.45.0",
"@typescript-eslint/parser": "^5.45.0",
"allchange": "^1.0.6",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"babel-jest": "^29.0.0",
"babelify": "^10.0.0",
"debug": "^4.3.4",
"domexception": "^4.0.0",
"eslint": "8.53.0",
"eslint": "8.56.0",
"eslint-config-google": "^0.14.0",
"eslint-config-prettier": "^9.0.0",
"eslint-import-resolver-typescript": "^3.5.1",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-jest": "^27.1.6",
"eslint-plugin-jsdoc": "^46.0.0",
"eslint-plugin-jsdoc": "^48.0.0",
"eslint-plugin-matrix-org": "^1.0.0",
"eslint-plugin-tsdoc": "^0.2.17",
"eslint-plugin-unicorn": "^49.0.0",
"exorcist": "^2.0.0",
"fake-indexeddb": "^5.0.0",
"eslint-plugin-unicorn": "^51.0.0",
"fake-indexeddb": "^5.0.2",
"fetch-mock": "9.11.0",
"fetch-mock-jest": "^1.5.1",
"husky": "^8.0.3",
"husky": "^9.0.0",
"jest": "^29.0.0",
"jest-environment-jsdom": "^29.0.0",
"jest-localstorage-mock": "^2.4.6",
"jest-mock": "^29.0.0",
"lint-staged": "^15.0.2",
"matrix-mock-request": "^2.5.0",
"prettier": "2.8.8",
"node-fetch": "^2.7.0",
"prettier": "3.2.5",
"rimraf": "^5.0.0",
"terser": "^5.5.1",
"ts-node": "^10.9.1",
"tsify": "^5.0.2",
"typedoc": "^0.24.0",
"typedoc-plugin-coverage": "^2.1.0",
"typedoc-plugin-coverage": "^3.0.0",
"typedoc-plugin-mdn-links": "^3.0.3",
"typedoc-plugin-missing-exports": "^2.0.0",
"typedoc-plugin-versions": "^0.2.3",
@@ -132,5 +133,6 @@
"outputDirectory": "coverage",
"outputName": "jest-sonar-report.xml",
"relativePaths": true
}
},
"typings": "./lib/index.d.ts"
}
-15
View File
@@ -1,15 +0,0 @@
#!/bin/bash
#
# Script to perform a post-release steps of matrix-js-sdk.
#
# Requires:
# jq; install from your distribution's package manager (https://stedolan.github.io/jq/)
set -e
jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
"$(dirname "$0")/scripts/release/post-merge-master.sh"
git push origin develop
fi
-346
View File
@@ -1,346 +0,0 @@
#!/bin/bash
#
# Script to perform a release of matrix-js-sdk and downstream projects.
#
# Requires:
# jq; install from your distribution's package manager (https://stedolan.github.io/jq/)
# hub; install via brew (macOS) or source/pre-compiled binaries (debian) (https://github.com/github/hub) - Tested on v2.2.9
# yarn; install via brew (macOS) or similar (https://yarnpkg.com/docs/install/)
#
# Note: this script is also used to release matrix-react-sdk, element-web, and element-desktop.
set -e
jq --version > /dev/null || (echo "jq is required: please install it"; kill $$)
if [[ $(command -v hub) ]] && [[ $(hub --version) =~ hub[[:space:]]version[[:space:]]([0-9]*).([0-9]*) ]]; then
HUB_VERSION_MAJOR=${BASH_REMATCH[1]}
HUB_VERSION_MINOR=${BASH_REMATCH[2]}
if [[ $HUB_VERSION_MAJOR -lt 2 ]] || [[ $HUB_VERSION_MAJOR -eq 2 && $HUB_VERSION_MINOR -lt 5 ]]; then
echo "hub version 2.5 is required, you have $HUB_VERSION_MAJOR.$HUB_VERSION_MINOR installed"
exit
fi
else
echo "hub is required: please install it"
exit
fi
yarn --version > /dev/null || (echo "yarn is required: please install it"; kill $$)
USAGE="$0 [-x] [-c changelog_file] vX.Y.Z"
help() {
cat <<EOF
$USAGE
-c changelog_file: specify name of file containing changelog
-x: skip updating the changelog
EOF
}
if ! git diff-index --quiet --cached HEAD; then
echo "this git checkout has staged (uncommitted) changes. Refusing to release."
exit
fi
if ! git diff-files --quiet; then
echo "this git checkout has uncommitted changes. Refusing to release."
exit
fi
skip_changelog=
changelog_file="CHANGELOG.md"
while getopts hc:x f; do
case $f in
h)
help
exit 0
;;
c)
changelog_file="$OPTARG"
;;
x)
skip_changelog=1
;;
esac
done
shift $(expr $OPTIND - 1)
if [ $# -ne 1 ]; then
echo "Usage: $USAGE" >&2
exit 1
fi
function check_dependency {
local depver=$(cat package.json | jq -r .dependencies[\"$1\"])
if [ "$depver" == "null" ]; then return 0; fi
echo "Checking version of $1..."
local latestver=$(yarn info -s "$1" dist-tags.next)
if [ "$depver" != "$latestver" ]
then
echo "The latest version of $1 is $latestver but package.json depends on $depver."
echo -n "Type 'u' to auto-upgrade, 'c' to continue anyway, or 'a' to abort:"
read resp
if [ "$resp" != "u" ] && [ "$resp" != "c" ]
then
echo "Aborting."
exit 1
fi
if [ "$resp" == "u" ]
then
echo "Upgrading $1 to $latestver..."
yarn add -E "$1@$latestver"
git add -u
git commit -m "Upgrade $1 to $latestver"
fi
fi
}
function reset_dependency {
local depver=$(cat package.json | jq -r .dependencies[\"$1\"])
if [ "$depver" == "null" ]; then return 0; fi
echo "Resetting $1 to develop branch..."
yarn add "github:matrix-org/$1#develop"
git add -u
git commit -m "Reset $1 back to develop branch"
}
has_subprojects=0
if [ -f release_config.yaml ]; then
subprojects=$(cat release_config.yaml | python -c "import yaml; import sys; print(' '.join(list(yaml.load(sys.stdin)['subprojects'].keys())))" 2> /dev/null)
if [ "$?" -eq 0 ]; then
has_subprojects=1
echo "Checking subprojects for upgrades"
for proj in $subprojects; do
check_dependency "$proj"
done
fi
fi
ret=0
cat package.json | jq '.dependencies[]' | grep -q '#develop' || ret=$?
if [ "$ret" -eq 0 ]; then
echo "package.json contains develop dependencies. Refusing to release."
exit
fi
# We use Git branch / commit dependencies for some packages, and Yarn seems
# to have a hard time getting that right. See also
# https://github.com/yarnpkg/yarn/issues/4734. As a workaround, we clean the
# global cache here to ensure we get the right thing.
yarn cache clean
# Ensure all dependencies are updated
yarn install --ignore-scripts --frozen-lockfile
# ignore leading v on release
release="${1#v}"
tag="v${release}"
prerelease=0
# We check if this build is a prerelease by looking to
# see if the version has a hyphen in it. Crude,
# but semver doesn't support postreleases so anything
# with a hyphen is a prerelease.
echo $release | grep -q '-' && prerelease=1
if [ $prerelease -eq 1 ]; then
echo Making a PRE-RELEASE
else
read -p "Making a FINAL RELEASE, press enter to continue " REPLY
fi
rel_branch=$(git symbolic-ref --short HEAD)
if [ -z "$skip_changelog" ]; then
echo "Generating changelog"
yarn run allchange "$release"
read -p "Edit $changelog_file manually, or press enter to continue " REPLY
if [ -n "$(git ls-files --modified $changelog_file)" ]; then
echo "Committing updated changelog"
git commit "$changelog_file" -m "Prepare changelog for $tag"
fi
fi
latest_changes=$(mktemp)
cat "${changelog_file}" | "$(dirname "$0")/scripts/changelog_head.py" > "${latest_changes}"
set -x
# Bump package.json and build the dist
echo "yarn version"
# yarn version will automatically commit its modification
# and make a release tag. We don't want it to create the tag
# because it can only sign with the default key, but we can
# only turn off both of these behaviours, so we have to
# manually commit the result.
yarn version --no-git-tag-version --new-version "$release"
"$(dirname "$0")/scripts/release/pre-release.sh"
# commit yarn.lock if it exists, is versioned, and is modified
if [[ -f yarn.lock && $(git status --porcelain yarn.lock | grep '^ M') ]];
then
pkglock='yarn.lock'
else
pkglock=''
fi
git commit package.json $pkglock -m "$tag"
# figure out if we should be signing this release
signing_id=
if [ -f release_config.yaml ]; then
result=$(cat release_config.yaml | python -c "import yaml; import sys; print(yaml.load(sys.stdin)['signing_id'])" 2> /dev/null || true)
if [ "$?" -eq 0 ]; then
signing_id=$result
fi
fi
# If there is a 'dist' script in the package.json,
# run it in a separate checkout of the project, then
# upload any files in the 'dist' directory as release
# assets.
# We make a completely separate checkout to be sure
# we're using released versions of the dependencies
# (rather than whatever we're pulling in from yarn link)
assets=''
dodist=0
jq -e .scripts.dist package.json 2> /dev/null || dodist=$?
if [ $dodist -eq 0 ]; then
projdir=$(pwd)
builddir=$(mktemp -d 2>/dev/null || mktemp -d -t 'mytmpdir')
echo "Building distribution copy in $builddir"
pushd "$builddir"
git clone "$projdir" .
git checkout "$rel_branch"
yarn install --frozen-lockfile
# We haven't tagged yet, so tell the dist script what version
# it's building
DIST_VERSION="$tag" yarn dist
popd
for i in "$builddir"/dist/*; do
assets="$assets -a $i"
if [ -n "$signing_id" ]
then
gpg -u "$signing_id" --armor --output "$i".asc --detach-sig "$i"
assets="$assets -a $i.asc"
fi
done
fi
if [ -n "$signing_id" ]; then
# make a signed tag
# gnupg seems to fail to get the right tty device unless we set it here
GIT_COMMITTER_EMAIL="$signing_id" GPG_TTY=$(tty) git tag -u "$signing_id" -F "${latest_changes}" "$tag"
else
git tag -a -F "${latest_changes}" "$tag"
fi
# push the tag and the release branch
git push origin "$rel_branch" "$tag"
if [ -n "$signing_id" ]; then
# make a signature for the source tarball.
#
# github will make us a tarball from the tag - we want to create a
# signature for it, which means that first of all we need to check that
# it's correct.
#
# we can't deterministically build exactly the same tarball, due to
# differences in gzip implementation - but we *can* build the same tar - so
# the easiest way to check the validity of the tarball from git is to unzip
# it and compare it with our own idea of what the tar should look like.
# This uses git archive which seems to be what github uses. Specifically,
# the header fields are set in the same way: same file mode, uid & gid
# both zero and mtime set to the timestamp of the commit that the tag
# references. Also note that this puts the commit into the tar headers
# and can be extracted with gunzip -c foo.tar.gz | git get-tar-commit-id
# the name of the sig file we want to create
source_sigfile="${tag}-src.tar.gz.asc"
tarfile="$tag.tar.gz"
gh_project_url=$(git remote get-url origin |
sed -e 's#^git@github\.com:#https://github.com/#' \
-e 's#^git\+ssh://git@github\.com/#https://github.com/#' \
-e 's/\.git$//')
project_name="${gh_project_url##*/}"
curl -L "${gh_project_url}/archive/${tarfile}" -o "${tarfile}"
# unzip it and compare it with the tar we would generate
if ! cmp --silent <(gunzip -c $tarfile) \
<(git archive --format tar --prefix="${project_name}-${release}/" "$tag"); then
# we don't bail out here, because really it's more likely that our comparison
# screwed up and it's super annoying to abort the script at this point.
cat >&2 <<EOF
!!!!!!!!!!!!!!!!!
!!!! WARNING !!!!
Mismatch between our own tarfile and that generated by github: not signing
source tarball.
To resolve, determine if $tarfile is correct, and if so sign it with gpg and
attach it to the release as $source_sigfile.
!!!!!!!!!!!!!!!!!
EOF
else
gpg -u "$signing_id" --armor --output "$source_sigfile" --detach-sig "$tarfile"
assets="$assets -a $source_sigfile"
fi
fi
hubflags=''
if [ $prerelease -eq 1 ]; then
hubflags='-p'
fi
release_text=$(mktemp)
echo "$tag" > "${release_text}"
echo >> "${release_text}"
cat "${latest_changes}" >> "${release_text}"
hub release create $hubflags $assets -F "${release_text}" "$tag"
if [ $dodist -eq 0 ]; then
rm -rf "$builddir"
fi
rm "${release_text}"
rm "${latest_changes}"
# if it is a pre-release, leave it on the release branch for now.
if [ $prerelease -eq 1 ]; then
git checkout "$rel_branch"
exit 0
fi
# merge release branch to master
echo "updating master branch"
git checkout master
git pull
git merge "$rel_branch" --no-edit
# push master to github
git push origin master
# finally, merge master back onto develop (if it exists)
if [ "$(git branch -lr | grep origin/develop -c)" -ge 1 ]; then
git checkout develop
git pull
git merge master --no-edit
git push origin develop
fi
[ -x ./post-release.sh ] && ./post-release.sh
if [ $has_subprojects -eq 1 ] && [ $prerelease -eq 0 ]; then
echo "Resetting subprojects to develop"
for proj in $subprojects; do
reset_dependency "$proj"
done
git push origin develop
fi
+107 -31
View File
@@ -2,16 +2,70 @@
const fs = require("fs");
async function listReleases(github, owner, repo) {
const response = await github.rest.repos.listReleases({
owner,
repo,
per_page: 100,
});
// Filters out draft releases
return response.data.filter((release) => !release.draft);
}
// Dependency can be a tuple of dependency, from version, to version, in which case a list of releases in that range (to inclusive) will be returned
// Or it can be a string in the form accepted by `getRelease`
async function getReleases(github, dependency) {
if (Array.isArray(dependency)) {
const [dep, fromVersion, toVersion] = dependency;
const upstreamPackageJson = getDependencyPackageJson(dep);
const [owner, repo] = upstreamPackageJson.repository.url.split("/").slice(-2);
const unfilteredReleases = await listReleases(github, owner, repo);
// Only include non-draft & non-prerelease releases, unless the to-release is a pre-release, include that one
const releases = unfilteredReleases.filter(
(release) => !release.prerelease || release.tag_name === `v${toVersion}`,
);
const fromVersionIndex = releases.findIndex((release) => release.tag_name === `v${fromVersion}`);
const toVersionIndex = releases.findIndex((release) => release.tag_name === `v${toVersion}`);
return releases.slice(toVersionIndex, fromVersionIndex);
}
return [await getRelease(github, dependency)];
}
// Dependency can be the name of an entry in package.json, in which case the owner, repo & version will be looked up in its own package.json
// Or it can be a string in the form owner/repo@tag - in this case the tag is used exactly to find the release
// Or it can be a string in the form owner/repo~tag - in this case the latest tag in the same major.minor.patch set is used to find the release
async function getRelease(github, dependency) {
let owner;
let repo;
let tag;
if (dependency.includes("/") && dependency.includes("@")) {
owner = dependency.split("/")[0];
repo = dependency.split("/")[1].split("@")[0];
tag = dependency.split("@")[1];
if (dependency.includes("/")) {
let rest;
[owner, rest] = dependency.split("/")[0];
if (dependency.includes("@")) {
[repo, tag] = rest.split("@");
} else if (dependency.includes("~")) {
[repo, tag] = rest.split("~");
if (tag.includes("-rc.")) {
// If the tag is an RC, find the latest matching RC in the set
try {
const releases = await listReleases(github, owner, repo);
const baseVersion = tag.split("-rc.")[0];
const release = releases.find((release) => release.tag_name.startsWith(baseVersion));
if (release) return release;
} catch (e) {
// Fall back to getReleaseByTag
}
}
}
} else {
const upstreamPackageJson = JSON.parse(fs.readFileSync(`./node_modules/${dependency}/package.json`, "utf8"));
const upstreamPackageJson = getDependencyPackageJson(dependency);
[owner, repo] = upstreamPackageJson.repository.url.split("/").slice(-2);
tag = `v${upstreamPackageJson.version}`;
}
@@ -24,23 +78,45 @@ async function getRelease(github, dependency) {
return response.data;
}
function getDependencyPackageJson(dependency) {
return JSON.parse(fs.readFileSync(`./node_modules/${dependency}/package.json`, "utf8"));
}
const HEADING_PREFIX = "## ";
const categories = [
"🔒 SECURITY FIXES",
"🚨 BREAKING CHANGESd",
"🦖 Deprecations",
"✨ Features",
"🐛 Bug Fixes",
"🧰 Maintenance",
];
const parseReleaseNotes = (body, sections) => {
let heading = null;
for (const line of body.split("\n")) {
const trimmed = line.trim();
if (trimmed.startsWith(HEADING_PREFIX)) {
heading = trimmed.slice(HEADING_PREFIX.length);
if (!categories.includes(heading)) heading = null;
continue;
}
if (heading && trimmed) {
sections[heading].push(trimmed);
}
}
};
const main = async ({ github, releaseId, dependencies }) => {
const { GITHUB_REPOSITORY } = process.env;
const [owner, repo] = GITHUB_REPOSITORY.split("/");
const sections = new Map();
let heading = null;
const sections = Object.fromEntries(categories.map((cat) => [cat, []]));
for (const dependency of dependencies) {
const release = await getRelease(github, dependency);
for (const line of release.body.split("\n")) {
if (line.startsWith("#")) {
heading = line;
sections.set(heading, []);
continue;
}
if (heading && line) {
sections.get(heading).push(line);
}
const releases = await getReleases(github, dependency);
for (const release of releases) {
parseReleaseNotes(release.body, sections);
}
}
@@ -50,22 +126,22 @@ const main = async ({ github, releaseId, dependencies }) => {
release_id: releaseId,
});
heading = null;
const output = [];
for (const line of [...release.body.split("\n"), null]) {
if (line === null || line.startsWith("#")) {
if (heading && sections.has(heading)) {
const lastIsBlank = !output.at(-1)?.trim();
if (lastIsBlank) output.pop();
output.push(...sections.get(heading));
if (lastIsBlank) output.push("");
}
heading = line;
}
output.push(line);
const intro = release.body.split(HEADING_PREFIX, 2)[0].trim();
let output = "";
if (intro) {
output = intro + "\n\n";
}
return output.join("\n");
for (const section in sections) {
const lines = sections[section];
if (!lines.length) continue;
output += HEADING_PREFIX + section + "\n\n";
output += lines.join("\n");
output += "\n\n";
}
return output;
};
// This is just for testing locally
+101
View File
@@ -31,9 +31,12 @@ import {
SELF_CROSS_SIGNING_PRIVATE_KEY_BASE64,
SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
USER_CROSS_SIGNING_PRIVATE_KEY_BASE64,
} from "../../test-utils/test-data";
import * as testData from "../../test-utils/test-data";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -97,6 +100,12 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
/** an object which intercepts `/keys/upload` requests on the test homeserver */
new E2EKeyReceiver(homeserverUrl);
// Silence warnings from the backup manager
fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
status: 404,
body: { errcode: "M_NOT_FOUND" },
});
await initCrypto(aliceClient);
});
@@ -236,6 +245,53 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
`[${TEST_USER_ID}].[${TEST_DEVICE_ID}].signatures.[${TEST_USER_ID}].[ed25519:${SELF_CROSS_SIGNING_PUBLIC_KEY_BASE64}]`,
);
});
it("can bootstrapCrossSigning twice", async () => {
mockSetupCrossSigningRequests();
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
// a second call should do nothing except GET requests
fetchMock.mockClear();
await bootstrapCrossSigning(authDict);
const calls = fetchMock.calls((url, opts) => opts.method != "GET");
expect(calls.length).toEqual(0);
});
newBackendOnly("will upload existing cross-signing keys to an established secret storage", async () => {
// This rather obscure codepath covers the case that:
// - 4S is set up and working
// - our device has private cross-signing keys, but has not published them to 4S
//
// To arrange that, we call `bootstrapCrossSigning` on our main device, and then (pretend to) set up 4S from
// a *different* device. Then, when we call `bootstrapCrossSigning` again, it should do the honours.
mockSetupCrossSigningRequests();
const accountDataAccumulator = new AccountDataAccumulator();
accountDataAccumulator.interceptGetAccountData();
const authDict = { type: "test" };
await bootstrapCrossSigning(authDict);
// Pretend that another device has uploaded a 4S key
accountDataAccumulator.accountDataEvents.set("m.secret_storage.default_key", { key: "key_id" });
accountDataAccumulator.accountDataEvents.set("m.secret_storage.key.key_id", {
key: "keykeykey",
algorithm: SECRET_STORAGE_ALGORITHM_V1_AES,
});
// Prepare for the cross-signing keys
const p = accountDataAccumulator.interceptSetAccountData(":type(m.cross_signing..*)");
await bootstrapCrossSigning(authDict);
await p;
// The cross-signing keys should have been uploaded
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.master")).toBeTruthy();
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.self_signing")).toBeTruthy();
expect(accountDataAccumulator.accountDataEvents.has("m.cross_signing.user_signing")).toBeTruthy();
});
});
describe("getCrossSigningStatus()", () => {
@@ -339,4 +395,49 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("cross-signing (%s)", (backend: s
expect(userSigningKeyId).toBe(getPubKey(crossSigningKeys.user_signing_key));
});
});
describe("crossSignDevice", () => {
beforeEach(async () => {
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// make sure that there is another device which we can sign
e2eKeyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
// Complete initialsync, to get the outgoing requests going
mockInitialApiRequests(aliceClient.getHomeserverUrl());
syncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
await aliceClient.startClient();
await syncPromise(aliceClient);
// Wait for legacy crypto to find the device
await jest.advanceTimersByTimeAsync(10);
const devices = await aliceClient.getCrypto()!.getUserDeviceInfo([aliceClient.getSafeUserId()]);
expect(devices.get(aliceClient.getSafeUserId())!.has(testData.TEST_DEVICE_ID)).toBeTruthy();
});
afterEach(async () => {
jest.useRealTimers();
});
it("fails for an unknown device", async () => {
await expect(aliceClient.getCrypto()!.crossSignDevice("unknown")).rejects.toThrow("Unknown device");
});
it("cross-signs the device", async () => {
mockSetupCrossSigningRequests();
await aliceClient.getCrypto()!.bootstrapCrossSigning({});
fetchMock.mockClear();
await aliceClient.getCrypto()!.crossSignDevice(testData.TEST_DEVICE_ID);
// check that a sig for the device was uploaded
const calls = fetchMock.calls("upload-sigs");
expect(calls.length).toEqual(1);
const body = JSON.parse(calls[0][1]!.body as string);
const deviceSig = body[aliceClient.getSafeUserId()][testData.TEST_DEVICE_ID];
expect(deviceSig).toHaveProperty("signatures");
});
});
});
+278 -116
View File
@@ -29,6 +29,7 @@ import {
getSyncResponse,
InitCrypto,
mkEventCustom,
mkMembershipCustom,
syncPromise,
} from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
@@ -38,6 +39,7 @@ import {
BOB_TEST_USER_ID,
SIGNED_CROSS_SIGNING_KEYS_DATA,
SIGNED_TEST_DEVICE_DATA,
TEST_ROOM_ID,
TEST_ROOM_ID as ROOM_ID,
TEST_USER_ID,
} from "../../test-utils/test-data";
@@ -74,7 +76,7 @@ import {
mockSetupCrossSigningRequests,
mockSetupMegolmBackupRequests,
} from "../../test-utils/mockEndpoints";
import { AddSecretStorageKeyOpts } from "../../../src/secret-storage";
import { SecretStorageKeyDescription } from "../../../src/secret-storage";
import {
CrossSigningKey,
CryptoCallbacks,
@@ -96,6 +98,7 @@ import {
getTestOlmAccountKeys,
} from "./olm-utils";
import { ToDevicePayload } from "../../../src/models/ToDeviceMessage";
import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator";
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -229,9 +232,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
/** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */
let keyReceiver: E2EKeyReceiver;
/** an object which intercepts `/keys/query` requests on the test homeserver */
let keyResponder: E2EKeyResponder;
/** an object which intercepts `/sync` requests from {@link #aliceClient} */
let syncResponder: ISyncResponder;
@@ -339,7 +339,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
function createCryptoCallbacks(): CryptoCallbacks {
// Store the cached secret storage key and return it when `getSecretStorageKey` is called
let cachedKey: { keyId: string; key: Uint8Array };
const cacheSecretStorageKey = (keyId: string, keyInfo: AddSecretStorageKeyOpts, key: Uint8Array) => {
const cacheSecretStorageKey = (keyId: string, keyInfo: SecretStorageKeyDescription, key: Uint8Array) => {
cachedKey = {
keyId,
key,
@@ -367,6 +367,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
cryptoCallbacks: createCryptoCallbacks(),
logger: logger.getChild("aliceClient"),
});
/* set up listeners for /keys/upload and /sync */
@@ -397,6 +398,13 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(aliceClient.getCrypto()).toHaveProperty("globalBlacklistUnverifiedDevices");
});
it("CryptoAPI.getOwnDeviceKeys returns plausible values", async () => {
const deviceKeys = await aliceClient.getCrypto()!.getOwnDeviceKeys();
// We just check for a 43-character base64 string
expect(deviceKeys.curve25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
expect(deviceKeys.ed25519).toMatch(/^[A-Za-z0-9+/]{43}$/);
});
it("Alice receives a megolm message", async () => {
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -693,7 +701,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
it("prepareToEncrypt", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
@@ -724,7 +732,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
it("Alice sends a megolm message with GlobalErrorOnUnknownDevices=false", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
@@ -752,7 +760,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
it("We should start a new megolm session after forceDiscardSession", async () => {
aliceClient.setGlobalErrorOnUnknownDevices(false);
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
@@ -1055,8 +1063,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await startClientAndAwaitFirstSync();
const p2pSession = await establishOlmSession(aliceClient, keyReceiver, syncResponder, testOlmAccount);
// We need to fake the timers to advance the time
jest.useFakeTimers();
// We need to fake the timers to advance the time, but the wasm bindings of matrix-sdk-crypto rely on a
// working `queueMicrotask`
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
const syncResponse = getSyncResponse(["@bob:xyz"]);
@@ -1370,7 +1379,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
expect(decryptedEvent.getContent().body).toEqual("42");
const exported = await aliceClient.exportRoomKeys();
const exported = await aliceClient.getCrypto()!.exportRoomKeysAsJson();
// start a new client
await aliceClient.stopClient();
@@ -1386,7 +1395,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
keyReceiver = new E2EKeyReceiver(homeserverUrl);
syncResponder = new SyncResponder(homeserverUrl);
await initCrypto(aliceClient);
await aliceClient.importRoomKeys(exported);
await aliceClient.getCrypto()!.importRoomKeysAsJson(exported);
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -2061,7 +2070,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
it("Sending an event initiates a member list sync", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
@@ -2084,7 +2093,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
it("loading the membership list inhibits a later load", async () => {
const homeserverUrl = aliceClient.getHomeserverUrl();
keyResponder = new E2EKeyResponder(homeserverUrl);
const keyResponder = new E2EKeyResponder(homeserverUrl);
keyResponder.addKeyReceiver("@alice:localhost", keyReceiver);
const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID");
@@ -2181,7 +2190,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
describe("key upload request", () => {
beforeEach(() => {
jest.useFakeTimers();
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
@@ -2381,8 +2391,9 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(devicesInfo.get(user)?.size).toBeFalsy();
});
it("Get devices from tacked users", async () => {
jest.useFakeTimers();
it("Get devices from tracked users", async () => {
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
@@ -2425,12 +2436,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
});
describe("Secret Storage and Key Backup", () => {
/**
* The account data events to be returned by the sync.
* Will be updated when fecthMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
* Will be used by `sendSyncResponseWithUpdatedAccountData`
*/
let accountDataEvents: Map<String, any>;
let accountDataAccumulator: AccountDataAccumulator;
/**
* Create a fake secret storage key
@@ -2443,76 +2449,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
beforeEach(async () => {
createSecretStorageKey.mockClear();
accountDataEvents = new Map();
accountDataAccumulator = new AccountDataAccumulator();
expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} });
await startClientAndAwaitFirstSync();
});
function mockGetAccountData() {
fetchMock.get(
`path:/_matrix/client/v3/user/:userId/account_data/:type`,
(url) => {
const type = url.split("/").pop();
const existing = accountDataEvents.get(type!);
if (existing) {
// return it
return {
status: 200,
body: existing.content,
};
} else {
// 404
return {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
};
}
},
{ overwriteRoutes: true },
);
}
/**
* Create a mock to respond to the PUT request `/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`
* Resolved when the cross signing key is uploaded
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
return new Promise((resolve) => {
// Called when the cross signing key is uploaded
fetchMock.put(
`express:/_matrix/client/v3/user/:userId/account_data/m.cross_signing.${key}`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
const type = url.split("/").pop();
// update account data for sync response
accountDataEvents.set(type!, content);
resolve(content.encrypted);
return {};
},
);
});
}
/**
* Send in the sync response the current account data events, as stored by `accountDataEvents`.
*/
function sendSyncResponseWithUpdatedAccountData() {
try {
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: Array.from(accountDataEvents, ([type, content]) => ({
type: type,
content: content,
})),
},
});
} catch (err) {
// Might fail with "Cannot queue more than one /sync response" if called too often.
// It's ok if it fails here, the sync response is cumulative and will contain
// the latest account data.
}
async function awaitCrossSigningKeyUpload(key: string): Promise<Record<string, {}>> {
const content = await accountDataAccumulator.interceptSetAccountData(`m.cross_signing.${key}`);
return content.encrypted;
}
/**
@@ -2520,28 +2469,18 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
* Resolved when a key is uploaded (ie in `body.content.key`)
* https://spec.matrix.org/v1.6/client-server-api/#put_matrixclientv3useruseridaccount_datatype
*/
function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
return new Promise((resolve) => {
// This url is called multiple times during the secret storage bootstrap process
// When we received the newly generated key, we return it
fetchMock.put(
"express:/_matrix/client/v3/user/:userId/account_data/:type(m.secret_storage.*)",
(url: string, options: RequestInit) => {
const type = url.split("/").pop();
const content = JSON.parse(options.body as string);
// update account data for sync response
accountDataEvents.set(type!, content);
if (content.key) {
resolve(content.key);
}
sendSyncResponseWithUpdatedAccountData();
return {};
},
{ overwriteRoutes: true },
);
});
async function awaitSecretStorageKeyStoredInAccountData(): Promise<string> {
// eslint-disable-next-line no-constant-condition
while (true) {
const content = await accountDataAccumulator.interceptSetAccountData(":type(m.secret_storage.*)", {
repeat: 1,
overwriteRoutes: true,
});
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
if (content.key) {
return content.key;
}
}
}
function awaitMegolmBackupKeyUpload(): Promise<Record<string, {}>> {
@@ -2552,7 +2491,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
// update account data for sync response
accountDataEvents.set("m.megolm_backup.v1", content);
accountDataAccumulator.accountDataEvents.set("m.megolm_backup.v1", content);
resolve(content.encrypted);
return {};
},
@@ -2617,7 +2556,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await bootstrapPromise;
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Finally ensure backup is working
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
@@ -2625,6 +2564,30 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await backupStatusUpdate;
}
describe("Generate 4S recovery keys", () => {
it("should create a random recovery key", async () => {
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase();
expect(generatedKey.privateKey).toBeDefined();
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
expect(generatedKey.privateKey.length).toBe(32);
expect(generatedKey.keyInfo?.passphrase).toBeUndefined();
expect(generatedKey.encodedPrivateKey).toBeDefined();
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
});
it("should create a recovery key from passphrase", async () => {
const generatedKey = await aliceClient.getCrypto()!.createRecoveryKeyFromPassphrase("mypassphrase");
expect(generatedKey.privateKey).toBeDefined();
expect(generatedKey.privateKey).toBeInstanceOf(Uint8Array);
expect(generatedKey.privateKey.length).toBe(32);
expect(generatedKey.keyInfo?.passphrase?.algorithm).toBe("m.pbkdf2");
expect(generatedKey.keyInfo?.passphrase?.iterations).toBe(500000);
expect(generatedKey.encodedPrivateKey).toBeDefined();
expect(generatedKey.encodedPrivateKey!.indexOf("Es")).toBe(0);
});
});
describe("bootstrapSecretStorage", () => {
// Doesn't work with legacy crypto, which will try to bootstrap even without private key, which is buggy.
newBackendOnly(
@@ -2639,7 +2602,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
);
it("Should create a 4S key", async () => {
mockGetAccountData();
accountDataAccumulator.interceptGetAccountData();
const awaitAccountData = awaitAccountDataUpdate("m.secret_storage.default_key");
@@ -2650,8 +2613,16 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// Wait for the key to be uploaded in the account data
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// check that the key content contains the key check info
const keyContent = accountDataAccumulator.accountDataEvents.get(
`m.secret_storage.key.${secretStorageKey}`,
)!;
// In order to verify if the key is valid, a zero secret is encrypted with the key
expect(keyContent.iv).toBeDefined();
expect(keyContent.mac).toBeDefined();
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Finally, wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2675,7 +2646,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2699,7 +2670,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2713,7 +2684,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Wait for bootstrapSecretStorage to finished
await bootstrapPromise;
@@ -2737,7 +2708,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const secretStorageKey = await awaitSecretStorageKeyStoredInAccountData();
// Return the newly created key in the sync response
sendSyncResponseWithUpdatedAccountData();
accountDataAccumulator.sendSyncResponseWithUpdatedAccountData(syncResponder);
// Wait for the cross signing keys to be uploaded
const [masterKey, userSigningKey, selfSigningKey] = await Promise.all([
@@ -2777,7 +2748,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
describe("Manage Key Backup", () => {
beforeEach(async () => {
jest.useFakeTimers();
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
});
afterEach(() => {
@@ -2890,6 +2862,19 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
const newBackupUploadPromise = awaitMegolmBackupKeyUpload();
// Track calls to scheduleAllGroupSessionsForBackup. This is
// only relevant on legacy encryption.
const scheduleAllGroupSessionsForBackup = jest.fn();
if (backend === "libolm") {
aliceClient.crypto!.backupManager.scheduleAllGroupSessionsForBackup =
scheduleAllGroupSessionsForBackup;
} else {
// With Rust crypto, we don't need to call this function, so
// we call the dummy value here so we pass our later
// expectation.
scheduleAllGroupSessionsForBackup();
}
await aliceClient.getCrypto()!.resetKeyBackup();
await awaitDeleteCalled;
await newBackupStatusUpdate;
@@ -2901,6 +2886,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(nextVersion).toBeDefined();
expect(nextVersion).not.toEqual(currentVersion);
expect(nextKey).not.toEqual(currentBackupKey);
expect(scheduleAllGroupSessionsForBackup).toHaveBeenCalled();
// The `deleteKeyBackupVersion` API is deprecated but has been modified to work with both crypto backend
// ensure that it works anyhow
@@ -2917,7 +2903,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
keyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
const keyResponder = new E2EKeyResponder(aliceClient.getHomeserverUrl());
keyResponder.addCrossSigningData(SIGNED_CROSS_SIGNING_KEYS_DATA);
keyResponder.addDeviceKeys(SIGNED_TEST_DEVICE_DATA);
keyResponder.addKeyReceiver(BOB_TEST_USER_ID, keyReceiver);
@@ -2953,4 +2939,180 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("crypto (%s)", (backend: string,
expect(hasCrossSigningKeysForUser).toBe(false);
});
});
/** Guards against downgrade attacks from servers hiding or manipulating the crypto settings. */
describe("Persistent encryption settings", () => {
let persistentStoreClient: MatrixClient;
let client2: MatrixClient;
beforeEach(async () => {
const homeserverurl = "https://alice-server.com";
const userId = "@alice:localhost";
const keyResponder = new E2EKeyResponder(homeserverurl);
keyResponder.addKeyReceiver(userId, keyReceiver);
// For legacy crypto, these tests only work properly with a proper (indexeddb-based) CryptoStore, so
// rather than using the existing `aliceClient`, create a new client. Once we drop legacy crypto, we can
// just use `aliceClient` here.
persistentStoreClient = await makeNewClient(homeserverurl, userId, "persistentStoreClient");
await persistentStoreClient.startClient({});
});
afterEach(async () => {
persistentStoreClient.stopClient();
client2?.stopClient();
});
test("Sending a message in a room where the server is hiding the state event does not send a plaintext event", async () => {
// Alice is in an encrypted room
const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2" });
syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState]));
await syncPromise(persistentStoreClient);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([persistentStoreClient.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]);
// We now replace the client, and allow the new one to resync, *without* the encryption event.
client2 = await replaceClient(persistentStoreClient);
syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([]));
await client2.startClient({});
await syncPromise(client2);
logger.log(client2.getUserId() + ": restarted");
await expectSendMessageToFail(client2);
});
test("Changes to the rotation period should be ignored", async () => {
// Alice is in an encrypted room, where the rotation period is set to 2 messages
const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 2 });
syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState]));
await syncPromise(persistentStoreClient);
// Send a message, and expect to get an `m.room.encrypted` event.
const [, msg1Content] = await Promise.all([
persistentStoreClient.sendTextMessage(ROOM_ID, "test1"),
expectEncryptedSendMessage(),
]);
// Replace the state with one which bumps the rotation period. This should be ignored, though it's not
// clear that is correct behaviour (see https://github.com/element-hq/element-meta/issues/69)
const encryptionState2 = mkEncryptionEvent({
algorithm: "m.megolm.v1.aes-sha2",
rotation_period_msgs: 100,
});
syncResponder.sendOrQueueSyncResponse({
next_batch: "1",
rooms: { join: { [TEST_ROOM_ID]: { timeline: { events: [encryptionState2], prev_batch: "" } } } },
});
await syncPromise(persistentStoreClient);
// Send two more messages. The first should use the same megolm session as the first; the second should
// use a different one.
const [, msg2Content] = await Promise.all([
persistentStoreClient.sendTextMessage(ROOM_ID, "test2"),
expectEncryptedSendMessage(),
]);
expect(msg2Content.session_id).toEqual(msg1Content.session_id);
const [, msg3Content] = await Promise.all([
persistentStoreClient.sendTextMessage(ROOM_ID, "test3"),
expectEncryptedSendMessage(),
]);
expect(msg3Content.session_id).not.toEqual(msg1Content.session_id);
});
test("Changes to the rotation period should be ignored after a client restart", async () => {
// Alice is in an encrypted room, where the rotation period is set to 2 messages
const encryptionState = mkEncryptionEvent({ algorithm: "m.megolm.v1.aes-sha2", rotation_period_msgs: 2 });
syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState]));
await syncPromise(persistentStoreClient);
// Send a message, and expect to get an `m.room.encrypted` event.
await Promise.all([persistentStoreClient.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]);
// We now replace the client, and allow the new one to resync with a *different* encryption event.
client2 = await replaceClient(persistentStoreClient);
const encryptionState2 = mkEncryptionEvent({
algorithm: "m.megolm.v1.aes-sha2",
rotation_period_msgs: 100,
});
syncResponder.sendOrQueueSyncResponse(getSyncResponseWithState([encryptionState2]));
await client2.startClient({});
await syncPromise(client2);
logger.log(client2.getUserId() + ": restarted");
// Now send another message, which should (for now) be rejected.
await expectSendMessageToFail(client2);
});
/** Shut down `oldClient`, and build a new MatrixClient for the same user. */
async function replaceClient(oldClient: MatrixClient) {
oldClient.stopClient();
syncResponder.sendOrQueueSyncResponse({}); // flush pending request from old client
return makeNewClient(oldClient.getHomeserverUrl(), oldClient.getSafeUserId(), "client2");
}
async function makeNewClient(
homeserverUrl: string,
userId: string,
loggerPrefix: string,
): Promise<MatrixClient> {
const client = createClient({
baseUrl: homeserverUrl,
userId: userId,
accessToken: "akjgkrgjs",
deviceId: "xzcvb",
cryptoCallbacks: createCryptoCallbacks(),
logger: logger.getChild(loggerPrefix),
// For legacy crypto, these tests only work with a proper persistent cryptoStore.
cryptoStore: new IndexedDBCryptoStore(indexedDB, "test"),
});
await initCrypto(client);
mockInitialApiRequests(client.getHomeserverUrl());
return client;
}
function mkEncryptionEvent(content: Object) {
return mkEventCustom({
sender: persistentStoreClient.getSafeUserId(),
type: "m.room.encryption",
state_key: "",
content: content,
});
}
/** Sync response which includes `TEST_ROOM_ID`, where alice is a member
*
* @param stateEvents - Additional state events for the test room
*/
function getSyncResponseWithState(stateEvents: Array<Object>) {
const roomResponse = {
state: {
events: [
mkMembershipCustom({ membership: "join", sender: persistentStoreClient.getSafeUserId() }),
...stateEvents,
],
},
timeline: {
events: [],
prev_batch: "",
},
};
return {
next_batch: "1",
rooms: { join: { [TEST_ROOM_ID]: roomResponse } },
};
}
/** Send a message with the given client, and check that it is not sent in plaintext */
async function expectSendMessageToFail(aliceClient2: MatrixClient) {
// The precise failure mode here is somewhat up for debate (https://github.com/element-hq/element-meta/issues/69).
// For now, the attempt to send is rejected with an exception. The text is different between old and new stacks.
await expect(aliceClient2.sendTextMessage(ROOM_ID, "test")).rejects.toThrow(
/unconfigured room !room:id|Room !room:id was previously configured to use encryption/,
);
}
});
});
+333 -25
View File
@@ -17,8 +17,18 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import { Mocked } from "jest-mock";
import { createClient, CryptoEvent, ICreateClientOpts, MatrixClient, TypedEventEmitter } from "../../../src";
import {
createClient,
CryptoApi,
CryptoEvent,
ICreateClientOpts,
IEvent,
IMegolmSessionData,
MatrixClient,
TypedEventEmitter,
} from "../../../src";
import { SyncResponder } from "../../test-utils/SyncResponder";
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver";
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder";
@@ -31,9 +41,10 @@ import {
syncPromise,
} from "../../test-utils/test-utils";
import * as testData from "../../test-utils/test-data";
import { KeyBackupInfo } from "../../../src/crypto-api/keybackup";
import { KeyBackupInfo, KeyBackupSession } from "../../../src/crypto-api/keybackup";
import { IKeyBackup } from "../../../src/crypto/backup";
import { flushPromises } from "../../test-utils/flushPromises";
import { defer, IDeferred } from "../../../src/utils";
const ROOM_ID = testData.TEST_ROOM_ID;
@@ -118,7 +129,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
let e2eKeyResponder: E2EKeyResponder;
beforeEach(async () => {
jest.useFakeTimers();
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// anything that we don't have a specific matcher for silently returns a 404
fetchMock.catch(404);
@@ -285,17 +297,21 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
describe("recover from backup", () => {
it("can restore from backup (Curve25519 version)", async function () {
let aliceCrypto: CryptoApi;
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
});
it("can restore from backup (Curve25519 version)", async function () {
const fullBackup = {
rooms: {
[ROOM_ID]: {
@@ -339,17 +355,179 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
expect(afterCache.imported).toStrictEqual(1);
});
/**
* Creates a mock backup response of a GET `room_keys/keys` with a given number of keys per room.
* @param keysPerRoom The number of keys per room
*/
function createBackupDownloadResponse(keysPerRoom: number[]) {
const response: {
rooms: {
[roomId: string]: {
sessions: {
[sessionId: string]: KeyBackupSession;
};
};
};
} = { rooms: {} };
const expectedTotal = keysPerRoom.reduce((a, b) => a + b, 0);
for (let i = 0; i < keysPerRoom.length; i++) {
const roomId = `!room${i}:example.com`;
response.rooms[roomId] = { sessions: {} };
for (let j = 0; j < keysPerRoom[i]; j++) {
const sessionId = `session${j}`;
// Put the same fake session data, not important for that test
response.rooms[roomId].sessions[sessionId] = testData.CURVE25519_KEY_BACKUP_DATA;
}
}
return { response, expectedTotal };
}
it("Should import full backup in chunks", async function () {
const importMockImpl = jest.fn();
// @ts-ignore - mock a private method for testing purpose
aliceCrypto.importBackedUpRoomKeys = importMockImpl;
// We need several rooms with several sessions to test chunking
const { response, expectedTotal } = createBackupDownloadResponse([45, 300, 345, 12, 130]);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
const check = await aliceCrypto.checkKeyBackupAndEnable();
const progressCallback = jest.fn();
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
progressCallback,
},
);
expect(result.imported).toStrictEqual(expectedTotal);
// Should be called 5 times: 200*4 plus one chunk with the remaining 32
expect(importMockImpl).toHaveBeenCalledTimes(5);
for (let i = 0; i < 4; i++) {
expect(importMockImpl.mock.calls[i][0].length).toEqual(200);
}
expect(importMockImpl.mock.calls[4][0].length).toEqual(32);
expect(progressCallback).toHaveBeenCalledWith({
stage: "fetch",
});
// Should be called 4 times and report 200/400/600/800
for (let i = 0; i < 4; i++) {
expect(progressCallback).toHaveBeenCalledWith({
total: expectedTotal,
successes: (i + 1) * 200,
stage: "load_keys",
failures: 0,
});
}
// The last chunk
expect(progressCallback).toHaveBeenCalledWith({
total: expectedTotal,
successes: 832,
stage: "load_keys",
failures: 0,
});
});
it("Should continue to process backup if a chunk import fails and report failures", async function () {
// @ts-ignore - mock a private method for testing purpose
aliceCrypto.importBackedUpRoomKeys = jest
.fn()
.mockImplementationOnce(() => {
// Fail to import first chunk
throw new Error("test error");
})
// Ok for other chunks
.mockResolvedValue(undefined);
const { response, expectedTotal } = createBackupDownloadResponse([100, 300]);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
const check = await aliceCrypto.checkKeyBackupAndEnable();
const progressCallback = jest.fn();
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
{
progressCallback,
},
);
expect(result.total).toStrictEqual(expectedTotal);
// A chunk failed to import
expect(result.imported).toStrictEqual(200);
expect(progressCallback).toHaveBeenCalledWith({
total: expectedTotal,
successes: 0,
stage: "load_keys",
failures: 200,
});
expect(progressCallback).toHaveBeenCalledWith({
total: expectedTotal,
successes: 200,
stage: "load_keys",
failures: 200,
});
});
it("Should continue if some keys fails to decrypt", async function () {
// @ts-ignore - mock a private method for testing purpose
aliceCrypto.importBackedUpRoomKeys = jest.fn();
const decryptionFailureCount = 2;
const mockDecryptor = {
// DecryptSessions does not reject on decryption failure, but just skip the key
decryptSessions: jest.fn().mockImplementation((sessions) => {
// simulate fail to decrypt 2 keys out of all
const decrypted = [];
const keys = Object.keys(sessions);
for (let i = 0; i < keys.length - decryptionFailureCount; i++) {
decrypted.push({
session_id: keys[i],
} as unknown as Mocked<IMegolmSessionData>);
}
return decrypted;
}),
free: jest.fn(),
};
// @ts-ignore - mock a private method for testing purpose
aliceCrypto.getBackupDecryptor = jest.fn().mockResolvedValue(mockDecryptor);
const { response, expectedTotal } = createBackupDownloadResponse([100]);
fetchMock.get("express:/_matrix/client/v3/room_keys/keys", response);
const check = await aliceCrypto.checkKeyBackupAndEnable();
const result = await aliceClient.restoreKeyBackupWithRecoveryKey(
testData.BACKUP_DECRYPTION_KEY_BASE58,
undefined,
undefined,
check!.backupInfo!,
);
expect(result.total).toStrictEqual(expectedTotal);
// A chunk failed to import
expect(result.imported).toStrictEqual(expectedTotal - decryptionFailureCount);
});
it("recover specific session from backup", async function () {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
testData.CURVE25519_KEY_BACKUP_DATA,
@@ -370,16 +548,6 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
it("Fails on bad recovery key", async function () {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup
await waitForDeviceList();
await aliceCrypto.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
const fullBackup = {
rooms: {
[ROOM_ID]: {
@@ -888,6 +1056,146 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("megolm-keys backup (%s)", (backe
});
});
describe("Backup Changed from other sessions", () => {
beforeEach(async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// ignore requests to send room key requests
fetchMock.put("express:/_matrix/client/v3/sendToDevice/m.room_key_request/:request_id", {});
aliceClient = await initTestClient();
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
testData.SIGNED_BACKUP_DATA.version!,
);
// start after saving the private key
await aliceClient.startClient();
// tell Alice to trust the dummy device that signed the backup, and re-check the backup.
// XXX: should we automatically re-check after a device becomes verified?
await waitForDeviceList();
await aliceClient.getCrypto()!.setDeviceVerified(testData.TEST_USER_ID, testData.TEST_DEVICE_ID);
await aliceClient.getCrypto()!.checkKeyBackupAndEnable();
});
// let aliceClient: MatrixClient;
const SYNC_RESPONSE = {
next_batch: 1,
rooms: { join: { [ROOM_ID]: { timeline: { events: [testData.ENCRYPTED_EVENT] } } } },
};
it("If current backup has changed, the manager should switch to the new one on UTD", async () => {
// =====
// First ensure that the client checks for keys using the backup version 1
/// =====
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
(url, request) => {
// check that the version is correct
const version = new URLSearchParams(new URL(url).search).get("version");
if (version == "1") {
return testData.CURVE25519_KEY_BACKUP_DATA;
} else {
return {
status: 403,
body: {
current_version: "1",
errcode: "M_WRONG_ROOM_KEYS_VERSION",
error: "Wrong backup version.",
},
};
}
},
{ overwriteRoutes: true },
);
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the backup.
syncResponder.sendOrQueueSyncResponse(SYNC_RESPONSE);
await syncPromise(aliceClient);
const room = aliceClient.getRoom(ROOM_ID)!;
const event = room.getLiveTimeline().getEvents()[0];
await advanceTimersUntil(awaitDecryption(event, { waitOnDecryptionFailure: true }));
expect(event.getContent()).toEqual(testData.CLEAR_EVENT.content);
// =====
// Second suppose now that the backup has changed to version 2
/// =====
const newBackup = {
...testData.SIGNED_BACKUP_DATA,
version: "2",
};
fetchMock.get("path:/_matrix/client/v3/room_keys/version", newBackup, { overwriteRoutes: true });
// suppose the new key is now known
const aliceCrypto = aliceClient.getCrypto()!;
await aliceCrypto.storeSessionBackupPrivateKey(
Buffer.from(testData.BACKUP_DECRYPTION_KEY_BASE64, "base64"),
newBackup.version,
);
// A check backup should happen at some point
await aliceCrypto.checkKeyBackupAndEnable();
const awaitHasQueriedNewBackup: IDeferred<void> = defer<void>();
fetchMock.get(
"express:/_matrix/client/v3/room_keys/keys/:room_id/:session_id",
(url, request) => {
// check that the version is correct
const version = new URLSearchParams(new URL(url).search).get("version");
if (version == newBackup.version) {
awaitHasQueriedNewBackup.resolve();
return testData.CURVE25519_KEY_BACKUP_DATA;
} else {
// awaitHasQueriedOldBackup.resolve();
return {
status: 403,
body: {
current_version: "2",
errcode: "M_WRONG_ROOM_KEYS_VERSION",
error: "Wrong backup version.",
},
};
}
},
{ overwriteRoutes: true },
);
// Send Alice a message that she won't be able to decrypt, and check that she fetches the key from the new backup.
const newMessage: Partial<IEvent> = {
type: "m.room.encrypted",
room_id: "!room:id",
sender: "@alice:localhost",
content: {
algorithm: "m.megolm.v1.aes-sha2",
ciphertext:
"AwgAEpABKvf9FqPW52zeHfeVTn90a3jlBLlx7g6VDEkc2089RQUJoWpSJRiK13E83rN41wgGFJccyfoCr7ZDGJeuGYMGETTrgnLQhLs6JmyPf37JYkzxW8uS8rGUKEqTFQriKhibHVLvVacOlSIObUiKU/V3r176XuixqZF/4eyK9A22JNpInbgI10ZUT6LnApH9LR3FpZbE2zImf1uNPuvp7r0xQbW7CcJjqpH+qTPBD5zFdFnMkc2SnbXCsIOaX11Dm0krWfQz7iA26ZnI1nyZnyh7XPrCnJCRsuQH",
device_id: "WVMJGTSSVB",
sender_key: "E5RiY/YCIrHWaF4u416CqvblC6udK2jt9SJ/h1QeLS0",
session_id: "ybnW+LGdUhoS4fHm1DAEphukO3sZ1GCqZD7UQz7L+GA",
},
event_id: "$event2",
origin_server_ts: 1507753887000,
};
const nextSyncResponse = {
next_batch: 2,
rooms: { join: { [ROOM_ID]: { timeline: { events: [newMessage] } } } },
};
syncResponder.sendOrQueueSyncResponse(nextSyncResponse);
await syncPromise(aliceClient);
await awaitHasQueriedNewBackup.promise;
});
});
/** make sure that the client knows about the dummy device */
async function waitForDeviceList(): Promise<void> {
// Completing the initial sync will make the device list download outdated device lists (of which our own
+150 -1
View File
@@ -16,8 +16,12 @@ limitations under the License.
import "fake-indexeddb/auto";
import { IDBFactory } from "fake-indexeddb";
import fetchMock from "fetch-mock-jest";
import { createClient } from "../../../src";
import { createClient, CryptoEvent, IndexedDBCryptoStore } from "../../../src";
import { populateStore } from "../../test-utils/test_indexeddb_cryptostore_dump";
jest.setTimeout(15000);
afterEach(() => {
// reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -88,6 +92,151 @@ describe("MatrixClient.initRustCrypto", () => {
await matrixClient.initRustCrypto();
await matrixClient.initRustCrypto();
});
it("should migrate from libolm", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
auth_data: {
public_key: "q+HZiJdHl2Yopv9GGvv7EYSzDMrAiRknK4glSdoaomI",
signatures: {
"@vdhtest200713:matrix.org": {
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
"reDp6Mu+j+tfUL3/T6f5OBT3N825Lzpc43vvG+RvjX6V+KxXzodBQArgCoeEHLtL9OgSBmNrhTkSOX87MWCKAw",
"ed25519:KMFSTJSMLB":
"F8tyV5W6wNi0GXTdSg+gxSCULQi0EYxdAAqfkyNq58KzssZMw5i+PRA0aI2b+D7NH/aZaJrtiYNHJ0gWLSQvAw",
},
},
},
version: "7",
algorithm: "m.megolm_backup.v1.curve25519-aes-sha2",
etag: "1",
count: 79,
});
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
"@vdhtest200713:matrix.org": {
KMFSTJSMLB: {
algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"],
device_id: "KMFSTJSMLB",
keys: {
"curve25519:KMFSTJSMLB": "LKv0bKbc0EC4h0jknbemv3QalEkeYvuNeUXVRgVVTTU",
"ed25519:KMFSTJSMLB": "qK70DEqIXq7T+UU3v/al47Ab4JkMEBLpNrTBMbS5rrw",
},
user_id: "@vdhtest200713:matrix.org",
signatures: {
"@vdhtest200713:matrix.org": {
"ed25519:KMFSTJSMLB":
"aE+PdxLAdwQ/xfJwLmqebvt/lrT97fZas2SQFFrM+dPmHxQtjyS8csm88BLfGRjJKK1B/vWev3AaKqQZwLTUAw",
"ed25519:lDvg6vi3P80L9XFNpUSU+5Y87m3p6yHcC83jhSU4Q5k":
"lCd4SA/JT1nnxsgN9yQaLJQhH5hkLMVVx6ba5JAjL1wpWVqyPxzMJHImX6vTztk6S8rybcdfYkea5W/Ii+4HCQ",
},
},
},
},
},
master_keys: {
"@vdhtest200713:matrix.org": {
user_id: "@vdhtest200713:matrix.org",
usage: ["master"],
keys: {
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
"gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI",
},
signatures: {
"@vdhtest200713:matrix.org": {
"ed25519:MWOGVUTXZN":
"stOu1aHbhsWB/Aj5M/HqBR83QzME+682C995Uc8JxSmmyrlWmgG8QrnoUDG2OFR1t6zNQ+QLEilU4WNEOV73DQ",
},
},
},
},
self_signing_keys: {
"@vdhtest200713:matrix.org": {
user_id: "@vdhtest200713:matrix.org",
usage: ["self_signing"],
keys: {
"ed25519:lDvg6vi3P80L9XFNpUSU+5Y87m3p6yHcC83jhSU4Q5k":
"lDvg6vi3P80L9XFNpUSU+5Y87m3p6yHcC83jhSU4Q5k",
},
signatures: {
"@vdhtest200713:matrix.org": {
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
"HKTC7NoBhAkfJtmemmkn/HvCCgBQViWZ0uH7aGPRaWMDFgD8T7Q+y1j3FKZv4mhSopR85Fq3FRyXsG8OVvGeBA",
},
},
},
},
user_signing_keys: {
"@vdhtest200713:matrix.org": {
user_id: "@vdhtest200713:matrix.org",
usage: ["user_signing"],
keys: {
"ed25519:YShqO/3u5vQ0uucojraWrtoLrek0CYrurN/vH/YPMg8":
"YShqO/3u5vQ0uucojraWrtoLrek0CYrurN/vH/YPMg8",
},
signatures: {
"@vdhtest200713:matrix.org": {
"ed25519:gh9fGr39eNZUdWynEMJ/q/WZq/Pk/foFxHXFBFm18ZI":
"u8VOi4IaeRJwDgy2ftK02NJQPdBijy8f/0+WnHGG72yfOvMthwWzEw8SrRSNG8glBNrfHinKwCyJJzAJwyepCQ",
},
},
},
},
});
const testStoreName = "test-store";
await populateStore(testStoreName);
const cryptoStore = new IndexedDBCryptoStore(indexedDB, testStoreName);
const matrixClient = createClient({
baseUrl: "http://test.server",
userId: "@vdhtest200713:matrix.org",
deviceId: "KMFSTJSMLB",
cryptoStore,
pickleKey: "+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o",
});
const progressListener = jest.fn();
matrixClient.addListener(CryptoEvent.LegacyCryptoStoreMigrationProgress, progressListener);
await matrixClient.initRustCrypto();
const verificationStatus = await matrixClient
.getCrypto()!
.getDeviceVerificationStatus("@vdhtest200713:matrix.org", "KMFSTJSMLB");
// Check that the current device and identity trust is migrated correctly just after migration
expect(verificationStatus).toBeDefined();
expect(verificationStatus!.crossSigningVerified).toEqual(true);
expect(verificationStatus!.signedByOwner).toEqual(true);
// Do some basic checks on the imported data
const deviceKeys = await matrixClient.getCrypto()!.getOwnDeviceKeys();
expect(deviceKeys.curve25519).toEqual("LKv0bKbc0EC4h0jknbemv3QalEkeYvuNeUXVRgVVTTU");
expect(deviceKeys.ed25519).toEqual("qK70DEqIXq7T+UU3v/al47Ab4JkMEBLpNrTBMbS5rrw");
expect(await matrixClient.getCrypto()!.getActiveSessionBackupVersion()).toEqual("7");
expect(await matrixClient.getCrypto()!.isEncryptionEnabledInRoom("!CWLUCoEWXSFyTCOtfL:matrix.org")).toBe(true);
// check the progress callback
expect(progressListener.mock.calls.length).toBeGreaterThan(50);
// The first call should have progress == 0
const [firstProgress, totalSteps] = progressListener.mock.calls[0];
expect(totalSteps).toBeGreaterThan(3000);
expect(firstProgress).toEqual(0);
for (let i = 1; i < progressListener.mock.calls.length - 1; i++) {
const [progress, total] = progressListener.mock.calls[i];
expect(total).toEqual(totalSteps);
expect(progress).toBeGreaterThan(progressListener.mock.calls[i - 1][0]);
expect(progress).toBeLessThanOrEqual(totalSteps);
}
// The final call should have progress == total == -1
expect(progressListener).toHaveBeenLastCalledWith(-1, -1);
}, 60000);
});
describe("MatrixClient.clearStores", () => {
+11 -11
View File
@@ -85,7 +85,8 @@ import { encodeBase64 } from "../../../src/base64";
// The verification flows use javascript timers to set timeouts. We tell jest to use mock timer implementations
// to ensure that we don't end up with dangling timeouts.
jest.useFakeTimers();
// But the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
beforeAll(async () => {
// we use the libolm primitives in the test, so init the Olm library
@@ -743,6 +744,8 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
expect(toDeviceMessage.transaction_id).toEqual(transactionId);
expect(toDeviceMessage.code).toEqual("m.user");
expect(request.phase).toEqual(VerificationPhase.Cancelled);
expect(request.cancellationCode).toEqual("m.user");
expect(request.cancellingUserId).toEqual("@alice:localhost");
});
it("can cancel during the SAS phase", async () => {
@@ -1259,14 +1262,11 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
const requestId = await requestPromises.get("m.megolm_backup.v1");
const keyBackupIsCached = emitPromise(aliceClient, CryptoEvent.KeyBackupDecryptionKeyCached);
await sendBackupGossipAndExpectVersion(requestId!, BACKUP_DECRYPTION_KEY_BASE64, matchingBackupInfo);
// We are lacking a way to signal that the secret has been received, so we wait a bit..
jest.useRealTimers();
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
await keyBackupIsCached;
// the backup secret should be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
@@ -1288,7 +1288,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
@@ -1312,7 +1312,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
@@ -1337,7 +1337,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
@@ -1358,7 +1358,7 @@ describe.each(Object.entries(CRYPTO_BACKENDS))("verification (%s)", (backend: st
await new Promise((resolve) => {
setTimeout(resolve, 500);
});
jest.useFakeTimers();
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
// the backup secret should not be cached
const cachedKey = await aliceClient.getCrypto()!.getSessionBackupPrivateKey();
@@ -33,7 +33,7 @@ import {
import { logger } from "../../src/logger";
import { encodeParams, encodeUri, QueryDict, replaceParam } from "../../src/utils";
import { TestClient } from "../TestClient";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../src/models/thread";
import { FeatureSupport, Thread, ThreadEvent } from "../../src/models/thread";
import { emitPromise } from "../test-utils/test-utils";
import { Feature, ServerSupport } from "../../src/feature";
@@ -623,9 +623,7 @@ describe("MatrixClient event timelines", function () {
"GET",
"/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
@@ -1154,10 +1152,7 @@ describe("MatrixClient event timelines", function () {
httpBackend
.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name),
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(THREAD_ROOT_UPDATED.event_id!),
)
.respond(200, {
chunk: [THREAD_REPLY3.event, THREAD_REPLY2.event, THREAD_REPLY],
@@ -1262,11 +1257,8 @@ describe("MatrixClient event timelines", function () {
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT_UPDATED.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({
dir: Direction.Backward,
limit: 3,
recurse: true,
}),
)
@@ -1321,11 +1313,7 @@ describe("MatrixClient event timelines", function () {
function respondToThread(root: Partial<IEvent>, replies: Partial<IEvent>[]): ExpectedHttpRequest {
const request = httpBackend.when(
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(root.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
"?dir=b&limit=1",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" + encodeURIComponent(root.event_id!) + "?dir=b",
);
request.respond(200, function () {
return {
@@ -1567,7 +1555,7 @@ describe("MatrixClient event timelines", function () {
expect(threadIds).toContain(THREAD2_ROOT.event_id);
const [allThreads] = timelineSets!;
const timeline = allThreads.getLiveTimeline()!;
// Test threads are in chronological order
// Test threads are in chronological order (first thread should be first because it has a more recent reply)
expect(timeline.getEvents().map((it) => it.event.event_id)).toEqual([
THREAD_ROOT.event_id,
THREAD2_ROOT.event_id,
@@ -2034,9 +2022,7 @@ describe("MatrixClient event timelines", function () {
"GET",
"/_matrix/client/v1/rooms/!foo%3Abar/relations/" +
encodeURIComponent(THREAD_ROOT.event_id!) +
"/" +
encodeURIComponent(THREAD_RELATION_TYPE.name) +
buildRelationPaginationQuery({ dir: Direction.Backward, limit: 1 }),
buildRelationPaginationQuery({ dir: Direction.Backward }),
)
.respond(200, function () {
return {
@@ -28,32 +28,70 @@ import {
NotificationCountType,
RelationType,
Room,
fixNotificationCountOnDecryption,
} from "../../src";
import { TestClient } from "../TestClient";
import { ReceiptType } from "../../src/@types/read_receipts";
import { mkThread } from "../test-utils/thread";
import { SyncState } from "../../src/sync";
const userA = "@alice:localhost";
const userB = "@bob:localhost";
const selfUserId = userA;
const selfAccessToken = "aseukfgwef";
function setupTestClient(): [MatrixClient, HttpBackend] {
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
const httpBackend = testClient.httpBackend;
const client = testClient.client;
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
return [client, httpBackend];
}
describe("Notification count fixing", () => {
let client: MatrixClient | undefined;
beforeEach(() => {
[client] = setupTestClient();
});
it("doesn't increment notification count for events that can't be found in a room", async () => {
const roomId = "!room:localhost";
client!.startClient({ threadSupport: true });
const room = new Room(roomId, client!, selfUserId);
jest.spyOn(client!, "getRoom").mockImplementation((id) => (id === roomId ? room : null));
const event = new MatrixEvent({
room_id: roomId,
type: "m.reaction",
event_id: "$foo",
content: {
"m.relates_to": {
rel_type: RelationType.Annotation,
event_id: "$foo",
key: "x",
},
},
});
jest.spyOn(event, "getPushActions").mockReturnValue({
notify: true,
tweaks: {},
});
fixNotificationCountOnDecryption(client!, event);
expect(room.getUnreadNotificationCount(NotificationCountType.Total)).toBe(0);
});
});
describe("MatrixClient syncing", () => {
const userA = "@alice:localhost";
const userB = "@bob:localhost";
const selfUserId = userA;
const selfAccessToken = "aseukfgwef";
let client: MatrixClient | undefined;
let httpBackend: HttpBackend | undefined;
const setupTestClient = (): [MatrixClient, HttpBackend] => {
const testClient = new TestClient(selfUserId, "DEVICE", selfAccessToken);
const httpBackend = testClient.httpBackend;
const client = testClient.client;
httpBackend!.when("GET", "/versions").respond(200, {});
httpBackend!.when("GET", "/pushrules").respond(200, {});
httpBackend!.when("POST", "/filter").respond(200, { filter_id: "a filter id" });
return [client, httpBackend];
};
beforeEach(() => {
[client, httpBackend] = setupTestClient();
});
+108
View File
@@ -0,0 +1,108 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import { MockOptionsMethodPut } from "fetch-mock";
import { ISyncResponder } from "./SyncResponder";
/**
* An object which intercepts `account_data` get and set requests via fetch-mock.
*/
export class AccountDataAccumulator {
/**
* The account data events to be returned by the sync.
* Will be updated when fetchMock intercepts calls to PUT `/_matrix/client/v3/user/:userId/account_data/`.
* Will be used by `sendSyncResponseWithUpdatedAccountData`
*/
public accountDataEvents: Map<String, any> = new Map();
/**
* Intercept requests to set a particular type of account data.
*
* Once it is set, its data is stored (for future return by `interceptGetAccountData` etc) and the resolved promise is
* resolved.
*
* @param accountDataType - type of account data to be intercepted
* @param opts - options to pass to fetchMock
* @returns a Promise which will resolve (with the content of the account data) once it is set.
*/
public interceptSetAccountData(accountDataType: string, opts?: MockOptionsMethodPut): Promise<any> {
return new Promise((resolve) => {
// Called when the cross signing key is uploaded
fetchMock.put(
`express:/_matrix/client/v3/user/:userId/account_data/${accountDataType}`,
(url: string, options: RequestInit) => {
const content = JSON.parse(options.body as string);
const type = url.split("/").pop();
// update account data for sync response
this.accountDataEvents.set(type!, content);
resolve(content);
return {};
},
opts,
);
});
}
/**
* Intercept all requests to get account data
*/
public interceptGetAccountData(): void {
fetchMock.get(
`express:/_matrix/client/v3/user/:userId/account_data/:type`,
(url) => {
const type = url.split("/").pop();
const existing = this.accountDataEvents.get(type!);
if (existing) {
// return it
return {
status: 200,
body: existing,
};
} else {
// 404
return {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
};
}
},
{ overwriteRoutes: true },
);
}
/**
* Send a sync response the current account data events.
*/
public sendSyncResponseWithUpdatedAccountData(syncResponder: ISyncResponder): void {
try {
syncResponder.sendOrQueueSyncResponse({
next_batch: 1,
account_data: {
events: Array.from(this.accountDataEvents, ([type, content]) => ({
type: type,
content: content,
})),
},
});
} catch (err) {
// Might fail with "Cannot queue more than one /sync response" if called too often.
// It's ok if it fails here, the sync response is cumulative and will contain
// the latest account data.
}
}
}
+15 -5
View File
@@ -24,11 +24,21 @@ import { KeyBackupInfo } from "../../src/crypto-api";
* @param homeserverUrl - the homeserver url for the client under test
*/
export function mockInitialApiRequests(homeserverUrl: string) {
fetchMock.getOnce(new URL("/_matrix/client/versions", homeserverUrl).toString(), { versions: ["v1.1"] });
fetchMock.getOnce(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
fetchMock.postOnce(new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(), {
filter_id: "fid",
});
fetchMock.getOnce(
new URL("/_matrix/client/versions", homeserverUrl).toString(),
{ versions: ["v1.1"] },
{ overwriteRoutes: true },
);
fetchMock.getOnce(
new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(),
{},
{ overwriteRoutes: true },
);
fetchMock.postOnce(
new URL("/_matrix/client/v3/user/%40alice%3Alocalhost/filter", homeserverUrl).toString(),
{ filter_id: "fid" },
{ overwriteRoutes: true },
);
}
/**
+2 -4
View File
@@ -14,8 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { OidcClientConfig } from "../../src";
import { ValidatedIssuerMetadata } from "../../src/oidc/validate";
import { OidcClientConfig, ValidatedIssuerMetadata } from "../../src";
/**
* Makes a valid OidcClientConfig with minimum valid values
@@ -26,8 +25,7 @@ export const makeDelegatedAuthConfig = (issuer = "https://auth.org/"): OidcClien
const metadata = mockOpenIdConfiguration(issuer);
return {
issuer,
account: issuer + "account",
accountManagementEndpoint: issuer + "account",
registrationEndpoint: metadata.registration_endpoint,
authorizationEndpoint: metadata.authorization_endpoint,
tokenEndpoint: metadata.token_endpoint,
@@ -0,0 +1,53 @@
## Dump of libolm indexeddb cryptostore
This directory contains a dump of a real indexeddb store from a session using
libolm crypto.
The corresponding pickle key is `+1k2Ppd7HIisUY824v7JtV3/oEE4yX0TqtmNPyhaD7o`.
It was created by pasting the following into the browser console:
```javascript
async function exportIndexedDb(name) {
const db = await new Promise((resolve, reject) => {
const dbReq = indexedDB.open(name);
dbReq.onerror = reject;
dbReq.onsuccess = () => resolve(dbReq.result);
});
const storeNames = db.objectStoreNames;
const exports = {};
for (const store of storeNames) {
exports[store] = [];
const txn = db.transaction(store, "readonly");
const objectStore = txn.objectStore(store);
await new Promise((resolve, reject) => {
const cursorReq = objectStore.openCursor();
cursorReq.onerror = reject;
cursorReq.onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const entry = { value: cursor.value };
if (!objectStore.keyPath) {
entry.key = cursor.key;
}
exports[store].push(entry);
cursor.continue();
} else {
resolve();
}
};
});
}
return exports;
}
window.saveAs(
new Blob([JSON.stringify(await exportIndexedDb("matrix-js-sdk:crypto"), null, 2)], {
type: "application/json;charset=utf-8",
}),
"dump.json",
);
```
The pickle key is extracted via `mxMatrixClientPeg.get().crypto.olmDevice.pickleKey`.
File diff suppressed because one or more lines are too long
@@ -0,0 +1,136 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { readFile } from "node:fs/promises";
import { resolve } from "node:path";
/**
* Populate an IndexedDB store with the test data from this directory.
*
* @param name - Name of the IndexedDB database to create.
*/
export async function populateStore(name: string): Promise<IDBDatabase> {
const req = indexedDB.open(name, 11);
const db = await new Promise<IDBDatabase>((resolve, reject) => {
req.onupgradeneeded = (ev): void => {
const db = req.result;
const oldVersion = ev.oldVersion;
upgradeDatabase(oldVersion, db);
};
req.onerror = (ev): void => {
reject(req.error);
};
req.onsuccess = (): void => {
const db = req.result;
resolve(db);
};
});
await importData(db);
return db;
}
/** Create the schema for the indexed db store */
function upgradeDatabase(oldVersion: number, db: IDBDatabase) {
if (oldVersion < 1) {
const outgoingRoomKeyRequestsStore = db.createObjectStore("outgoingRoomKeyRequests", { keyPath: "requestId" });
outgoingRoomKeyRequestsStore.createIndex("session", ["requestBody.room_id", "requestBody.session_id"]);
outgoingRoomKeyRequestsStore.createIndex("state", "state");
}
if (oldVersion < 2) {
db.createObjectStore("account");
}
if (oldVersion < 3) {
const sessionsStore = db.createObjectStore("sessions", { keyPath: ["deviceKey", "sessionId"] });
sessionsStore.createIndex("deviceKey", "deviceKey");
}
if (oldVersion < 4) {
db.createObjectStore("inbound_group_sessions", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 5) {
db.createObjectStore("device_data");
}
if (oldVersion < 6) {
db.createObjectStore("rooms");
}
if (oldVersion < 7) {
db.createObjectStore("sessions_needing_backup", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 8) {
db.createObjectStore("inbound_group_sessions_withheld", { keyPath: ["senderCurve25519Key", "sessionId"] });
}
if (oldVersion < 9) {
const problemsStore = db.createObjectStore("session_problems", { keyPath: ["deviceKey", "time"] });
problemsStore.createIndex("deviceKey", "deviceKey");
db.createObjectStore("notified_error_devices", { keyPath: ["userId", "deviceId"] });
}
if (oldVersion < 10) {
db.createObjectStore("shared_history_inbound_group_sessions", { keyPath: ["roomId"] });
}
if (oldVersion < 11) {
db.createObjectStore("parked_shared_history", { keyPath: ["roomId"] });
}
}
async function importData(db: IDBDatabase) {
const path = resolve("spec/test-utils/test_indexeddb_cryptostore_dump/dump.json");
const json: Record<string, Array<{ key?: any; value: any }>> = JSON.parse(
await readFile(path, { encoding: "utf8" }),
);
for (const [storeName, data] of Object.entries(json)) {
await new Promise((resolve, reject) => {
const store = db.transaction(storeName, "readwrite").objectStore(storeName);
function putEntry(idx: number) {
if (idx >= data.length) {
resolve(undefined);
return;
}
const { key, value } = data[idx];
try {
const putReq = store.put(value, key);
putReq.onsuccess = (_) => putEntry(idx + 1);
putReq.onerror = (_) => reject(putReq.error);
} catch (e) {
throw new Error(
`Error populating '${storeName}' with key ${JSON.stringify(key)}, value ${JSON.stringify(
value,
)}: ${e}`,
);
}
}
putEntry(0);
});
}
}
+20
View File
@@ -161,3 +161,23 @@ export const mkThread = ({
return { thread, rootEvent, events };
};
/**
* Create a thread, and make sure the events are added to the thread and the
* room's timeline as if they came in via sync.
*
* Note that mkThread doesn't actually add the events properly to the room.
*/
export const populateThread = ({
room,
client,
authorId,
participantUserIds,
length = 2,
ts = 1,
}: MakeThreadProps): MakeThreadResult => {
const ret = mkThread({ room, client, authorId, participantUserIds, length, ts });
ret.thread.initialEventsFetched = true;
room.addLiveEvents(ret.events);
return ret;
};
+23 -5
View File
@@ -269,7 +269,11 @@ export class MockRTCRtpTransceiver {
}
export class MockMediaStreamTrack {
constructor(public readonly id: string, public readonly kind: "audio" | "video", public enabled = true) {}
constructor(
public readonly id: string,
public readonly kind: "audio" | "video",
public enabled = true,
) {}
public stop = jest.fn<void, []>();
@@ -306,7 +310,10 @@ export class MockMediaStreamTrack {
// XXX: Using EventTarget in jest doesn't seem to work, so we write our own
// implementation
export class MockMediaStream {
constructor(public id: string, private tracks: MockMediaStreamTrack[] = []) {}
constructor(
public id: string,
private tracks: MockMediaStreamTrack[] = [],
) {}
public listeners: [string, (...args: any[]) => any][] = [];
public isStopped = false;
@@ -435,7 +442,11 @@ type EmittedEventMap = CallEventHandlerEventHandlerMap &
export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, EmittedEventMap> {
public mediaHandler = new MockMediaHandler();
constructor(public userId: string, public deviceId: string, public sessionId: string) {
constructor(
public userId: string,
public deviceId: string,
public sessionId: string,
) {
super();
}
@@ -502,7 +513,10 @@ export class MockCallMatrixClient extends TypedEventEmitter<EmittedEvents, Emitt
}
export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandlerMap> {
constructor(public roomId: string, public groupCallId?: string) {
constructor(
public roomId: string,
public groupCallId?: string,
) {
super();
}
@@ -550,7 +564,11 @@ export class MockMatrixCall extends TypedEventEmitter<CallEvent, CallEventHandle
}
export class MockCallFeed {
constructor(public userId: string, public deviceId: string | undefined, public stream: MockMediaStream) {}
constructor(
public userId: string,
public deviceId: string | undefined,
public stream: MockMediaStream,
) {}
public measureVolumeActivity(val: boolean) {}
public dispose() {}
+11 -108
View File
@@ -15,13 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import fetchMock from "fetch-mock-jest";
import MockHttpBackend from "matrix-mock-request";
import { AutoDiscoveryAction, M_AUTHENTICATION } from "../../src";
import { AutoDiscoveryAction } from "../../src";
import { AutoDiscovery } from "../../src/autodiscovery";
import { OidcError } from "../../src/oidc/error";
import { makeDelegatedAuthConfig } from "../test-utils/oidc";
// keep to reset the fetch function after using MockHttpBackend
// @ts-ignore private property
@@ -351,7 +348,7 @@ describe("AutoDiscovery", function () {
function () {
const httpBackend = getHttpBackend();
httpBackend.when("GET", "/_matrix/client/versions").respond(200, {
not_matrix_versions: ["v1.1"],
not_matrix_versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -388,7 +385,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -409,10 +406,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -428,7 +421,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -450,10 +443,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -469,16 +458,13 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
invalid: true,
},
});
return Promise.all([
httpBackend.flushAllExpected(),
@@ -494,10 +480,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: null,
},
"m.authentication": {
state: "FAIL_ERROR",
error: OidcError.Misconfigured,
},
};
expect(conf).toEqual(expected);
@@ -515,7 +497,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -560,7 +542,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
"m.homeserver": {
@@ -606,7 +588,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(404, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -653,7 +635,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend.when("GET", "/_matrix/identity/v2").respond(500, {});
httpBackend.when("GET", "/.well-known/matrix/client").respond(200, {
@@ -697,7 +679,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -728,10 +710,6 @@ describe("AutoDiscovery", function () {
error: null,
base_url: "https://identity.example.org",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -747,7 +725,7 @@ describe("AutoDiscovery", function () {
expect(req.path).toEqual("https://chat.example.org/_matrix/client/versions");
})
.respond(200, {
versions: ["v1.1"],
versions: ["v1.5"],
});
httpBackend
.when("GET", "/_matrix/identity/v2")
@@ -784,10 +762,6 @@ describe("AutoDiscovery", function () {
"org.example.custom.property": {
cupcakes: "yes",
},
"m.authentication": {
state: "IGNORE",
error: OidcError.NotSupported,
},
};
expect(conf).toEqual(expected);
@@ -897,75 +871,4 @@ describe("AutoDiscovery", function () {
}),
]);
});
describe("m.authentication", () => {
const homeserverName = "example.org";
const homeserverUrl = "https://chat.example.org/";
const issuer = "https://auth.org/";
beforeAll(() => {
// make these tests independent from fetch mocking above
AutoDiscovery.setFetchFn(realAutoDiscoveryFetch);
});
beforeEach(() => {
fetchMock.resetBehavior();
fetchMock.get(`${homeserverUrl}_matrix/client/versions`, { versions: ["v1.1"] });
fetchMock.get("https://example.org/.well-known/matrix/client", {
"m.homeserver": {
// Note: we also expect this test to trim the trailing slash
base_url: "https://chat.example.org/",
},
"m.authentication": {
issuer,
},
});
});
it("should return valid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.SUCCESS,
...config,
signingKeys: [],
account: undefined,
error: null,
});
});
it("should set state to error for invalid authentication configuration", async () => {
const config = makeDelegatedAuthConfig(issuer);
// authorization_code is required
config.metadata.grant_types_supported = ["openid"];
fetchMock.get(`${config.metadata.issuer}.well-known/openid-configuration`, config.metadata);
fetchMock.get(`${config.metadata.issuer}jwks`, {
status: 200,
headers: {
"Content-Type": "application/json",
},
keys: [],
});
const result = await AutoDiscovery.findClientConfig(homeserverName);
expect(result[M_AUTHENTICATION.stable!]).toEqual({
state: AutoDiscovery.FAIL_ERROR,
error: OidcError.OpSupport,
});
});
});
});
+15
View File
@@ -37,6 +37,21 @@ describe("ContentRepo", function () {
);
});
it("should allow redirects when requested on download URLs", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, undefined, undefined, undefined, false, true)).toEqual(
baseUrl + "/_matrix/media/v3/download/server.name/resourceid?allow_redirect=true",
);
});
it("should allow redirects when requested on thumbnail URLs", function () {
const mxcUri = "mxc://server.name/resourceid";
expect(getHttpUriForMxc(baseUrl, mxcUri, 32, 32, "scale", false, true)).toEqual(
baseUrl +
"/_matrix/media/v3/thumbnail/server.name/resourceid?width=32&height=32&method=scale&allow_redirect=true",
);
});
it("should return the empty string for null input", function () {
expect(getHttpUriForMxc(null as any, "")).toEqual("");
});
+6 -22
View File
@@ -356,7 +356,6 @@ describe("Crypto", function () {
let crypto: Crypto;
let mockBaseApis: MatrixClient;
let mockRoomList: RoomList;
let fakeEmitter: EventEmitter;
@@ -390,19 +389,10 @@ describe("Crypto", function () {
isGuest: jest.fn(),
emit: jest.fn(),
} as unknown as MatrixClient;
mockRoomList = {} as unknown as RoomList;
fakeEmitter = new EventEmitter();
crypto = new Crypto(
mockBaseApis,
"@alice:home.server",
"FLIBBLE",
clientStore,
cryptoStore,
mockRoomList,
[],
);
crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
crypto.registerEventHandlers(fakeEmitter as any);
await crypto.init();
});
@@ -1273,7 +1263,7 @@ describe("Crypto", function () {
({
init_with_private_key: jest.fn(),
free,
} as unknown as PkDecryption),
}) as unknown as PkDecryption,
);
client.client.checkSecretStoragePrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
@@ -1299,7 +1289,7 @@ describe("Crypto", function () {
({
init_with_seed: jest.fn(),
free,
} as unknown as PkSigning),
}) as unknown as PkSigning,
);
client.client.checkCrossSigningPrivateKey(new Uint8Array(), "");
expect(free).toHaveBeenCalled();
@@ -1341,15 +1331,9 @@ describe("Crypto", function () {
setRoomEncryption: jest.fn().mockResolvedValue(undefined),
} as unknown as RoomList;
crypto = new Crypto(
mockClient,
"@alice:home.server",
"FLIBBLE",
clientStore,
cryptoStore,
mockRoomList,
[],
);
crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []);
// @ts-ignore we are injecting a mock into a private property
crypto.roomList = mockRoomList;
});
it("should set the algorithm if called for a known room", async () => {
+2 -2
View File
@@ -106,7 +106,7 @@ describe("Cross Signing", function () {
});
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T>() => ({} as T);
alice.getAccountDataFromServer = async <T>() => ({}) as T;
// set Alice's cross-signing key
await alice.bootstrapCrossSigning({
authUploadDeviceSigningKeys: async (func) => {
@@ -146,7 +146,7 @@ describe("Cross Signing", function () {
};
alice.uploadKeySignatures = async () => ({ failures: {} });
alice.setAccountData = async () => ({});
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({} as T);
alice.getAccountDataFromServer = async <T extends { [k: string]: any }>(): Promise<T | null> => ({}) as T;
const authUploadDeviceSigningKeys: BootstrapCrossSigningOpts["authUploadDeviceSigningKeys"] = async (func) => {
await func({});
};
+1 -3
View File
@@ -33,12 +33,10 @@ export async function resetCrossSigningKeys(
export async function createSecretStorageKey(): Promise<IRecoveryKey> {
const decryption = new global.Olm.PkDecryption();
const storagePublicKey = decryption.generate_key();
decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
decryption.free();
return {
// `pubkey` not used anymore with symmetric 4S
keyInfo: { pubkey: storagePublicKey, key: undefined! },
privateKey: storagePrivateKey,
};
}
+1 -7
View File
@@ -190,10 +190,7 @@ describe("Secrets", function () {
};
resetCrossSigningKeys(alice);
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, {
pubkey: undefined,
key: undefined,
});
const { keyId: newKeyId } = await alice.addSecretStorageKey(SECRET_STORAGE_ALGORITHM_V1_AES, { key });
// we don't await on this because it waits for the event to come down the sync
// which won't happen in the test setup
alice.setDefaultSecretStorageKeyId(newKeyId);
@@ -335,7 +332,6 @@ describe("Secrets", function () {
it("bootstraps when cross-signing keys in secret storage", async function () {
const decryption = new global.Olm.PkDecryption();
const storagePublicKey = decryption.generate_key();
const storagePrivateKey = decryption.get_private_key();
const bob: MatrixClient = await makeTestClient(
@@ -378,8 +374,6 @@ describe("Secrets", function () {
});
await bob.bootstrapSecretStorage({
createSecretStorageKey: async () => ({
// `pubkey` not used anymore with symmetric 4S
keyInfo: { pubkey: storagePublicKey },
privateKey: storagePrivateKey,
}),
});
+226
View File
@@ -0,0 +1,226 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import "jest-localstorage-mock";
import { IndexedDBCryptoStore, LocalStorageCryptoStore, MemoryCryptoStore } from "../../../../src";
import { CryptoStore, MigrationState, SESSION_BATCH_SIZE } from "../../../../src/crypto/store/base";
describe.each([
["IndexedDBCryptoStore", () => new IndexedDBCryptoStore(global.indexedDB, "tests")],
["LocalStorageCryptoStore", () => new LocalStorageCryptoStore(localStorage)],
["MemoryCryptoStore", () => new MemoryCryptoStore()],
])("CryptoStore tests for %s", function (name, dbFactory) {
let store: CryptoStore;
beforeEach(async () => {
store = dbFactory();
});
describe("containsData", () => {
it("returns false at first", async () => {
expect(await store.containsData()).toBe(false);
});
it("returns true after startup and account setup", async () => {
await store.startup();
await store.doTxn("readwrite", [IndexedDBCryptoStore.STORE_ACCOUNT], (txn) => {
store.storeAccount(txn, "not a real account");
});
expect(await store.containsData()).toBe(true);
});
});
describe("migrationState", () => {
beforeEach(async () => {
await store.startup();
});
it("returns 0 at first", async () => {
expect(await store.getMigrationState()).toEqual(MigrationState.NOT_STARTED);
});
it("stores updates", async () => {
await store.setMigrationState(MigrationState.INITIAL_DATA_MIGRATED);
expect(await store.getMigrationState()).toEqual(MigrationState.INITIAL_DATA_MIGRATED);
});
});
describe("get/delete EndToEndSessionsBatch", () => {
beforeEach(async () => {
await store.startup();
});
it("returns null at first", async () => {
expect(await store.getEndToEndSessionsBatch()).toBe(null);
});
it("returns a batch of sessions", async () => {
// First store some sessions in the db
const N_DEVICES = 6;
const N_SESSIONS_PER_DEVICE = 6;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
let nSessions = 0;
await store.doTxn("readonly", [IndexedDBCryptoStore.STORE_SESSIONS], (txn) =>
store.countEndToEndSessions(txn, (n) => (nSessions = n)),
);
expect(nSessions).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
// Then, get a batch and check it looks right.
const batch = await store.getEndToEndSessionsBatch();
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
for (let i = 0; i < N_DEVICES; i++) {
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
const r = batch![i * N_DEVICES + j];
expect(r.deviceKey).toEqual(`device${i}`);
expect(r.sessionId).toEqual(`session${j}`);
}
}
});
it("returns another batch of sessions after the first batch is deleted", async () => {
// First store some sessions in the db
const N_DEVICES = 8;
const N_SESSIONS_PER_DEVICE = 8;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Get the first batch
const batch = (await store.getEndToEndSessionsBatch())!;
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndSessionsBatch(batch);
// Fetch a second batch
const batch2 = (await store.getEndToEndSessionsBatch())!;
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndSessionsBatch(batch2);
// the batch should now be null.
expect(await store.getEndToEndSessionsBatch()).toBe(null);
});
/** Create a bunch of fake Olm sessions and stash them in the DB. */
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_SESSIONS, (txn) => {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndSession(
`device${i}`,
`session${j}`,
{
deviceKey: `device${i}`,
sessionId: `session${j}`,
},
txn,
);
}
}
});
}
});
describe("get/delete EndToEndInboundGroupSessionsBatch", () => {
beforeEach(async () => {
await store.startup();
});
it("returns null at first", async () => {
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
});
it("returns a batch of sessions", async () => {
const N_DEVICES = 6;
const N_SESSIONS_PER_DEVICE = 6;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Mark one of the sessions as needing backup
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_BACKUP, async (txn) => {
await store.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }], txn);
});
expect(await store.countEndToEndInboundGroupSessions()).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
const batch = await store.getEndToEndInboundGroupSessionsBatch();
expect(batch!.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE);
for (let i = 0; i < N_DEVICES; i++) {
for (let j = 0; j < N_SESSIONS_PER_DEVICE; j++) {
const r = batch![i * N_DEVICES + j];
expect(r.senderKey).toEqual(pad43(`device${i}`));
expect(r.sessionId).toEqual(`session${j}`);
// only the last session needs backup
expect(r.needsBackup).toBe(i === 5 && j === 5);
}
}
});
it("returns another batch of sessions after the first batch is deleted", async () => {
// First store some sessions in the db
const N_DEVICES = 8;
const N_SESSIONS_PER_DEVICE = 8;
await createSessions(N_DEVICES, N_SESSIONS_PER_DEVICE);
// Get the first batch
const batch = (await store.getEndToEndInboundGroupSessionsBatch())!;
expect(batch.length).toEqual(SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndInboundGroupSessionsBatch(batch);
// Fetch a second batch
const batch2 = (await store.getEndToEndInboundGroupSessionsBatch())!;
expect(batch2.length).toEqual(N_DEVICES * N_SESSIONS_PER_DEVICE - SESSION_BATCH_SIZE);
// ... and delete.
await store.deleteEndToEndInboundGroupSessionsBatch(batch2);
// the batch should now be null.
expect(await store.getEndToEndInboundGroupSessionsBatch()).toBe(null);
});
/** Create a bunch of fake megolm sessions and stash them in the DB. */
async function createSessions(nDevices: number, nSessionsPerDevice: number) {
await store.doTxn("readwrite", IndexedDBCryptoStore.STORE_INBOUND_GROUP_SESSIONS, (txn) => {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndInboundGroupSession(
pad43(`device${i}`),
`session${j}`,
{
forwardingCurve25519KeyChain: [],
keysClaimed: {},
room_id: "",
session: "",
},
txn,
);
}
}
});
}
});
});
/** Pad a string to 43 characters long */
function pad43(x: string): string {
return x + ".".repeat(43 - x.length);
}
@@ -0,0 +1,73 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import "fake-indexeddb/auto";
import { IndexedDBCryptoStore } from "../../../../src";
import { MigrationState } from "../../../../src/crypto/store/base";
describe("IndexedDBCryptoStore", () => {
describe("Test `existsAndIsNotMigrated`", () => {
beforeEach(async () => {
// eslint-disable-next-line no-global-assign
indexedDB = new IDBFactory();
});
it("Should be true if there is a legacy database", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(true);
});
it("Should be true if there is a legacy database in non migrated state", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
await store.setMigrationState(MigrationState.NOT_STARTED);
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(true);
});
describe.each([
MigrationState.INITIAL_DATA_MIGRATED,
MigrationState.OLM_SESSIONS_MIGRATED,
MigrationState.MEGOLM_SESSIONS_MIGRATED,
MigrationState.ROOM_SETTINGS_MIGRATED,
])("Exists and Migration state is %s", (migrationState) => {
it("Should be false if migration has started", async () => {
// should detect a store that is not migrated
const store = new IndexedDBCryptoStore(global.indexedDB, "tests");
await store.startup();
await store.setMigrationState(migrationState);
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(false);
});
});
it("Should be false if there is no legacy database", async () => {
const result = await IndexedDBCryptoStore.existsAndIsNotMigrated(global.indexedDB, "tests");
expect(result).toBe(false);
});
});
});
+56 -28
View File
@@ -55,6 +55,7 @@ import {
RuleId,
IPushRule,
ConditionKind,
getHttpUriForMxc,
} from "../../src";
import { supportsMatrixCall } from "../../src/webrtc/call";
import { makeBeaconEvent } from "../test-utils/beacon";
@@ -64,7 +65,7 @@ import {
PolicyScope,
} from "../../src/models/invites-ignorer";
import { IOlmDevice } from "../../src/crypto/algorithms/megolm";
import { QueryDict } from "../../src/utils";
import { defer, QueryDict } from "../../src/utils";
import { SyncState } from "../../src/sync";
import * as featureUtils from "../../src/feature";
import { StubStore } from "../../src/store/stub";
@@ -369,6 +370,21 @@ describe("MatrixClient", function () {
client.stopClient();
});
describe("mxcUrlToHttp", () => {
it("should call getHttpUriForMxc", () => {
const mxc = "mxc://server/example";
expect(client.mxcUrlToHttp(mxc)).toBe(getHttpUriForMxc(client.baseUrl, mxc));
expect(client.mxcUrlToHttp(mxc, 32)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32));
expect(client.mxcUrlToHttp(mxc, 32, 46)).toBe(getHttpUriForMxc(client.baseUrl, mxc, 32, 46));
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale")).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale"),
);
expect(client.mxcUrlToHttp(mxc, 32, 46, "scale", false, true)).toBe(
getHttpUriForMxc(client.baseUrl, mxc, 32, 46, "scale", false, true),
);
});
});
describe("timestampToEvent", () => {
const roomId = "!room:server.org";
const eventId = "$eventId:example.org";
@@ -1434,25 +1450,11 @@ describe("MatrixClient", function () {
const mockRoom = {
getMyMembership: () => "join",
updatePendingEvent: (event: MatrixEvent, status: EventStatus) => event.setStatus(status),
currentState: {
getStateEvents: (eventType, stateKey) => {
if (eventType === EventType.RoomCreate) {
expect(stateKey).toEqual("");
return new MatrixEvent({
content: {
[RoomCreateTypeField]: RoomType.Space,
},
});
} else if (eventType === EventType.RoomEncryption) {
expect(stateKey).toEqual("");
return new MatrixEvent({ content: {} });
} else {
throw new Error("Unexpected event type or state key");
}
},
} as Room["currentState"],
hasEncryptionStateEvent: jest.fn().mockReturnValue(true),
} as unknown as Room;
let mockCrypto: Mocked<Crypto>;
let event: MatrixEvent;
beforeEach(async () => {
event = new MatrixEvent({
@@ -1467,11 +1469,12 @@ describe("MatrixClient", function () {
expect(getRoomId).toEqual(roomId);
return mockRoom;
};
client.crypto = client["cryptoBackend"] = {
// mock crypto
encryptEvent: () => new Promise(() => {}),
mockCrypto = {
isEncryptionEnabledInRoom: jest.fn().mockResolvedValue(true),
encryptEvent: jest.fn(),
stop: jest.fn(),
} as unknown as Crypto;
} as unknown as Mocked<Crypto>;
client.crypto = client["cryptoBackend"] = mockCrypto;
});
function assertCancelled() {
@@ -1488,12 +1491,21 @@ describe("MatrixClient", function () {
});
it("should cancel an event which is encrypting", async () => {
const encryptEventDefer = defer();
mockCrypto.encryptEvent.mockReturnValue(encryptEventDefer.promise);
const statusPromise = testUtils.emitPromise(event, "Event.status");
// @ts-ignore protected method access
client.encryptAndSendEvent(mockRoom, event);
await testUtils.emitPromise(event, "Event.status");
const encryptAndSendPromise = client.encryptAndSendEvent(mockRoom, event);
await statusPromise;
expect(event.status).toBe(EventStatus.ENCRYPTING);
client.cancelPendingEvent(event);
assertCancelled();
// now let the encryption complete, and check that the message is not sent.
encryptEventDefer.resolve();
await encryptAndSendPromise;
assertCancelled();
});
it("should cancel an event which is not sent", () => {
@@ -2211,8 +2223,7 @@ describe("MatrixClient", function () {
"org.matrix.msc3391": true,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
const unstablePrefix = "/_matrix/client/unstable/org.matrix.msc3391";
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
@@ -2250,8 +2261,7 @@ describe("MatrixClient", function () {
"org.matrix.msc3391": false,
},
};
jest.spyOn(client.http, "request").mockResolvedValue(versionsResponse);
const requestSpy = jest.spyOn(client.http, "authedRequest").mockImplementation(() => Promise.resolve());
const requestSpy = jest.spyOn(client.http, "authedRequest").mockResolvedValue(versionsResponse);
const path = `/user/${encodeURIComponent(userId)}/account_data/${eventType}`;
// populate version support
@@ -3002,4 +3012,22 @@ describe("MatrixClient", function () {
expect(result).toEqual({});
});
});
describe("getAuthIssuer", () => {
it("should use unstable prefix", async () => {
httpLookups = [
{
method: "GET",
path: `/auth_issuer`,
data: {
issuer: "https://issuer/",
},
prefix: "/_matrix/client/unstable/org.matrix.msc2965",
},
];
await expect(client.getAuthIssuer()).resolves.toEqual({ issuer: "https://issuer/" });
expect(httpLookups.length).toEqual(0);
});
});
});
+25 -4
View File
@@ -34,9 +34,12 @@ function makeMockEvent(originTs = 0): MatrixEvent {
}
describe("CallMembership", () => {
it("rejects membership with no expiry", () => {
it("rejects membership with no expiry and no expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: undefined }));
new CallMembership(
makeMockEvent(),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: undefined }),
);
}).toThrow();
});
@@ -57,6 +60,16 @@ describe("CallMembership", () => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { scope: undefined }));
}).toThrow();
});
it("rejects with malformatted expires_ts", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires_ts: "string" }));
}).toThrow();
});
it("rejects with malformatted expires", () => {
expect(() => {
new CallMembership(makeMockEvent(), Object.assign({}, membershipTemplate, { expires: "string" }));
}).toThrow();
});
it("uses event timestamp if no created_ts", () => {
const membership = new CallMembership(makeMockEvent(12345), membershipTemplate);
@@ -71,11 +84,19 @@ describe("CallMembership", () => {
expect(membership.createdTs()).toEqual(67890);
});
it("computes absolute expiry time", () => {
it("computes absolute expiry time based on expires", () => {
const membership = new CallMembership(makeMockEvent(1000), membershipTemplate);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("computes absolute expiry time based on expires_ts", () => {
const membership = new CallMembership(
makeMockEvent(1000),
Object.assign({}, membershipTemplate, { expires: undefined, expires_ts: 6000 }),
);
expect(membership.getAbsoluteExpiry()).toEqual(5000 + 1000);
});
it("considers memberships unexpired if local age low enough", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(3000);
@@ -85,7 +106,7 @@ describe("CallMembership", () => {
it("considers memberships expired when local age large", () => {
const fakeEvent = makeMockEvent(1000);
fakeEvent.getLocalAge = jest.fn().mockReturnValue(6000);
fakeEvent.localTimestamp = Date.now() - 6000;
const membership = new CallMembership(fakeEvent, membershipTemplate);
expect(membership.isExpired()).toEqual(true);
});
+60 -41
View File
@@ -60,19 +60,27 @@ describe("MatrixRTCSession", () => {
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
expect(sess?.memberships[0].membershipID).toEqual("bloop");
expect(sess?.memberships[0].isExpired()).toEqual(false);
expect(sess?.callId).toEqual("");
});
it("ignores expired memberships events", () => {
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.expires = 1000;
expiredMembership.device_id = "EXPIRED";
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], () => 10000);
const mockRoom = makeMockRoom([membershipTemplate, expiredMembership], 10000);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
expect(sess?.memberships.length).toEqual(1);
expect(sess?.memberships[0].deviceId).toEqual("AAAAAAA");
});
it("ignores memberships events of members not in the room", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
mockRoom.hasMembershipState = (state) => state === "join";
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
expect(sess?.memberships.length).toEqual(0);
});
it("honours created_ts", () => {
const expiredMembership = Object.assign({}, membershipTemplate);
expiredMembership.created_ts = 500;
@@ -90,9 +98,12 @@ describe("MatrixRTCSession", () => {
it("safely ignores events with no memberships section", () => {
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
@@ -111,9 +122,12 @@ describe("MatrixRTCSession", () => {
it("safely ignores events with junk memberships section", () => {
const mockRoom = {
...makeMockRoom([]),
roomId: randomString(8),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue({
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_type: string, _stateKey: string) => [
{
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
@@ -213,8 +227,8 @@ describe("MatrixRTCSession", () => {
});
it("sends a membership event when joining a call", () => {
jest.useFakeTimers();
sess!.joinRoomSession([mockFocus]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
@@ -226,6 +240,7 @@ describe("MatrixRTCSession", () => {
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [{ type: "mock" }],
membershipID: expect.stringMatching(".*"),
},
@@ -233,6 +248,7 @@ describe("MatrixRTCSession", () => {
},
"@alice:example.org",
);
jest.useRealTimers();
});
it("does nothing if join called when already joined", () => {
@@ -265,7 +281,7 @@ describe("MatrixRTCSession", () => {
const timeElapsed = 60 * 60 * 1000 - 1000;
mockRoom.getLiveTimeline().getState(EventTimeline.FORWARDS)!.getStateEvents = jest
.fn()
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, () => timeElapsed));
.mockReturnValue(mockRTCEvent(eventContent.memberships, mockRoom.roomId, timeElapsed));
const eventReSentPromise = new Promise<Record<string, any>>((r) => {
resolveFn = (_roomId: string, _type: string, val: Record<string, any>) => {
@@ -290,6 +306,7 @@ describe("MatrixRTCSession", () => {
call_id: "",
device_id: "AAAAAAA",
expires: 3600000 * 2,
expires_ts: 1000 + 3600000 * 2,
foci_active: [{ type: "mock" }],
created_ts: 1000,
membershipID: expect.stringMatching(".*"),
@@ -509,7 +526,7 @@ describe("MatrixRTCSession", () => {
});
});
it("Does not emits if no membership changes", () => {
it("Does not emit if no membership changes", () => {
const mockRoom = makeMockRoom([membershipTemplate]);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
@@ -538,10 +555,8 @@ describe("MatrixRTCSession", () => {
it("emits an event at the time a membership event expires", () => {
jest.useFakeTimers();
try {
let eventAge = 0;
const membership = Object.assign({}, membershipTemplate);
const mockRoom = makeMockRoom([membership], () => eventAge);
const mockRoom = makeMockRoom([membership], 0);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
const membershipObject = sess.memberships[0];
@@ -549,7 +564,6 @@ describe("MatrixRTCSession", () => {
const onMembershipsChanged = jest.fn();
sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged);
eventAge = 61 * 1000 * 1000;
jest.advanceTimersByTime(61 * 1000 * 1000);
expect(onMembershipsChanged).toHaveBeenCalledWith([membershipObject], []);
@@ -560,52 +574,55 @@ describe("MatrixRTCSession", () => {
});
it("prunes expired memberships on update", () => {
client.sendStateEvent = jest.fn();
jest.useFakeTimers();
try {
client.sendStateEvent = jest.fn();
let eventAge = 0;
const mockRoom = makeMockRoom(
[
const mockMemberships = [
Object.assign({}, membershipTemplate, {
device_id: "OTHERDEVICE",
expires: 1000,
}),
],
() => eventAge,
);
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
];
const mockRoomNoExpired = makeMockRoom(mockMemberships, 0);
// sanity check
expect(sess.memberships).toHaveLength(1);
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoomNoExpired);
eventAge = 10000;
// sanity check
expect(sess.memberships).toHaveLength(1);
expect(sess.memberships[0].deviceId).toEqual("OTHERDEVICE");
sess.joinRoomSession([mockFocus]);
jest.advanceTimersByTime(10000);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoom!.roomId,
EventType.GroupCallMemberPrefix,
{
memberships: [
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
],
},
"@alice:example.org",
);
sess.joinRoomSession([mockFocus]);
expect(client.sendStateEvent).toHaveBeenCalledWith(
mockRoomNoExpired!.roomId,
EventType.GroupCallMemberPrefix,
{
memberships: [
{
application: "m.call",
scope: "m.room",
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
],
},
"@alice:example.org",
);
} finally {
jest.useRealTimers();
}
});
it("fills in created_ts for other memberships on update", () => {
client.sendStateEvent = jest.fn();
jest.useFakeTimers();
const mockRoom = makeMockRoom([
Object.assign({}, membershipTemplate, {
device_id: "OTHERDEVICE",
@@ -635,6 +652,7 @@ describe("MatrixRTCSession", () => {
call_id: "",
device_id: "AAAAAAA",
expires: 3600000,
expires_ts: Date.now() + 3600000,
foci_active: [mockFocus],
membershipID: expect.stringMatching(".*"),
},
@@ -642,6 +660,7 @@ describe("MatrixRTCSession", () => {
},
"@alice:example.org",
);
jest.useRealTimers();
});
it("collects keys from encryption events", () => {
@@ -87,7 +87,7 @@ describe("MatrixRTCSessionManager", () => {
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
});
it("Calls onCallEncryption on encryption keys event", () => {
it("Calls onCallEncryption on encryption keys event", async () => {
const room1 = makeMockRoom([membershipTemplate]);
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
jest.spyOn(client, "getRoom").mockReturnValue(room1);
@@ -95,7 +95,7 @@ describe("MatrixRTCSessionManager", () => {
client.emit(ClientEvent.Room, room1);
const onCallEncryptionMock = jest.fn();
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
client.decryptEventIfNeeded = () => Promise.resolve();
const timelineEvent = {
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
getContent: jest.fn().mockReturnValue({}),
@@ -106,6 +106,7 @@ describe("MatrixRTCSessionManager", () => {
},
} as unknown as MatrixEvent;
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
await new Promise(process.nextTick);
expect(onCallEncryptionMock).toHaveBeenCalled();
});
});
+11 -21
View File
@@ -18,41 +18,32 @@ import { EventType, MatrixEvent, Room } from "../../../src";
import { CallMembershipData } from "../../../src/matrixrtc/CallMembership";
import { randomString } from "../../../src/randomstring";
export function makeMockRoom(
memberships: CallMembershipData[],
getLocalAge: (() => number) | undefined = undefined,
): Room {
export function makeMockRoom(memberships: CallMembershipData[], localAge: number | null = null): Room {
const roomId = randomString(8);
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
const roomState = makeMockRoomState(memberships, roomId, localAge);
return {
roomId: roomId,
hasMembershipState: jest.fn().mockReturnValue(true),
getLiveTimeline: jest.fn().mockReturnValue({
getState: jest.fn().mockReturnValue(makeMockRoomState(memberships, roomId, getLocalAge)),
getState: jest.fn().mockReturnValue(roomState),
}),
} as unknown as Room;
}
export function makeMockRoomState(
memberships: CallMembershipData[],
roomId: string,
getLocalAge: (() => number) | undefined,
) {
export function makeMockRoomState(memberships: CallMembershipData[], roomId: string, localAge: number | null = null) {
const event = mockRTCEvent(memberships, roomId, localAge);
return {
on: jest.fn(),
off: jest.fn(),
getStateEvents: (_: string, stateKey: string) => {
const event = mockRTCEvent(memberships, roomId, getLocalAge);
if (stateKey !== undefined) return event;
return [event];
},
};
}
export function mockRTCEvent(
memberships: CallMembershipData[],
roomId: string,
getLocalAge: (() => number) | undefined,
): MatrixEvent {
const getLocalAgeFn = getLocalAge ?? (() => 10);
export function mockRTCEvent(memberships: CallMembershipData[], roomId: string, localAge: number | null): MatrixEvent {
return {
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
getContent: jest.fn().mockReturnValue({
@@ -60,8 +51,7 @@ export function mockRTCEvent(
}),
getSender: jest.fn().mockReturnValue("@mock:user.example"),
getTs: jest.fn().mockReturnValue(1000),
getLocalAge: getLocalAgeFn,
localTimestamp: Date.now(),
localTimestamp: Date.now() - (localAge ?? 10),
getRoomId: jest.fn().mockReturnValue(roomId),
sender: {
userId: "@mock:user.example",
+3 -3
View File
@@ -213,7 +213,7 @@ describe("MSC3089Branch", () => {
expect(eventId).toEqual(fileEventId);
return fileEvent;
},
} as EventTimelineSet);
}) as EventTimelineSet;
client.mxcUrlToHttp = (mxc: string) => {
expect(mxc).toEqual("mxc://" + mxcLatter);
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
@@ -239,7 +239,7 @@ describe("MSC3089Branch", () => {
expect(eventId).toEqual(fileEventId);
return fileEvent;
},
} as EventTimelineSet);
}) as EventTimelineSet;
client.mxcUrlToHttp = (mxc: string) => {
expect(mxc).toEqual("mxc://" + mxcLatter);
return `https://example.org/_matrix/media/v1/download/${mxcLatter}`;
@@ -332,7 +332,7 @@ describe("MSC3089Branch", () => {
getId: () => "$unknown",
},
];
staticRoom.getLiveTimeline = () => ({ getEvents: () => events } as EventTimeline);
staticRoom.getLiveTimeline = () => ({ getEvents: () => events }) as EventTimeline;
directory.getFile = (evId: string) => {
expect(evId).toEqual(fileEventId);
+2 -2
View File
@@ -399,7 +399,7 @@ describe("MSC3089TreeSpace", () => {
];
},
};
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
const getFn = jest.fn().mockImplementation((roomId: string) => {
if (roomId === thirdChildRoom) {
@@ -422,7 +422,7 @@ describe("MSC3089TreeSpace", () => {
});
it("should find specific directories", () => {
client.getRoom = () => ({} as Room); // to appease the TreeSpace constructor
client.getRoom = () => ({}) as Room; // to appease the TreeSpace constructor
// Only mocking used API
const firstSubdirectory = { roomId: "!first:example.org" } as any as MSC3089TreeSpace;
+279 -21
View File
@@ -14,10 +14,19 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MockedObject } from "jest-mock";
import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event";
import { emitPromise } from "../../test-utils/test-utils";
import { Crypto, IEventDecryptionResult } from "../../../src/crypto";
import { IAnnotatedPushRule, PushRuleActionName, TweakName } from "../../../src";
import {
IAnnotatedPushRule,
MatrixClient,
PushRuleActionName,
Room,
THREAD_RELATION_TYPE,
TweakName,
} from "../../../src";
describe("MatrixEvent", () => {
it("should create copies of itself", () => {
@@ -61,31 +70,264 @@ describe("MatrixEvent", () => {
expect(a.toSnapshot().isEquivalentTo(b)).toBe(false);
});
it("should prune clearEvent when being redacted", () => {
const ev = new MatrixEvent({
type: "m.room.message",
content: {
body: "Test",
},
event_id: "$event1:server",
describe("redaction", () => {
it("should prune clearEvent when being redacted", () => {
const ev = createEvent("$event1:server", "Test");
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBe("Test");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBe("xyz");
const mockClient = {} as unknown as MockedObject<MatrixClient>;
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
expect(ev.getContent().body).toBeUndefined();
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBeUndefined();
});
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBe("Test");
ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", "");
expect(ev.getContent().body).toBe("Test");
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBe("xyz");
it("should remain in the main timeline when redacted", async () => {
// Given an event in the main timeline
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const ev = createEvent("$event1:server");
const redaction = new MatrixEvent({
type: "m.room.redaction",
redacts: ev.getId(),
await room.addLiveEvents([ev]);
await room.createThreadsTimelineSets();
expect(ev.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]);
// When I redact it
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
// Then it remains in the main timeline
expect(ev.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([ev.getId()]);
});
ev.makeRedacted(redaction);
expect(ev.getContent().body).toBeUndefined();
expect(ev.getWireContent().body).toBeUndefined();
expect(ev.getWireContent().ciphertext).toBeUndefined();
it("should keep thread roots in both timelines when redacted", async () => {
// Given a thread exists
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const threadRoot = createEvent("$threadroot:server");
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
await room.addLiveEvents([threadRoot, ev]);
await room.createThreadsTimelineSets();
expect(threadRoot.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
// When I redact the thread root
const redaction = createRedaction(ev.getId()!);
threadRoot.makeRedacted(redaction, room);
// Then it remains in the main timeline and the thread
expect(threadRoot.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
});
it("should move into the main timeline when redacted", async () => {
// Given an event in a thread
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const threadRoot = createEvent("$threadroot:server");
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
await room.addLiveEvents([threadRoot, ev]);
await room.createThreadsTimelineSets();
expect(ev.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId()]);
// When I redact it
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
// Then it disappears from the thread and appears in the main timeline
expect(ev.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId()]);
expect(threadLiveEventIds(room, 0)).not.toContain(ev.getId());
});
it("should move reactions to a redacted event into the main timeline", async () => {
// Given an event in a thread with a reaction
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const threadRoot = createEvent("$threadroot:server");
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
const reaction = createReactionEvent("$reaction:server", ev.getId()!);
await room.addLiveEvents([threadRoot, ev, reaction]);
await room.createThreadsTimelineSets();
expect(reaction.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]);
// When I redact the event
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
// Then the reaction moves into the main timeline
expect(reaction.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), reaction.getId()]);
expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId());
});
it("should move edits of a redacted event into the main timeline", async () => {
// Given an event in a thread with a reaction
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const threadRoot = createEvent("$threadroot:server");
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
const edit = createEditEvent("$edit:server", ev.getId()!);
await room.addLiveEvents([threadRoot, ev, edit]);
await room.createThreadsTimelineSets();
expect(edit.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]);
// When I redact the event
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
// Then the edit moves into the main timeline
expect(edit.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId(), ev.getId(), edit.getId()]);
expect(threadLiveEventIds(room, 0)).not.toContain(edit.getId());
});
it("should move reactions to replies to replies a redacted event into the main timeline", async () => {
// Given an event in a thread with a reaction
const mockClient = createMockClient();
const room = new Room("!roomid:e.xyz", mockClient, "myname");
const threadRoot = createEvent("$threadroot:server");
const ev = createThreadedEvent("$event1:server", threadRoot.getId()!);
const reply1 = createReplyEvent("$reply1:server", ev.getId()!);
const reply2 = createReplyEvent("$reply2:server", reply1.getId()!);
const reaction = createReactionEvent("$reaction:server", reply2.getId()!);
await room.addLiveEvents([threadRoot, ev, reply1, reply2, reaction]);
await room.createThreadsTimelineSets();
expect(reaction.threadRootId).toEqual(threadRoot.getId());
expect(mainTimelineLiveEventIds(room)).toEqual([threadRoot.getId()]);
expect(threadLiveEventIds(room, 0)).toEqual([
threadRoot.getId(),
ev.getId(),
reply1.getId(),
reply2.getId(),
reaction.getId(),
]);
// When I redact the event
const redaction = createRedaction(ev.getId()!);
ev.makeRedacted(redaction, room);
// Then the replies move to the main thread and the reaction disappears
expect(reaction.threadRootId).toBeUndefined();
expect(mainTimelineLiveEventIds(room)).toEqual([
threadRoot.getId(),
ev.getId(),
reply1.getId(),
reply2.getId(),
reaction.getId(),
]);
expect(threadLiveEventIds(room, 0)).not.toContain(reply1.getId());
expect(threadLiveEventIds(room, 0)).not.toContain(reply2.getId());
expect(threadLiveEventIds(room, 0)).not.toContain(reaction.getId());
});
function createMockClient(): MatrixClient {
return {
supportsThreads: jest.fn().mockReturnValue(true),
decryptEventIfNeeded: jest.fn().mockReturnThis(),
getUserId: jest.fn().mockReturnValue("@user:server"),
} as unknown as MockedObject<MatrixClient>;
}
function createEvent(eventId: string, body?: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
body: body ?? eventId,
},
event_id: eventId,
});
}
function createThreadedEvent(eventId: string, threadRootId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
"body": eventId,
"m.relates_to": {
rel_type: THREAD_RELATION_TYPE.name,
event_id: threadRootId,
},
},
event_id: eventId,
});
}
function createEditEvent(eventId: string, repliedToId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
"body": "Edited",
"m.new_content": {
body: "Edited",
},
"m.relates_to": {
event_id: repliedToId,
rel_type: "m.replace",
},
},
event_id: eventId,
});
}
function createReplyEvent(eventId: string, repliedToId: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.message",
content: {
"m.relates_to": {
event_id: repliedToId,
key: "x",
rel_type: "m.in_reply_to",
},
},
event_id: eventId,
});
}
function createReactionEvent(eventId: string, reactedToId: string): MatrixEvent {
return new MatrixEvent({
type: "m.reaction",
content: {
"m.relates_to": {
event_id: reactedToId,
key: "x",
rel_type: "m.annotation",
},
},
event_id: eventId,
});
}
function createRedaction(redactedEventid: string): MatrixEvent {
return new MatrixEvent({
type: "m.room.redaction",
redacts: redactedEventid,
});
}
});
describe("applyVisibilityEvent", () => {
@@ -330,3 +572,19 @@ describe("MatrixEvent", () => {
expect(stateEvent.threadRootId).toBeUndefined();
});
});
function mainTimelineLiveEventIds(room: Room): Array<string> {
return room
.getLiveTimeline()
.getEvents()
.map((e) => e.getId()!);
}
function threadLiveEventIds(room: Room, threadIndex: number): Array<string> {
return room
.getThreads()
[threadIndex].getUnfilteredTimelineSet()
.getLiveTimeline()
.getEvents()
.map((e) => e.getId()!);
}
+541
View File
@@ -0,0 +1,541 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { FeatureSupport, MatrixClient, MatrixEvent, ReceiptContent, THREAD_RELATION_TYPE, Thread } from "../../../src";
import { Room } from "../../../src/models/room";
/**
* Note, these tests check the functionality of the RoomReceipts class, but most
* of them access that functionality via the surrounding Room class, because a
* room is required for RoomReceipts to function, and this matches the pattern
* of how this code is used in the wild.
*/
describe("RoomReceipts", () => {
beforeAll(() => {
jest.replaceProperty(Thread, "hasServerSideSupport", FeatureSupport.Stable);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("reports events unread if there are no receipts", () => {
// Given there are no receipts in the room
const room = createRoom();
const [event] = createEvent();
room.addLiveEvents([event]);
// When I ask about any event, then it is unread
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(false);
});
it("reports events we sent as read even if there are no (real) receipts", () => {
// Given there are no receipts in the room
const room = createRoom();
const [event] = createEventSentBy(readerId);
room.addLiveEvents([event]);
// When I ask about an event I sent, it is read (because a synthetic
// receipt was created and stored in RoomReceipts)
expect(room.hasUserReadEvent(readerId, event.getId()!)).toBe(true);
});
it("reports read if we receive an unthreaded receipt for this event", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive an unthreaded receipt for a later event", () => {
// Given we have 2 events
const room = createRoom();
const [event1, event1Id] = createEvent();
const [event2] = createEvent();
room.addLiveEvents([event1, event2]);
// When we receive a receipt for the later event
room.addReceipt(createReceipt(readerId, event2));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
});
it("reports read for a non-live event if we receive an unthreaded receipt for a live one", () => {
// Given we have 2 events: one live and one old
const room = createRoom();
const [oldEvent, oldEventId] = createEvent();
const [liveEvent] = createEvent();
room.addLiveEvents([liveEvent]);
createOldTimeline(room, [oldEvent]);
// When we receive a receipt for the live event
room.addReceipt(createReceipt(readerId, liveEvent));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, oldEventId)).toBe(true);
});
it("compares by timestamp if two events are in separate old timelines", () => {
// Given we have 2 events, both in old timelines, with event2 after
// event1 in terms of timestamps
const room = createRoom();
const [event1, event1Id] = createEvent();
const [event2, event2Id] = createEvent();
event1.event.origin_server_ts = 1;
event2.event.origin_server_ts = 2;
createOldTimeline(room, [event1]);
createOldTimeline(room, [event2]);
// When we receive a receipt for the older event
room.addReceipt(createReceipt(readerId, event1));
// Then the earlier one is read and the later one is not
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an unthreaded receipt for an earlier event", () => {
// Given we have 2 events
const room = createRoom();
const [event1] = createEvent();
const [event2, event2Id] = createEvent();
room.addLiveEvents([event1, event2]);
// When we receive a receipt for the earlier event
room.addReceipt(createReceipt(readerId, event1));
// Then the later one is unread
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an unthreaded receipt for a different user", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for another user
room.addReceipt(createReceipt(otherUserId, event));
// Then the event is still unread since the receipt was not for us
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// But it's read for the other person
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
});
it("reports events we sent as read even if an earlier receipt arrives", () => {
// Given we sent an event after some other event
const room = createRoom();
const [previousEvent] = createEvent();
const [myEvent] = createEventSentBy(readerId);
room.addLiveEvents([previousEvent, myEvent]);
// And I just received a receipt for the previous event
room.addReceipt(createReceipt(readerId, previousEvent));
// When I ask about the event I sent, it is read (because of synthetic receipts)
expect(room.hasUserReadEvent(readerId, myEvent.getId()!)).toBe(true);
});
it("considers events after ones we sent to be unread", () => {
// Given we sent an event, then another event came in
const room = createRoom();
const [myEvent] = createEventSentBy(readerId);
const [laterEvent] = createEvent();
room.addLiveEvents([myEvent, laterEvent]);
// When I ask about the later event, it is unread (because it's after the synthetic receipt)
expect(room.hasUserReadEvent(readerId, laterEvent.getId()!)).toBe(false);
});
it("correctly reports readness even when receipts arrive out of order", () => {
// Given we have 3 events
const room = createRoom();
const [event1] = createEvent();
const [event2, event2Id] = createEvent();
const [event3, event3Id] = createEvent();
room.addLiveEvents([event1, event2, event3]);
// When we receive receipts for the older events out of order
room.addReceipt(createReceipt(readerId, event2));
room.addReceipt(createReceipt(readerId, event1));
// Then we correctly ignore the older receipt
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
});
it("reports read if we receive a threaded receipt for this event (main)", () => {
// Given my event exists and is unread
const room = createRoom();
const [event, eventId] = createEvent();
room.addLiveEvents([event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event+user
room.addReceipt(createThreadedReceipt(readerId, event, "main"));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive a threaded receipt for this event (non-main)", () => {
// Given my event exists and is unread
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for this event on this thread
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
// Then that event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports read if we receive an threaded receipt for a later event", () => {
// Given we have 2 events in a thread
const room = createRoom();
const [root, rootId] = createEvent();
const [event1, event1Id] = createThreadedEvent(root);
const [event2] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2]);
// When we receive a receipt for the later event
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
// Then the earlier one is read
expect(room.hasUserReadEvent(readerId, event1Id)).toBe(true);
});
it("reports unread if we receive an threaded receipt for an earlier event", () => {
// Given we have 2 events in a thread
const room = createRoom();
const [root, rootId] = createEvent();
const [event1] = createThreadedEvent(root);
const [event2, event2Id] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2]);
// When we receive a receipt for the earlier event
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
// Then the later one is unread
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(false);
});
it("reports unread if we receive an threaded receipt for a different user", () => {
// Given my event exists and is unread
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event]);
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// When we receive a receipt for another user
room.addReceipt(createThreadedReceipt(otherUserId, event, rootId));
// Then the event is still unread since the receipt was not for us
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// But it's read for the other person
expect(room.hasUserReadEvent(otherUserId, eventId)).toBe(true);
});
it("reports unread if we receive a receipt for a later event in a different thread", () => {
// Given 2 events exist in different threads
const room = createRoom();
const [root1] = createEvent();
const [root2] = createEvent();
const [thread1, thread1Id] = createThreadedEvent(root1);
const [thread2] = createThreadedEvent(root2);
setupThread(room, root1);
setupThread(room, root2);
room.addLiveEvents([root1, root2, thread1, thread2]);
// When we receive a receipt for the later event
room.addReceipt(createThreadedReceipt(readerId, thread2, root2.getId()!));
// Then the old one is still unread since the receipt was not for this thread
expect(room.hasUserReadEvent(readerId, thread1Id)).toBe(false);
});
it("correctly reports readness even when threaded receipts arrive out of order", () => {
// Given we have 3 events
const room = createRoom();
const [root, rootId] = createEvent();
const [event1] = createThreadedEvent(root);
const [event2, event2Id] = createThreadedEvent(root);
const [event3, event3Id] = createThreadedEvent(root);
setupThread(room, root);
room.addLiveEvents([root, event1, event2, event3]);
// When we receive receipts for the older events out of order
room.addReceipt(createThreadedReceipt(readerId, event2, rootId));
room.addReceipt(createThreadedReceipt(readerId, event1, rootId));
// Then we correctly ignore the older receipt
expect(room.hasUserReadEvent(readerId, event2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, event3Id)).toBe(false);
});
it("correctly reports readness when mixing threaded and unthreaded receipts", () => {
// Given we have a setup from this presentation:
// https://docs.google.com/presentation/d/1H1gxRmRFAm8d71hCILWmpOYezsvdlb7cB6ANl-20Gns/edit?usp=sharing
//
// Main1----\
// | ---Thread1a <- threaded receipt
// | |
// | Thread1b
// threaded receipt -> Main2--\
// | ----------------Thread2a <- unthreaded receipt
// Main3 |
// Thread2b <- threaded receipt
//
const room = createRoom();
const [main1, main1Id] = createEvent();
const [main2, main2Id] = createEvent();
const [main3, main3Id] = createEvent();
const [thread1a, thread1aId] = createThreadedEvent(main1);
const [thread1b, thread1bId] = createThreadedEvent(main1);
const [thread2a, thread2aId] = createThreadedEvent(main2);
const [thread2b, thread2bId] = createThreadedEvent(main2);
setupThread(room, main1);
setupThread(room, main2);
room.addLiveEvents([main1, thread1a, thread1b, main2, thread2a, main3, thread2b]);
// And the timestamps on the events are consistent with the order above
main1.event.origin_server_ts = 1;
thread1a.event.origin_server_ts = 2;
thread1b.event.origin_server_ts = 3;
main2.event.origin_server_ts = 4;
thread2a.event.origin_server_ts = 5;
main3.event.origin_server_ts = 6;
thread2b.event.origin_server_ts = 7;
// (Note: in principle, we have the information needed to order these
// events without using their timestamps, since they all came in via
// addLiveEvents. In reality, some of them would have come in via the
// /relations API, making it impossible to get the correct ordering
// without MSC4033, which is why we fall back to timestamps. I.e. we
// definitely could fix the code to make the above
// timestamp-manipulation unnecessary, but it would only make this test
// neater, not actually help in the real world.)
// When the receipts arrive
room.addReceipt(createThreadedReceipt(readerId, main2, "main"));
room.addReceipt(createThreadedReceipt(readerId, thread1a, main1Id));
room.addReceipt(createReceipt(readerId, thread2a));
room.addReceipt(createThreadedReceipt(readerId, thread2b, main2Id));
// Then we correctly identify that only main3 is unread
expect(room.hasUserReadEvent(readerId, main1Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, main2Id)).toBe(true);
expect(room.hasUserReadEvent(readerId, main3Id)).toBe(false);
expect(room.hasUserReadEvent(readerId, thread1aId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread1bId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread2aId)).toBe(true);
expect(room.hasUserReadEvent(readerId, thread2bId)).toBe(true);
});
describe("dangling receipts", () => {
it("reports unread if the unthreaded receipt is in a dangling state", () => {
const room = createRoom();
const [event, eventId] = createEvent();
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the event to the room
// The receipt is removed from the dangling state
room.addLiveEvents([event]);
// Then the event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("reports unread if the threaded receipt is in a dangling state", () => {
const room = createRoom();
const [root, rootId] = createEvent();
const [event, eventId] = createThreadedEvent(root);
setupThread(room, root);
// When we receive a receipt for this event+user
room.addReceipt(createThreadedReceipt(readerId, event, rootId));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the events to the room
// The receipt is removed from the dangling state
room.addLiveEvents([root, event]);
// Then the event is read
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
it("should handle multiple dangling receipts for the same event", () => {
const room = createRoom();
const [event, eventId] = createEvent();
// When we receive a receipt for this event+user
room.addReceipt(createReceipt(readerId, event));
// We receive another receipt in the same event for another user
room.addReceipt(createReceipt(otherUserId, event));
// The event is not added in the room
// So the receipt is in a dangling state
expect(room.hasUserReadEvent(readerId, eventId)).toBe(false);
// Add the event to the room
// The two receipts should be processed
room.addLiveEvents([event]);
// Then the event is read
// We expect that the receipt of `otherUserId` didn't replace/erase the receipt of `readerId`
expect(room.hasUserReadEvent(readerId, eventId)).toBe(true);
});
});
});
function createFakeClient(): MatrixClient {
return {
getUserId: jest.fn(),
getEventMapper: jest.fn().mockReturnValue(jest.fn()),
isInitialSyncComplete: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
fetchRoomEvent: jest.fn().mockResolvedValue({}),
paginateEventTimeline: jest.fn(),
canSupport: { get: jest.fn() },
} as unknown as MatrixClient;
}
const senderId = "sender:s.ss";
const readerId = "reader:r.rr";
const otherUserId = "other:o.oo";
function createRoom(): Room {
return new Room("!rid", createFakeClient(), "@u:s.nz", { timelineSupport: true });
}
let idCounter = 0;
function nextId(): string {
return "$" + (idCounter++).toString(10);
}
/**
* Create an event and return it and its ID.
*/
function createEvent(): [MatrixEvent, string] {
return createEventSentBy(senderId);
}
/**
* Create an event with the supplied sender and return it and its ID.
*/
function createEventSentBy(customSenderId: string): [MatrixEvent, string] {
const event = new MatrixEvent({ sender: customSenderId, event_id: nextId() });
return [event, event.getId()!];
}
/**
* Create an event in the thread of the supplied root and return it and its ID.
*/
function createThreadedEvent(root: MatrixEvent): [MatrixEvent, string] {
const rootEventId = root.getId()!;
const event = new MatrixEvent({
sender: senderId,
event_id: nextId(),
content: {
"m.relates_to": {
event_id: rootEventId,
rel_type: THREAD_RELATION_TYPE.name,
["m.in_reply_to"]: {
event_id: rootEventId,
},
},
},
});
return [event, event.getId()!];
}
function createReceipt(userId: string, referencedEvent: MatrixEvent): MatrixEvent {
const content: ReceiptContent = {
[referencedEvent.getId()!]: {
"m.read": {
[userId]: {
ts: 123,
},
},
},
};
return new MatrixEvent({
type: "m.receipt",
content,
});
}
function createThreadedReceipt(userId: string, referencedEvent: MatrixEvent, threadId: string): MatrixEvent {
const content: ReceiptContent = {
[referencedEvent.getId()!]: {
"m.read": {
[userId]: {
ts: 123,
thread_id: threadId,
},
},
},
};
return new MatrixEvent({
type: "m.receipt",
content,
});
}
/**
* Create a timeline in the timeline set that is not the live timeline.
*/
function createOldTimeline(room: Room, events: MatrixEvent[]) {
const oldTimeline = room.getUnfilteredTimelineSet().addTimeline();
room.getUnfilteredTimelineSet().addEventsToTimeline(events, true, oldTimeline);
}
/**
* Perform the hacks required for this room to create a thread based on the root
* event supplied.
*/
function setupThread(room: Room, root: MatrixEvent) {
const thread = room.createThread(root.getId()!, root, [root], false);
thread.initialEventsFetched = true;
}
+69 -32
View File
@@ -19,7 +19,7 @@ import { mocked } from "jest-mock";
import { MatrixClient, PendingEventOrdering } from "../../../src/client";
import { Room, RoomEvent } from "../../../src/models/room";
import { FeatureSupport, Thread, THREAD_RELATION_TYPE, ThreadEvent } from "../../../src/models/thread";
import { makeThreadEvent, mkThread } from "../../test-utils/thread";
import { makeThreadEvent, mkThread, populateThread } from "../../test-utils/thread";
import { TestClient } from "../../TestClient";
import { emitPromise, mkEdit, mkMessage, mkReaction, mock } from "../../test-utils/test-utils";
import { Direction, EventStatus, EventType, MatrixEvent } from "../../../src";
@@ -149,20 +149,38 @@ describe("Thread", () => {
});
it("considers other events with no RR as unread", () => {
const { thread, events } = mkThread({
// Given a long thread exists
const { thread, events } = populateThread({
room,
client,
authorId: myUserId,
participantUserIds: [myUserId],
authorId: "@other:foo.com",
participantUserIds: ["@other:foo.com"],
length: 25,
ts: 190,
});
// Before alice's last unthreaded receipt
expect(thread.hasUserReadEvent("@alice:example.org", events.at(1)!.getId() ?? "")).toBeTruthy();
const event1 = events.at(1)!;
const event2 = events.at(2)!;
const event24 = events.at(24)!;
// After alice's last unthreaded receipt
expect(thread.hasUserReadEvent("@alice:example.org", events.at(-1)!.getId() ?? "")).toBeFalsy();
// And we have read the second message in it with an unthreaded receipt
const receipt = new MatrixEvent({
type: "m.receipt",
room_id: room.roomId,
content: {
// unthreaded receipt for the second message in the thread
[event2.getId()!]: {
[ReceiptType.Read]: {
[myUserId]: { ts: 200 },
},
},
},
});
room.addReceipt(receipt);
// Then we have read the first message in the thread, and not the last
expect(thread.hasUserReadEvent(myUserId, event1.getId()!)).toBe(true);
expect(thread.hasUserReadEvent(myUserId, event24.getId()!)).toBe(false);
});
it("considers event as read if there's a more recent unthreaded receipt", () => {
@@ -481,13 +499,13 @@ describe("Thread", () => {
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt).toBeTruthy();
expect(receipt?.eventId).toEqual(message.getId());
expect(receipt?.data.ts).toEqual(100);
expect(receipt?.eventId).toEqual(message2.getId());
expect(receipt?.data.ts).toEqual(200);
expect(receipt?.data.thread_id).toEqual(thread.id);
// (And the receipt was synthetic)
@@ -505,14 +523,14 @@ describe("Thread", () => {
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread } = await createThreadAndEvent(client, 200, 100, userId);
const { thread, message1 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then no receipt was added to the thread (the receipt is still
// for the thread root). This happens because since we have no
// Then the receipt is for the first message, because its
// timestamp is later. This happens because since we have no
// recursive relations support, we know that sometimes events
// appear out of order, so we have to check their timestamps as
// a guess of the correct order.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
});
});
@@ -530,11 +548,11 @@ describe("Thread", () => {
// And a thread with an added event (with later timestamp)
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 1, 100, userId);
const { thread, message2 } = await createThreadAnd2Events(client, 1, 100, 200, userId);
// Then a receipt was added to the thread
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
expect(receipt?.eventId).toEqual(message2.getId());
});
it("Creates a local echo receipt even for events BEFORE an existing receipt", async () => {
@@ -550,22 +568,24 @@ describe("Thread", () => {
// And a thread with an added event with a lower timestamp than its other events
const userId = "user1";
const { thread, message } = await createThreadAndEvent(client, 200, 100, userId);
const { thread, message2 } = await createThreadAnd2Events(client, 300, 200, 100, userId);
// Then a receipt was added to the thread, because relations
// recursion is available, so we trust the server to have
// provided us with events in the right order.
// Then a receipt was added for the last message, even though it
// has lower ts, because relations recursion is available, so we
// trust the server to have provided us with events in the right
// order.
const receipt = thread.getReadReceiptForUserId(userId);
expect(receipt?.eventId).toEqual(message.getId());
expect(receipt?.eventId).toEqual(message2.getId());
});
});
async function createThreadAndEvent(
async function createThreadAnd2Events(
client: MatrixClient,
rootTs: number,
eventTs: number,
message1Ts: number,
message2Ts: number,
userId: string,
): Promise<{ thread: Thread; message: MatrixEvent }> {
): Promise<{ thread: Thread; message1: MatrixEvent; message2: MatrixEvent }> {
const room = new Room("room1", client, userId);
// Given a thread
@@ -576,24 +596,41 @@ describe("Thread", () => {
participantUserIds: [],
ts: rootTs,
});
// Sanity: the current receipt is for the thread root
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(thread.rootEvent?.getId());
// Sanity: there is no read receipt on the thread yet because the
// thread events don't get properly added to the room by mkThread.
expect(thread.getReadReceiptForUserId(userId)).toBeNull();
const awaitTimelineEvent = new Promise<void>((res) => thread.on(RoomEvent.Timeline, () => res()));
// When we add a message that is before the latest receipt
const message = makeThreadEvent({
// Add a message with ts message1Ts
const message1 = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: eventTs,
ts: message1Ts,
});
await thread.addEvent(message, false, true);
await thread.addEvent(message1, false, true);
await awaitTimelineEvent;
return { thread, message };
// Sanity: the thread now has a properly-added event, so this event
// has a synthetic receipt.
expect(thread.getReadReceiptForUserId(userId)?.eventId).toEqual(message1.getId());
// Add a message with ts message2Ts
const message2 = makeThreadEvent({
event: true,
rootEventId: thread.id,
replyToEventId: thread.id,
user: userId,
room: room.roomId,
ts: message2Ts,
});
await thread.addEvent(message2, false, true);
await awaitTimelineEvent;
return { thread, message1, message2 };
}
function createClientWithEventMapper(canSupport: Map<Feature, ServerSupport> = new Map()): MatrixClient {
+2
View File
@@ -106,6 +106,8 @@ describe("fixNotificationCountOnDecryption", () => {
mockClient,
);
room.addLiveEvents([event]);
THREAD_ID = event.getId()!;
threadEvent = mkEvent({
type: EventType.RoomMessage,
+23 -5
View File
@@ -20,7 +20,10 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { mocked } from "jest-mock";
import jwtDecode from "jwt-decode";
import { jwtDecode } from "jwt-decode";
import { Crypto } from "@peculiar/webcrypto";
import { getRandomValues } from "node:crypto";
import { TextEncoder } from "node:util";
import { Method } from "../../../src";
import * as crypto from "../../../src/crypto/crypto";
@@ -36,13 +39,15 @@ import { makeDelegatedAuthConfig, mockOpenIdConfiguration } from "../../test-uti
jest.mock("jwt-decode");
const webCrypto = new Crypto();
// save for resetting mocks
const realSubtleCrypto = crypto.subtleCrypto;
describe("oidc authorization", () => {
const delegatedAuthConfig = makeDelegatedAuthConfig();
const authorizationEndpoint = delegatedAuthConfig.metadata.authorization_endpoint;
const tokenEndpoint = delegatedAuthConfig.metadata.token_endpoint;
const authorizationEndpoint = delegatedAuthConfig.authorizationEndpoint;
const tokenEndpoint = delegatedAuthConfig.tokenEndpoint;
const clientId = "xyz789";
const baseUrl = "https://test.com";
@@ -53,7 +58,19 @@ describe("oidc authorization", () => {
jest.spyOn(logger, "warn");
jest.setSystemTime(now);
fetchMock.get(delegatedAuthConfig.issuer + ".well-known/openid-configuration", mockOpenIdConfiguration());
fetchMock.get(
delegatedAuthConfig.metadata.issuer + ".well-known/openid-configuration",
mockOpenIdConfiguration(),
);
Object.defineProperty(window, "crypto", {
value: {
getRandomValues,
randomUUID: jest.fn().mockReturnValue("not-random-uuid"),
subtle: webCrypto.subtle,
},
});
global.TextEncoder = TextEncoder;
});
afterEach(() => {
@@ -165,7 +182,8 @@ describe("oidc authorization", () => {
token_type: "Bearer",
access_token: "test_access_token",
refresh_token: "test_refresh_token",
id_token: "valid.id.token",
id_token:
"eyJhbGciOiJSUzI1NiIsImtpZCI6Imh4ZEhXb0Y5bW4ifQ.eyJleHAiOjE3MDgzNTY3NjcsInN1YiI6IjAxSFBQMkZTQllERTlQOUVNTThERDdXWkhSIiwiYXVkIjoiMDFIUTBXSDUyV0paV1JSU1k5V0VFUTVUMlEiLCJub25jZSI6ImhScEI2cGtFMDYiLCJhdXRoX3RpbWUiOjE3MDc5OTAzMTIsImlhdCI6MTcwODM1MzE2NywiYXRfaGFzaCI6Il9HSEU4cDhocHFnMW1ac041YUlycVEiLCJpc3MiOiJodHRwczovL2F1dGgtb2lkYy5sYWIuZWxlbWVudC5kZXYvIiwiY19oYXNoIjoib2hJRmNuaUZWd3pGSzVJdXlsX1RlQSJ9.SGUG78dCC3sTWgQBDTicKwamKiPpb6REiz79CM2ml_kVJCoS7gT0TlztC4h25FKi3c9aB3XCVn9J8UzvJgvG8Rt_oS--FIuhK6oRm7NdcN0bCkbG7iZEWGxx-kQnifcCFHyZ6T1CxR8X00Uvc6_lRfBZVlTyuuQaJ_PHiiKMlV93FbxvQUIq6FTkQP2Z56p4JIXIzjOONzA91skTqQGycl5f9Vhp6cqXFzl6ARK30M7A-8UI5qCxClUJ7kD9KgN5YZ7uivLp1x01WBnik2DXH0eSwXcTX2WLkYtMXgMxylJhIiO586apIC5nr7sfip-Y_4PgBlSjRRgrmOGC-VUFCA",
expires_in: 300,
};
+31 -22
View File
@@ -17,39 +17,48 @@ limitations under the License.
import fetchMockJest from "fetch-mock-jest";
import { OidcError } from "../../../src/oidc/error";
import { registerOidcClient } from "../../../src/oidc/register";
import { OidcRegistrationClientMetadata, registerOidcClient } from "../../../src/oidc/register";
import { makeDelegatedAuthConfig } from "../../test-utils/oidc";
describe("registerOidcClient()", () => {
const issuer = "https://auth.com/";
const registrationEndpoint = "https://auth.com/register";
const clientName = "Element";
const baseUrl = "https://just.testing";
const metadata: OidcRegistrationClientMetadata = {
clientUri: baseUrl,
redirectUris: [baseUrl],
clientName,
applicationType: "web",
tosUri: "http://tos-uri",
policyUri: "http://policy-uri",
contacts: ["admin@example.com"],
};
const dynamicClientId = "xyz789";
const delegatedAuthConfig = {
issuer,
registrationEndpoint,
authorizationEndpoint: issuer + "auth",
tokenEndpoint: issuer + "token",
};
const delegatedAuthConfig = makeDelegatedAuthConfig(issuer);
beforeEach(() => {
fetchMockJest.mockClear();
fetchMockJest.resetBehavior();
});
it("should make correct request to register client", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
body: JSON.stringify({ client_id: dynamicClientId }),
});
expect(await registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).toEqual(dynamicClientId);
expect(fetchMockJest).toHaveBeenCalledWith(registrationEndpoint, {
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
method: "POST",
body: JSON.stringify({
expect(await registerOidcClient(delegatedAuthConfig, metadata)).toEqual(dynamicClientId);
expect(fetchMockJest).toHaveBeenCalledWith(
delegatedAuthConfig.registrationEndpoint!,
expect.objectContaining({
headers: {
"Accept": "application/json",
"Content-Type": "application/json",
},
method: "POST",
}),
);
expect(JSON.parse(fetchMockJest.mock.calls[0][1]!.body as string)).toEqual(
expect.objectContaining({
client_name: clientName,
client_uri: baseUrl,
response_types: ["code"],
@@ -59,25 +68,25 @@ describe("registerOidcClient()", () => {
token_endpoint_auth_method: "none",
application_type: "web",
}),
});
);
});
it("should throw when registration request fails", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 500,
});
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow(
OidcError.DynamicRegistrationFailed,
);
});
it("should throw when registration response is invalid", async () => {
fetchMockJest.post(registrationEndpoint, {
fetchMockJest.post(delegatedAuthConfig.registrationEndpoint!, {
status: 200,
// no clientId in response
body: "{}",
});
await expect(() => registerOidcClient(delegatedAuthConfig, clientName, baseUrl)).rejects.toThrow(
await expect(() => registerOidcClient(delegatedAuthConfig, metadata)).rejects.toThrow(
OidcError.DynamicRegistrationInvalid,
);
});
+16 -16
View File
@@ -64,7 +64,7 @@ describe("OidcTokenRefresher", () => {
keys: [],
});
fetchMock.post(config.metadata.token_endpoint, {
fetchMock.post(config.tokenEndpoint, {
status: 200,
headers: {
"Content-Type": "application/json",
@@ -88,7 +88,7 @@ describe("OidcTokenRefresher", () => {
},
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.oidcClientReady).rejects.toThrow();
expect(logger.error).toHaveBeenCalledWith(
"Failed to initialise OIDC client.",
@@ -98,7 +98,7 @@ describe("OidcTokenRefresher", () => {
});
it("initialises oidc client", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// @ts-ignore peek at private property to see we initialised the client correctly
@@ -114,19 +114,19 @@ describe("OidcTokenRefresher", () => {
describe("doRefreshAccessToken()", () => {
it("should throw when oidcClient has not been initialised", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await expect(refresher.doRefreshAccessToken("token")).rejects.toThrow(
"Cannot get new token before OIDC client is initialised.",
);
});
it("should refresh the tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
const result = await refresher.doRefreshAccessToken("refresh-token");
expect(fetchMock).toHaveFetched(config.metadata.token_endpoint, {
expect(fetchMock).toHaveFetched(config.tokenEndpoint, {
method: "POST",
});
@@ -137,7 +137,7 @@ describe("OidcTokenRefresher", () => {
});
it("should persist the new tokens", async () => {
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// spy on our stub
jest.spyOn(refresher, "persistTokens");
@@ -153,7 +153,7 @@ describe("OidcTokenRefresher", () => {
it("should only have one inflight refresh request at once", async () => {
fetchMock
.postOnce(
config.metadata.token_endpoint,
config.tokenEndpoint,
{
status: 200,
headers: {
@@ -164,7 +164,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true },
)
.postOnce(
config.metadata.token_endpoint,
config.tokenEndpoint,
{
status: 200,
headers: {
@@ -175,7 +175,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: false },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.resetHistory();
@@ -188,7 +188,7 @@ describe("OidcTokenRefresher", () => {
const result2 = await first;
// only one call to token endpoint
expect(fetchMock).toHaveFetchedTimes(1, config.metadata.token_endpoint);
expect(fetchMock).toHaveFetchedTimes(1, config.tokenEndpoint);
expect(result1).toEqual({
accessToken: "first-new-access-token",
refreshToken: "first-new-refresh-token",
@@ -208,7 +208,7 @@ describe("OidcTokenRefresher", () => {
it("should log and rethrow when token refresh fails", async () => {
fetchMock.post(
config.metadata.token_endpoint,
config.tokenEndpoint,
{
status: 503,
headers: {
@@ -218,7 +218,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
await expect(refresher.doRefreshAccessToken("refresh-token")).rejects.toThrow();
@@ -228,7 +228,7 @@ describe("OidcTokenRefresher", () => {
// make sure inflight request is cleared after a failure
fetchMock
.postOnce(
config.metadata.token_endpoint,
config.tokenEndpoint,
{
status: 503,
headers: {
@@ -238,7 +238,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: true },
)
.postOnce(
config.metadata.token_endpoint,
config.tokenEndpoint,
{
status: 200,
headers: {
@@ -249,7 +249,7 @@ describe("OidcTokenRefresher", () => {
{ overwriteRoutes: false },
);
const refresher = new OidcTokenRefresher(authConfig, clientId, redirectUri, deviceId, idTokenClaims);
const refresher = new OidcTokenRefresher(authConfig.issuer, clientId, redirectUri, deviceId, idTokenClaims);
await refresher.oidcClientReady;
// reset call counts
fetchMock.resetHistory();
+10 -95
View File
@@ -15,107 +15,14 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import jwtDecode from "jwt-decode";
import { jwtDecode } from "jwt-decode";
import { M_AUTHENTICATION } from "../../../src";
import { logger } from "../../../src/logger";
import {
validateIdToken,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "../../../src/oidc/validate";
import { validateIdToken, validateOIDCIssuerWellKnown } from "../../../src/oidc/validate";
import { OidcError } from "../../../src/oidc/error";
jest.mock("jwt-decode");
describe("validateWellKnownAuthentication()", () => {
const baseWk = {
"m.homeserver": {
base_url: "https://hs.org",
},
};
it("should throw not supported error when wellKnown has no m.authentication section", () => {
expect(() => validateWellKnownAuthentication(undefined)).toThrow(OidcError.NotSupported);
});
it("should throw misconfigured error when authentication issuer is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: { url: "test.com" },
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is not a string", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: { url: "test" },
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should throw misconfigured error when authentication account is false", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: false,
},
};
expect(() => validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!] as any)).toThrow(
OidcError.Misconfigured,
);
});
it("should return valid config when wk uses stable m.authentication", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
account: "account.com",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
account: "account.com",
});
});
it("should return valid config when m.authentication account is missing", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
it("should remove unexpected properties", () => {
const wk = {
...baseWk,
[M_AUTHENTICATION.stable!]: {
issuer: "test.com",
somethingElse: "test",
},
};
expect(validateWellKnownAuthentication(wk[M_AUTHENTICATION.stable!])).toEqual({
issuer: "test.com",
});
});
});
describe("validateOIDCIssuerWellKnown", () => {
const validWk: any = {
authorization_endpoint: "https://test.org/authorize",
@@ -125,6 +32,8 @@ describe("validateOIDCIssuerWellKnown", () => {
response_types_supported: ["code"],
grant_types_supported: ["authorization_code"],
code_challenge_methods_supported: ["S256"],
account_management_uri: "https://authorize.org/account",
account_management_actions_supported: ["org.matrix.cross_signing_reset"],
};
beforeEach(() => {
// stub to avoid console litter
@@ -157,6 +66,8 @@ describe("validateOIDCIssuerWellKnown", () => {
authorizationEndpoint: validWk.authorization_endpoint,
tokenEndpoint: validWk.token_endpoint,
registrationEndpoint: validWk.registration_endpoint,
accountManagementActionsSupported: ["org.matrix.cross_signing_reset"],
accountManagementEndpoint: "https://authorize.org/account",
});
});
@@ -167,6 +78,8 @@ describe("validateOIDCIssuerWellKnown", () => {
authorizationEndpoint: validWk.authorization_endpoint,
tokenEndpoint: validWk.token_endpoint,
registrationEndpoint: undefined,
accountManagementActionsSupported: ["org.matrix.cross_signing_reset"],
accountManagementEndpoint: "https://authorize.org/account",
});
});
@@ -186,6 +99,8 @@ describe("validateOIDCIssuerWellKnown", () => {
["code_challenge_methods_supported", undefined],
["code_challenge_methods_supported", "not an array"],
["code_challenge_methods_supported", ["doesnt include S256"]],
["account_management_uri", { not: "a string" }],
["account_management_actions_supported", { not: "an array" }],
])("should throw OP support error when %s is %s", (key, value) => {
const wk = {
...validWk,
+1
View File
@@ -265,6 +265,7 @@ describe.each([[StoreType.Memory], [StoreType.IndexedDB]])("queueToDevice (%s st
});
const mockRoom = {
updatePendingEvent: jest.fn(),
hasEncryptionStateEvent: jest.fn().mockReturnValue(false),
} as unknown as Room;
client.resendEvent(dummyEvent, mockRoom);
+15
View File
@@ -43,8 +43,10 @@ const THREAD_ID = "$thread_event_id";
const ROOM_ID = "!123:matrix.org";
describe("Read receipt", () => {
let threadRoot: MatrixEvent;
let threadEvent: MatrixEvent;
let roomEvent: MatrixEvent;
let editOfThreadRoot: MatrixEvent;
beforeEach(() => {
httpBackend = new MockHttpBackend();
@@ -57,6 +59,15 @@ describe("Read receipt", () => {
client.isGuest = () => false;
client.supportsThreads = () => true;
threadRoot = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
user: "@bob:matrix.org",
room: ROOM_ID,
content: { body: "This is the thread root" },
});
threadRoot.event.event_id = THREAD_ID;
threadEvent = utils.mkEvent({
event: true,
type: EventType.RoomMessage,
@@ -82,6 +93,9 @@ describe("Read receipt", () => {
body: "Hello from a room",
},
});
editOfThreadRoot = utils.mkEdit(threadRoot, client, "@bob:matrix.org", ROOM_ID);
editOfThreadRoot.setThreadId(THREAD_ID);
});
describe("sendReceipt", () => {
@@ -208,6 +222,7 @@ describe("Read receipt", () => {
it.each([
{ getEvent: () => roomEvent, destinationId: MAIN_ROOM_TIMELINE },
{ getEvent: () => threadEvent, destinationId: THREAD_ID },
{ getEvent: () => editOfThreadRoot, destinationId: MAIN_ROOM_TIMELINE },
])("adds the receipt to $destinationId", ({ getEvent, destinationId }) => {
const event = getEvent();
const userId = "@bob:example.org";
+4 -1
View File
@@ -32,7 +32,10 @@ export class DummyTransport<D extends RendezvousTransportDetails, T> implements
ready = false;
cancelled = false;
constructor(private name: string, private mockDetails: D) {}
constructor(
private name: string,
private mockDetails: D,
) {}
onCancelled?: RendezvousFailureListener;
details(): Promise<RendezvousTransportDetails> {
+143 -77
View File
@@ -23,7 +23,7 @@ import {
MSC3903ECDHPayload,
MSC3903ECDHv2RendezvousChannel as MSC3903ECDHRendezvousChannel,
} from "../../../src/rendezvous/channels";
import { MatrixClient } from "../../../src";
import { Device, MatrixClient } from "../../../src";
import {
MSC3886SimpleHttpRendezvousTransport,
MSC3886SimpleHttpRendezvousTransportDetails,
@@ -31,16 +31,57 @@ import {
import { DummyTransport } from "./DummyTransport";
import { decodeBase64 } from "../../../src/base64";
import { logger } from "../../../src/logger";
import { DeviceInfo } from "../../../src/crypto/deviceinfo";
import { CrossSigningKey, OwnDeviceKeys } from "../../../src/crypto-api";
type UserID = string;
type DeviceID = string;
type Fingerprint = string;
type SimpleDeviceMap = Record<UserID, Record<DeviceID, Fingerprint>>;
function mockDevice(userId: UserID, deviceId: DeviceID, fingerprint: Fingerprint): Device {
return {
deviceId,
userId,
getFingerprint: () => fingerprint,
} as unknown as Device;
}
function mockDeviceMap(
userId: UserID,
deviceId: DeviceID,
deviceKey?: Fingerprint,
otherDevices: SimpleDeviceMap = {},
): Map<string, Map<string, Device>> {
const deviceMap: Map<string, Map<string, Device>> = new Map();
const myDevices: Map<string, Device> = new Map();
if (deviceKey) {
myDevices.set(deviceId, mockDevice(userId, deviceId, deviceKey));
}
deviceMap.set(userId, myDevices);
for (const u in otherDevices) {
let userDevices = deviceMap.get(u);
if (!userDevices) {
userDevices = new Map();
deviceMap.set(u, userDevices);
}
for (const d in otherDevices[u]) {
userDevices.set(d, mockDevice(u, d, otherDevices[u][d]));
}
}
return deviceMap;
}
function makeMockClient(opts: {
userId: string;
deviceId: string;
deviceKey?: string;
userId: UserID;
deviceId: DeviceID;
deviceKey?: Fingerprint;
getLoginTokenEnabled: boolean;
msc3882r0Only: boolean;
msc3886Enabled: boolean;
devices?: Record<string, Partial<DeviceInfo>>;
devices?: SimpleDeviceMap;
verificationFunction?: (
userId: string,
deviceId: string,
@@ -48,50 +89,77 @@ function makeMockClient(opts: {
blocked: boolean,
known: boolean,
) => void;
crossSigningIds?: Record<string, string>;
}): MatrixClient {
return {
getVersions() {
return {
unstable_features: {
"org.matrix.msc3882": opts.getLoginTokenEnabled,
"org.matrix.msc3886": opts.msc3886Enabled,
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"m.get_login_token": {
enabled: opts.getLoginTokenEnabled,
crossSigningIds?: Partial<Record<CrossSigningKey, string>>;
}): [MatrixClient, Map<string, Map<string, Device>>] {
const deviceMap = mockDeviceMap(opts.userId, opts.deviceId, opts.deviceKey, opts.devices);
return [
{
doesServerSupportUnstableFeature: jest.fn().mockImplementation((feature) => {
if (feature === "org.matrix.msc3886") {
return opts.msc3886Enabled;
} else if (feature === "org.matrix.msc3882") {
return opts.getLoginTokenEnabled;
} else {
return false;
}
}),
getVersions() {
return {
unstable_features: {
"org.matrix.msc3882": opts.getLoginTokenEnabled,
"org.matrix.msc3886": opts.msc3886Enabled,
},
};
},
getCapabilities() {
return opts.msc3882r0Only
? {}
: {
capabilities: {
"m.get_login_token": {
enabled: opts.getLoginTokenEnabled,
},
},
},
};
},
getUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
getDeviceEd25519Key() {
return opts.deviceKey;
},
baseUrl: "https://example.com",
crypto: {
getStoredDevice(userId: string, deviceId: string) {
return opts.devices?.[deviceId] ?? null;
};
},
setDeviceVerification: opts.verificationFunction,
crossSigningInfo: {
getId(key: string) {
return opts.crossSigningIds?.[key];
},
getUserId() {
return opts.userId;
},
},
} as unknown as MatrixClient;
getSafeUserId() {
return opts.userId;
},
getDeviceId() {
return opts.deviceId;
},
baseUrl: "https://example.com",
getCrypto() {
return {
getUserDeviceInfo(
[userId]: string[],
downloadUncached?: boolean,
): Promise<Map<string, Map<string, Device>>> {
return Promise.resolve(deviceMap);
},
getCrossSigningKeyId(key: CrossSigningKey): string | null {
return opts.crossSigningIds?.[key] ?? null;
},
setDeviceVerified(userId: string, deviceId: string, verified: boolean): Promise<void> {
return Promise.resolve();
},
crossSignDevice(deviceId: string): Promise<void> {
return Promise.resolve();
},
getOwnDeviceKeys(): Promise<OwnDeviceKeys> {
return Promise.resolve({
ed25519: opts.deviceKey!,
curve25519: "aaaa",
});
},
};
},
} as unknown as MatrixClient,
deviceMap,
];
}
function makeTransport(name: string, uri = "https://test.rz/123456") {
@@ -106,6 +174,7 @@ describe("Rendezvous", function () {
let httpBackend: MockHttpBackend;
let fetchFn: typeof global.fetch;
let transports: DummyTransport<any, MSC3903ECDHPayload>[];
const userId: UserID = "@user:example.com";
beforeEach(function () {
httpBackend = new MockHttpBackend();
@@ -118,9 +187,9 @@ describe("Rendezvous", function () {
});
it("generate and cancel", async function () {
const alice = makeMockClient({
userId: "@alice:example.com",
deviceId: "DEVICEID",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
msc3886Enabled: false,
getLoginTokenEnabled: true,
msc3882r0Only: true,
@@ -194,8 +263,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
msc3886Enabled: false,
getLoginTokenEnabled,
@@ -257,8 +326,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -316,8 +385,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -375,7 +444,7 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
const [alice] = makeMockClient({
userId: "alice",
deviceId: "ALICE",
getLoginTokenEnabled: true,
@@ -436,7 +505,7 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const alice = makeMockClient({
const [alice] = makeMockClient({
userId: "alice",
deviceId: "ALICE",
getLoginTokenEnabled: true,
@@ -495,7 +564,7 @@ describe("Rendezvous", function () {
await bobCompleteProm;
});
async function completeLogin(devices: Record<string, Partial<DeviceInfo>>) {
async function completeLogin(devices: SimpleDeviceMap) {
const aliceTransport = makeTransport("Alice", "https://test.rz/123456");
const bobTransport = makeTransport("Bob", "https://test.rz/999999");
transports.push(aliceTransport, bobTransport);
@@ -505,8 +574,8 @@ describe("Rendezvous", function () {
// alice is already signs in and generates a code
const aliceOnFailure = jest.fn();
const aliceVerification = jest.fn();
const alice = makeMockClient({
userId: "alice",
const [alice, deviceMap] = makeMockClient({
userId,
deviceId: "ALICE",
getLoginTokenEnabled: true,
msc3882r0Only: false,
@@ -575,13 +644,14 @@ describe("Rendezvous", function () {
aliceRz,
bobTransport,
bobEcdh,
deviceMap,
};
}
it("approve on existing device + verification", async function () {
const { bobEcdh, aliceRz } = await completeLogin({
BOB: {
getFingerprint: () => "bbbb",
[userId]: {
BOB: "bbbb",
},
});
const verifyProm = aliceRz.verifyNewDeviceOnExistingDevice();
@@ -607,33 +677,29 @@ describe("Rendezvous", function () {
});
it("device appears online within timeout", async function () {
const devices: Record<string, Partial<DeviceInfo>> = {};
const { aliceRz } = await completeLogin(devices);
// device appears after 1 second
const devices: SimpleDeviceMap = {};
const { aliceRz, deviceMap } = await completeLogin(devices);
// device appears before the timeout
setTimeout(() => {
devices.BOB = {
getFingerprint: () => "bbbb",
};
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
}, 1000);
await aliceRz.verifyNewDeviceOnExistingDevice(2000);
});
it("device appears online after timeout", async function () {
const devices: Record<string, Partial<DeviceInfo>> = {};
const { aliceRz } = await completeLogin(devices);
// device appears after 1 second
const devices: SimpleDeviceMap = {};
const { aliceRz, deviceMap } = await completeLogin(devices);
// device appears after the timeout
setTimeout(() => {
devices.BOB = {
getFingerprint: () => "bbbb",
};
deviceMap.get(userId)!.set("BOB", mockDevice(userId, "BOB", "bbbb"));
}, 1500);
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow();
});
it("mismatched device key", async function () {
const { aliceRz } = await completeLogin({
BOB: {
getFingerprint: () => "XXXX",
[userId]: {
BOB: "XXXX",
},
});
await expect(aliceRz.verifyNewDeviceOnExistingDevice(1000)).rejects.toThrow(/different key/);
+7 -2
View File
@@ -27,6 +27,7 @@ import { M_BEACON } from "../../src/@types/beacon";
import { MatrixClient } from "../../src/client";
import { DecryptionError } from "../../src/crypto/algorithms";
import { defer } from "../../src/utils";
import { Room } from "../../src/models/room";
describe("RoomState", function () {
const roomId = "!foo:bar";
@@ -362,9 +363,11 @@ describe("RoomState", function () {
});
it("does not add redacted beacon info events to state", () => {
const mockClient = {} as unknown as MockedObject<MatrixClient>;
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId);
const redactionEvent = new MatrixEvent({ type: "m.room.redaction" });
redactedBeaconEvent.makeRedacted(redactionEvent);
const room = new Room(roomId, mockClient, userA);
redactedBeaconEvent.makeRedacted(redactionEvent, room);
const emitSpy = jest.spyOn(state, "emit");
state.setStateEvents([redactedBeaconEvent]);
@@ -394,11 +397,13 @@ describe("RoomState", function () {
});
it("destroys and removes redacted beacon events", () => {
const mockClient = {} as unknown as MockedObject<MatrixClient>;
const beaconId = "$beacon1";
const beaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactedBeaconEvent = makeBeaconInfoEvent(userA, roomId, { isLive: true }, beaconId);
const redactionEvent = new MatrixEvent({ type: "m.room.redaction", redacts: beaconEvent.getId() });
redactedBeaconEvent.makeRedacted(redactionEvent);
const room = new Room(roomId, mockClient, userA);
redactedBeaconEvent.makeRedacted(redactionEvent, room);
state.setStateEvents([beaconEvent]);
const beaconInstance = state.beacons.get(getBeaconInfoIdentifier(beaconEvent));
+69 -13
View File
@@ -1460,7 +1460,7 @@ describe("Room", function () {
it("should reset the unread count when our non-synthetic receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
room.timeline = [event1, event2];
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
@@ -1479,7 +1479,7 @@ describe("Room", function () {
it("should not reset the unread count when someone else's receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
room.timeline = [event1, event2];
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
@@ -1498,7 +1498,7 @@ describe("Room", function () {
it("should not reset the unread count when our non-synthetic receipt points to an earlier event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
room.timeline = [event1, event2];
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
@@ -1517,7 +1517,7 @@ describe("Room", function () {
it("should not reset the unread count when our a synthetic receipt points to the latest event", () => {
// Given a room with 2 events, and an unread count set.
room.client.isInitialSyncComplete = jest.fn().mockReturnValue(true);
room.timeline = [event1, event2];
jest.spyOn(room, "timeline", "get").mockReturnValue([event1, event2]);
room.setUnread(NotificationCountType.Total, 45);
room.setUnread(NotificationCountType.Highlight, 57);
// Sanity check:
@@ -1743,13 +1743,70 @@ describe("Room", function () {
});
describe("hasUserReadUpTo", function () {
it("should acknowledge if an event has been read", function () {
it("returns true if there is a receipt for this event (main timeline)", function () {
const ts = 13787898424;
room.addLiveEvents([eventToAck]);
room.addReceipt(mkReceipt(roomId, [mkRecord(eventToAck.getId()!, "m.read", userB, ts)]));
room.findEventById = jest.fn().mockReturnValue({} as MatrixEvent);
room.findEventById = jest.fn().mockReturnValue({ getThread: jest.fn() } as unknown as MatrixEvent);
expect(room.hasUserReadEvent(userB, eventToAck.getId()!)).toEqual(true);
});
it("return false for an unknown event", function () {
it("returns true if there is a receipt for a later event (main timeline)", async function () {
// Given some events exist in the room
const events: MatrixEvent[] = [
utils.mkMessage({
room: roomId,
user: userA,
msg: "1111",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "2222",
event: true,
}),
utils.mkMessage({
room: roomId,
user: userA,
msg: "3333",
event: true,
}),
];
await room.addLiveEvents(events);
// When I add a receipt for the latest one
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the older ones are read too
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
expect(room.hasUserReadEvent(userB, events[1].getId()!)).toEqual(true);
});
describe("threads enabled", () => {
beforeEach(() => {
jest.spyOn(room.client, "supportsThreads").mockReturnValue(true);
});
afterEach(() => {
jest.restoreAllMocks();
});
it("returns true if there is an unthreaded receipt for a later event in a thread", async () => {
// Given a thread exists in the room
const { thread, events } = mkThread({ room, length: 3 });
thread.initialEventsFetched = true;
await room.addLiveEvents(events);
// When I add an unthreaded receipt for the latest thread message
room.addReceipt(mkReceipt(roomId, [mkRecord(events[2].getId()!, "m.read", userB, 102)]));
// Then the main timeline message is read
expect(room.hasUserReadEvent(userB, events[0].getId()!)).toEqual(true);
});
});
it("returns false for an unknown event", function () {
expect(room.hasUserReadEvent(userB, "unknown_event")).toEqual(false);
});
});
@@ -3165,7 +3222,6 @@ describe("Room", function () {
// When we ask what they have read
// Then we say "nothing"
expect(room.getEventReadUpTo(userA)).toBeNull();
expect(logger.warn).toHaveBeenCalledWith("Ignoring receipt for missing event with id missingEventId");
});
it("ignores receipts pointing at the wrong thread", () => {
@@ -3259,7 +3315,7 @@ describe("Room", function () {
return event1 === `eventId${i}` ? 1 : -1;
},
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
expect(room.getEventReadUpTo(userA)).toEqual(`eventId${i}`);
}
@@ -3272,7 +3328,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.ReadPrivate) {
return { eventId: "eventId1", data: { ts: i === 1 ? 2 : 1 } } as WrappedReceipt;
@@ -3292,7 +3348,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
room.getReadReceiptForUserId = (userId, ignore, receiptType): WrappedReceipt | null => {
if (receiptType === ReceiptType.Read) {
return { eventId: "eventId2", data: { ts: 1 } } as WrappedReceipt;
@@ -3310,7 +3366,7 @@ describe("Room", function () {
({
compareEventOrdering: () => null,
findEventById: jest.fn().mockReturnValue({} as MatrixEvent),
} as unknown as EventTimelineSet);
}) as unknown as EventTimelineSet;
});
it("should give precedence to m.read.private", () => {
@@ -3654,7 +3710,7 @@ describe("Room", function () {
expect(room.polls.get(pollStartEvent.getId()!)).toBeTruthy();
const redactedEvent = new MatrixEvent({ type: "m.room.redaction" });
pollStartEvent.makeRedacted(redactedEvent);
pollStartEvent.makeRedacted(redactedEvent, room);
await flushPromises();
+16 -6
View File
@@ -23,6 +23,7 @@ import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingReque
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi } from "../../../src";
import { logger, LogSpan } from "../../../src/logger";
afterEach(() => {
fetchMock.mockReset();
@@ -93,10 +94,13 @@ describe("KeyClaimManager", () => {
olmMachine.markRequestAsSent.mockResolvedValueOnce(undefined);
// fire off the request
await keyClaimManager.ensureSessionsForUsers([u1, u2]);
await keyClaimManager.ensureSessionsForUsers(new LogSpan(logger, "test"), [u1, u2]);
// check that all the calls were made
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1, u2]);
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
expect(calledWith).toEqual([u1.toString(), u2.toString()]);
expect(fetchMock).toHaveFetched("https://example.com/_matrix/client/v3/keys/claim", {
method: "POST",
body: { k1: "v1" },
@@ -119,12 +123,13 @@ describe("KeyClaimManager", () => {
let markRequestAsSentPromise = awaitCallToMarkRequestAsSent();
// fire off two requests, and keep track of whether their promises resolve
const span = new LogSpan(logger, "test");
let req1Resolved = false;
keyClaimManager.ensureSessionsForUsers([u1]).then(() => {
keyClaimManager.ensureSessionsForUsers(span, [u1]).then(() => {
req1Resolved = true;
});
let req2Resolved = false;
const req2 = keyClaimManager.ensureSessionsForUsers([u2]).then(() => {
const req2 = keyClaimManager.ensureSessionsForUsers(span, [u2]).then(() => {
req2Resolved = true;
});
@@ -133,7 +138,10 @@ describe("KeyClaimManager", () => {
// at this point, there should have been a single call to getMissingSessions, and a single fetch; and neither
// call to ensureSessionsAsUsers should have completed
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u1]);
// check that all the calls were made
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
const calledWith = olmMachine.getMissingSessions.mock.calls[0][0].map((u) => u.toString());
expect(calledWith).toEqual([u1.toString()]);
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(req1Resolved).toBe(false);
@@ -145,7 +153,9 @@ describe("KeyClaimManager", () => {
resolveMarkRequestAsSentCallback = await markRequestAsSentPromise;
// the first request should now have completed, and we should have more calls and fetches
expect(olmMachine.getMissingSessions).toHaveBeenCalledWith([u2]);
// We can't use directly toHaveBeenCalledWith because the UserId are cloned in the process.
const calledWith2 = olmMachine.getMissingSessions.mock.calls[1][0].map((u) => u.toString());
expect(calledWith2).toEqual([u2.toString()]);
expect(olmMachine.getMissingSessions).toHaveBeenCalledTimes(2);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(req1Resolved).toBe(true);
@@ -27,8 +27,9 @@ import {
UploadSigningKeysRequest,
ToDeviceRequest,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
import { TypedEventEmitter } from "../../../src/models/typed-event-emitter";
import { TypedEventEmitter } from "../../../src";
import { HttpApiEvent, HttpApiEventHandlerMap, IHttpOpts, MatrixHttpApi, UIAuthCallback } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { defer } from "../../../src/utils";
@@ -274,4 +275,302 @@ describe("OutgoingRequestProcessor", () => {
// ... and `makeOutgoingRequest` resolves satisfactorily
await result;
});
describe("Should retry requests", () => {
beforeEach(() => {
jest.useFakeTimers();
// here we use another httpApi instance in order to use fetchMock
const dummyEventEmitter = new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>();
const httpApi = new MatrixHttpApi(dummyEventEmitter, {
baseUrl: "https://example.com",
prefix: "/_matrix",
onlyData: true,
});
processor = new OutgoingRequestProcessor(olmMachine, httpApi);
});
afterEach(() => {
jest.useRealTimers();
fetchMock.reset();
});
describe("Should retry on retryable errors", () => {
const retryableErrors: Array<[number, { status: number; body: { error: string } }]> = [
[429, { status: 429, body: { error: "Too Many Requests" } }],
[500, { status: 500, body: { error: "Internal Server Error" } }],
[502, { status: 502, body: { error: "Bad Gateway" } }],
[503, { status: 503, body: { error: "Service Unavailable" } }],
[504, { status: 504, body: { error: "Gateway timeout" } }],
[505, { status: 505, body: { error: "HTTP Version Not Supported" } }],
[506, { status: 506, body: { error: "Variant Also Negotiates" } }],
[507, { status: 507, body: { error: "Insufficient Storage" } }],
[508, { status: 508, body: { error: "Loop Detected" } }],
[510, { status: 510, body: { error: "Not Extended" } }],
[511, { status: 511, body: { error: "Network Authentication Required" } }],
[525, { status: 525, body: { error: "SSL Handshake Failed" } }],
];
describe.each(retryableErrors)(`When status code is %s`, (_, error) => {
test.each(tests)(`for request of type %ss`, async (_, RequestClass, expectedMethod, expectedPath) => {
// first, mock up a request as we might expect to receive it from the Rust layer ...
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
fetchMock.mock(expectedPath, error, { method: expectedMethod });
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
// Run all timers and wait for the request promise to resolve/reject
await Promise.all([jest.runAllTimersAsync(), requestPromise.catch(() => {})]);
await expect(requestPromise).rejects.toThrow();
// Should have ultimately made 5 requests (1 initial + 4 retries)
const calls = fetchMock.calls(expectedPath);
expect(calls).toHaveLength(5);
// The promise should have been rejected
await expect(requestPromise).rejects.toThrow();
});
});
});
it("should not retry if M_TOO_LARGE", async () => {
const testBody = '{ "messages": { "user": {"device": "bar" }}}';
const outgoingRequest = new ToDeviceRequest("1234", "custom.type", "12345", testBody);
fetchMock.put("express:/_matrix/client/v3/sendToDevice/:type/:txnId", {
status: 502,
body: {
errcode: "M_TOO_LARGE",
error: "Request too large",
},
});
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
await Promise.all([requestPromise.catch(() => {}), jest.runAllTimersAsync()]);
await expect(requestPromise).rejects.toThrow();
const calls = fetchMock.calls("express:/_matrix/client/v3/sendToDevice/:type/:txnId");
expect(calls).toHaveLength(1);
// The promise should have been rejected
await expect(requestPromise).rejects.toThrow();
});
it("should retry on Failed to fetch connection errors", async () => {
let callCount = 0;
fetchMock.post("path:/_matrix/client/v3/keys/upload", (url, opts) => {
callCount++;
if (callCount == 2) {
return {
status: 200,
body: "{}",
};
} else {
throw new Error("Failed to fetch");
}
});
const outgoingRequest = new KeysUploadRequest("1234", "{}");
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
await Promise.all([requestPromise, jest.runAllTimersAsync()]);
const calls = fetchMock.calls("path:/_matrix/client/v3/keys/upload");
expect(calls).toHaveLength(2);
expect(olmMachine.markRequestAsSent).toHaveBeenCalled();
});
it("should retry to send to-device", async () => {
let callCount = 0;
const testBody = '{ "messages": { "user": {"device": "bar" }}}';
const outgoingRequest = new ToDeviceRequest("1234", "custom.type", "12345", testBody);
fetchMock.put("express:/_matrix/client/v3/sendToDevice/:type/:txnId", (url, opts) => {
callCount++;
if (callCount == 2) {
return {
status: 200,
body: "{}",
};
} else {
throw new Error("Failed to fetch");
}
});
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
await Promise.all([requestPromise, jest.runAllTimersAsync()]);
const calls = fetchMock.calls("express:/_matrix/client/v3/sendToDevice/:type/:txnId");
expect(calls).toHaveLength(2);
expect(olmMachine.markRequestAsSent).toHaveBeenCalled();
});
it("should retry to call with UIA", async () => {
let callCount = 0;
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new UploadSigningKeysRequest(testBody);
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", (url, opts) => {
callCount++;
if (callCount == 2) {
return {
status: 200,
body: "{}",
};
} else {
throw new Error("Failed to fetch");
}
});
const authCallback: UIAuthCallback<Object> = async (makeRequest) => {
return await makeRequest({ type: "test" });
};
const requestPromise = processor.makeOutgoingRequest(outgoingRequest, authCallback);
await Promise.all([requestPromise, jest.runAllTimersAsync()]);
const calls = fetchMock.calls("path:/_matrix/client/v3/keys/device_signing/upload");
expect(calls).toHaveLength(2);
// Will not mark as sent as it's a UIA request
});
it("should retry on respect server cool down on LIMIT_EXCEEDED", async () => {
const retryAfterMs = 5000;
let callCount = 0;
fetchMock.post("path:/_matrix/client/v3/keys/upload", (url, opts) => {
callCount++;
if (callCount == 2) {
return {
status: 200,
body: "{}",
};
} else {
return {
status: 429,
body: {
errcode: "M_LIMIT_EXCEEDED",
error: "Too many requests",
retry_after_ms: retryAfterMs,
},
};
}
});
const outgoingRequest = new KeysUploadRequest("1234", "{}");
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
// advanced by less than the retryAfterMs
await jest.advanceTimersByTimeAsync(retryAfterMs - 1000);
// should not have made a second request yet
{
const calls = fetchMock.calls("path:/_matrix/client/v3/keys/upload");
expect(calls).toHaveLength(1);
}
// advanced by the remaining time
await jest.advanceTimersByTimeAsync(retryAfterMs + 1000);
await requestPromise;
const calls = fetchMock.calls("path:/_matrix/client/v3/keys/upload");
expect(calls).toHaveLength(2);
expect(olmMachine.markRequestAsSent).toHaveBeenCalled();
});
const nonRetryableErrors: Array<[number, { status: number; body: { errcode: string } }]> = [
[400, { status: 400, body: { errcode: "Bad Request" } }],
[401, { status: 401, body: { errcode: "M_UNKNOWN_TOKEN" } }],
[403, { status: 403, body: { errcode: "M_FORBIDDEN" } }],
];
describe.each(nonRetryableErrors)("Should not retry all sort of errors", (_, error) => {
test.each(tests)("for %ss", async (_, RequestClass, expectedMethod, expectedPath) => {
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
// @ts-ignore to avoid having to do if else to switch the method (.put/.post)
fetchMock[expectedMethod.toLowerCase()](expectedPath, {
status: error.status,
body: error.body,
});
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
// Run all timers and wait for the request promise to resolve/reject
await Promise.all([jest.runAllTimersAsync(), requestPromise.catch(() => {})]);
await expect(requestPromise).rejects.toThrow();
// Should have only tried once
const calls = fetchMock.calls(expectedPath);
expect(calls).toHaveLength(1);
await expect(requestPromise).rejects.toThrow();
});
});
describe("Should not retry client timeouts", () => {
test.each(tests)("for %ss", async (_, RequestClass, expectedMethod, expectedPath) => {
const testBody = '{ "foo": "bar" }';
const outgoingRequest = new RequestClass("1234", testBody);
// @ts-ignore to avoid having to do if else to switch the method (.put/.post)
fetchMock[expectedMethod.toLowerCase()](expectedPath, () => {
// This is what a client timeout error will throw
throw new DOMException("The user aborted a request.", "AbortError");
});
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
// Run all timers and wait for the request promise to resolve/reject
await Promise.all([jest.runAllTimersAsync(), requestPromise.catch(() => {})]);
await expect(requestPromise).rejects.toThrow();
// Should have only tried once
const calls = fetchMock.calls(expectedPath);
expect(calls).toHaveLength(1);
await expect(requestPromise).rejects.toThrow();
});
});
describe("Should retry until it works", () => {
it.each([1, 2, 3, 4])("should succeed if the call number %s is ok", async (successfulCall) => {
let callCount = 0;
fetchMock.post("path:/_matrix/client/v3/keys/upload", (url, opts) => {
callCount++;
if (callCount == successfulCall) {
return {
status: 200,
body: "{}",
};
} else {
return {
status: 500,
body: { error: "Internal server error" },
};
}
});
const outgoingRequest = new KeysUploadRequest("1234", "{}");
const requestPromise = processor.makeOutgoingRequest(outgoingRequest);
await Promise.all([requestPromise, jest.runAllTimersAsync()]);
const calls = fetchMock.calls("path:/_matrix/client/v3/keys/upload");
expect(calls).toHaveLength(successfulCall);
expect(olmMachine.markRequestAsSent).toHaveBeenCalled();
});
});
});
});
@@ -0,0 +1,598 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { Mocked, SpyInstance } from "jest-mock";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import fetchMock from "fetch-mock-jest";
import { PerSessionKeyBackupDownloader } from "../../../src/rust-crypto/PerSessionKeyBackupDownloader";
import { logger } from "../../../src/logger";
import { defer, IDeferred } from "../../../src/utils";
import { RustBackupCryptoEventMap, RustBackupCryptoEvents, RustBackupManager } from "../../../src/rust-crypto/backup";
import * as TestData from "../../test-utils/test-data";
import {
ConnectionError,
CryptoEvent,
HttpApiEvent,
HttpApiEventHandlerMap,
IHttpOpts,
IMegolmSessionData,
MatrixHttpApi,
TypedEventEmitter,
} from "../../../src";
import * as testData from "../../test-utils/test-data";
import { BackupDecryptor } from "../../../src/common-crypto/CryptoBackend";
import { KeyBackupSession } from "../../../src/crypto-api/keybackup";
describe("PerSessionKeyBackupDownloader", () => {
/** The downloader under test */
let downloader: PerSessionKeyBackupDownloader;
const mockCipherKey: Mocked<KeyBackupSession> = {} as unknown as Mocked<KeyBackupSession>;
// matches the const in PerSessionKeyBackupDownloader
const BACKOFF_TIME = 5000;
let mockEmitter: TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap>;
let mockHttp: MatrixHttpApi<IHttpOpts & { onlyData: true }>;
let mockRustBackupManager: Mocked<RustBackupManager>;
let mockOlmMachine: Mocked<OlmMachine>;
let mockBackupDecryptor: Mocked<BackupDecryptor>;
let expectedSession: { [roomId: string]: { [sessionId: string]: IDeferred<void> } };
function expectSessionImported(roomId: string, sessionId: string) {
const deferred = defer<void>();
if (!expectedSession[roomId]) {
expectedSession[roomId] = {};
}
expectedSession[roomId][sessionId] = deferred;
return deferred.promise;
}
function mockClearSession(sessionId: string): Mocked<IMegolmSessionData> {
return {
session_id: sessionId,
} as unknown as Mocked<IMegolmSessionData>;
}
beforeEach(async () => {
mockEmitter = new TypedEventEmitter() as TypedEventEmitter<RustBackupCryptoEvents, RustBackupCryptoEventMap>;
mockHttp = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
mockBackupDecryptor = {
decryptSessions: jest.fn(),
} as unknown as Mocked<BackupDecryptor>;
mockBackupDecryptor.decryptSessions.mockImplementation(async (ciphertexts) => {
const sessionId = Object.keys(ciphertexts)[0];
return [mockClearSession(sessionId)];
});
mockRustBackupManager = {
getActiveBackupVersion: jest.fn(),
requestKeyBackupVersion: jest.fn(),
importBackedUpRoomKeys: jest.fn(),
createBackupDecryptor: jest.fn().mockReturnValue(mockBackupDecryptor),
on: jest.fn().mockImplementation((event, listener) => {
mockEmitter.on(event, listener);
}),
off: jest.fn().mockImplementation((event, listener) => {
mockEmitter.off(event, listener);
}),
} as unknown as Mocked<RustBackupManager>;
mockOlmMachine = {
getBackupKeys: jest.fn(),
} as unknown as Mocked<OlmMachine>;
downloader = new PerSessionKeyBackupDownloader(logger, mockOlmMachine, mockHttp, mockRustBackupManager);
expectedSession = {};
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
const roomId = keys[0].room_id;
const sessionId = keys[0].session_id;
const deferred = expectedSession[roomId] && expectedSession[roomId][sessionId];
if (deferred) {
deferred.resolve();
}
});
jest.useFakeTimers();
});
afterEach(() => {
expectedSession = {};
downloader.stop();
fetchMock.mockReset();
jest.useRealTimers();
});
describe("Given valid backup available", () => {
beforeEach(async () => {
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
mockOlmMachine.getBackupKeys.mockResolvedValue({
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
});
it("Should download and import a missing key from backup", async () => {
const awaitKeyImported = defer<void>();
const roomId = "!roomId";
const sessionId = "sessionId";
const expectAPICall = new Promise<void>((resolve) => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/${roomId}/${sessionId}`, (url, request) => {
resolve();
return TestData.CURVE25519_KEY_BACKUP_DATA;
});
});
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
awaitKeyImported.resolve();
});
mockBackupDecryptor.decryptSessions.mockResolvedValue([TestData.MEGOLM_SESSION_DATA]);
downloader.onDecryptionKeyMissingError(roomId, sessionId);
await expectAPICall;
await awaitKeyImported.promise;
expect(mockRustBackupManager.createBackupDecryptor).toHaveBeenCalledTimes(1);
});
it("Should not hammer the backup if the key is requested repeatedly", async () => {
const blockOnServerRequest = defer<void>();
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/!roomId/:session_id`, async (url, request) => {
await blockOnServerRequest.promise;
return [mockCipherKey];
});
const awaitKey2Imported = defer<void>();
mockRustBackupManager.importBackedUpRoomKeys.mockImplementation(async (keys) => {
if (keys[0].session_id === "sessionId2") {
awaitKey2Imported.resolve();
}
});
// @ts-ignore access to private function
const spy = jest.spyOn(downloader, "queryKeyBackup");
// Call 3 times for same key
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
// Call again for a different key
downloader.onDecryptionKeyMissingError("!roomId", "sessionId2");
// Allow the first server request to complete
blockOnServerRequest.resolve();
await awaitKey2Imported.promise;
expect(spy).toHaveBeenCalledTimes(2);
});
it("should continue to next key if current not in backup", async () => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA0`, {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "No backup found",
},
});
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA1`, mockCipherKey);
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const expectImported = expectSessionImported("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveLastReturnedWith(Promise.resolve({ ok: false, error: "MISSING_DECRYPTION_KEY" }));
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(2);
await expectImported;
});
it("Should not query repeatedly for a key not in backup", async () => {
fetchMock.get(`path:/_matrix/client/v3/room_keys/keys/!roomA/sessionA0`, {
status: 404,
body: {
errcode: "M_NOT_FOUND",
error: "No backup found",
},
});
// @ts-ignore access to private function
const spy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
const returnedPromise = spy.mock.results[0].value;
await expect(returnedPromise).rejects.toThrow("Failed to get key from backup: MISSING_DECRYPTION_KEY");
// Should not query again for a key not in backup
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(1);
// advance time to retry
jest.advanceTimersByTime(BACKOFF_TIME + 10);
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await jest.runAllTimersAsync();
expect(spy).toHaveBeenCalledTimes(2);
await expect(spy.mock.results[1].value).rejects.toThrow(
"Failed to get key from backup: MISSING_DECRYPTION_KEY",
);
});
it("Should stop properly", async () => {
// Simulate a call to stop while request is in flight
const blockOnServerRequest = defer<void>();
const requestRoomKeyCalled = defer<void>();
// Mock the request to block
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, async (url, request) => {
requestRoomKeyCalled.resolve();
await blockOnServerRequest.promise;
return mockCipherKey;
});
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA2");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA3");
await requestRoomKeyCalled.promise;
downloader.stop();
blockOnServerRequest.resolve();
// let the first request complete
await jest.runAllTimersAsync();
expect(mockRustBackupManager.importBackedUpRoomKeys).not.toHaveBeenCalled();
expect(
fetchMock.calls(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`).length,
).toStrictEqual(1);
});
});
describe("Given no usable backup available", () => {
let getConfigSpy: SpyInstance;
beforeEach(async () => {
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
// @ts-ignore access to private function
getConfigSpy = jest.spyOn(downloader, "getOrCreateBackupConfiguration");
});
it("Should not query server if no backup", async () => {
fetchMock.get("path:/_matrix/client/v3/room_keys/version", {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "No current backup version." },
});
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
});
it("Should not query server if backup not active", async () => {
// there is a backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// but it's not trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
});
it("Should stop if backup key is not cached", async () => {
// there is a backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// it is trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// but the key is not cached
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
});
it("Should stop if backup key cached as wrong version", async () => {
// there is a backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// it is trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// but the cached key has the wrong version
mockOlmMachine.getBackupKeys.mockResolvedValue({
backupVersion: "0",
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
});
it("Should stop if backup key version does not match the active one", async () => {
// there is a backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
// The sdk is out of sync, the trusted version is the old one
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue("0");
// key for old backup cached
mockOlmMachine.getBackupKeys.mockResolvedValue({
backupVersion: "0",
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
downloader.onDecryptionKeyMissingError("!roomId", "sessionId");
await jest.runAllTimersAsync();
expect(getConfigSpy).toHaveBeenCalledTimes(1);
expect(getConfigSpy).toHaveReturnedWith(Promise.resolve(null));
});
});
describe("Given Backup state update", () => {
it("After initial sync, when backup becomes trusted it should request keys for past requests", async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// but at this point it's not trusted and we don't have the key
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(null);
mockOlmMachine.getBackupKeys.mockResolvedValue(null);
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey);
const a0Imported = expectSessionImported("!roomA", "sessionA0");
const a1Imported = expectSessionImported("!roomA", "sessionA1");
const b1Imported = expectSessionImported("!roomB", "sessionB1");
const c1Imported = expectSessionImported("!roomC", "sessionC1");
// During initial sync several keys are requested
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
downloader.onDecryptionKeyMissingError("!roomB", "sessionB1");
downloader.onDecryptionKeyMissingError("!roomC", "sessionC1");
await jest.runAllTimersAsync();
// @ts-ignore access to private property
expect(downloader.hasConfigurationProblem).toEqual(true);
// Now the backup becomes trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// And we have the key in cache
mockOlmMachine.getBackupKeys.mockResolvedValue({
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
// In that case the sdk would fire a backup status update
mockEmitter.emit(CryptoEvent.KeyBackupStatus, true);
await jest.runAllTimersAsync();
await a0Imported;
await a1Imported;
await b1Imported;
await c1Imported;
});
});
describe("Error cases", () => {
beforeEach(async () => {
// there is a backup
mockRustBackupManager.requestKeyBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA);
// It's trusted
mockRustBackupManager.getActiveBackupVersion.mockResolvedValue(TestData.SIGNED_BACKUP_DATA.version!);
// And we have the key in cache
mockOlmMachine.getBackupKeys.mockResolvedValue({
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys);
});
it("Should wait on rate limit error", async () => {
// simulate rate limit error
fetchMock.get(
`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`,
{
status: 429,
body: {
errcode: "M_LIMIT_EXCEEDED",
error: "Too many requests",
retry_after_ms: 5000,
},
},
{ overwriteRoutes: true },
);
const keyImported = expectSessionImported("!roomA", "sessionA0");
// @ts-ignore
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const rateDeferred = defer<void>();
keyQuerySpy.mockImplementation(
// @ts-ignore
async (targetRoomId: string, targetSessionId: string, configuration: any) => {
try {
return await originalImplementation(targetRoomId, targetSessionId, configuration);
} catch (err: any) {
if (err.name === "KeyDownloadRateLimitError") {
rateDeferred.resolve();
}
throw err;
}
},
);
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await rateDeferred.promise;
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
await expect(keyQuerySpy.mock.results[0].value).rejects.toThrow(
"Failed to get key from backup: rate limited",
);
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
overwriteRoutes: true,
});
// Advance less than the retry_after_ms
jest.advanceTimersByTime(100);
// let any pending callbacks in PromiseJobs run
await Promise.resolve();
// no additional call should have been made
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
// The loop should resume after the retry_after_ms
jest.advanceTimersByTime(5000);
// let any pending callbacks in PromiseJobs run
await Promise.resolve();
await keyImported;
expect(keyQuerySpy).toHaveBeenCalledTimes(2);
});
it("After a network error the same key is retried", async () => {
// simulate connectivity error
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, () => {
throw new ConnectionError("fetch failed", new Error("fetch failed"));
});
// @ts-ignore
const originalImplementation = downloader.queryKeyBackup.bind(downloader);
// @ts-ignore
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
const errorDeferred = defer<void>();
keyQuerySpy.mockImplementation(
// @ts-ignore
async (targetRoomId: string, targetSessionId: string, configuration: any) => {
try {
return await originalImplementation(targetRoomId, targetSessionId, configuration);
} catch (err: any) {
if (err.name === "KeyDownloadError") {
errorDeferred.resolve();
}
throw err;
}
},
);
const keyImported = expectSessionImported("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
await errorDeferred.promise;
await Promise.resolve();
await expect(keyQuerySpy.mock.results[0].value).rejects.toThrow(
"Failed to get key from backup: NETWORK_ERROR",
);
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
overwriteRoutes: true,
});
// Advance less than the retry_after_ms
jest.advanceTimersByTime(100);
// let any pending callbacks in PromiseJobs run
await Promise.resolve();
// no additional call should have been made
expect(keyQuerySpy).toHaveBeenCalledTimes(1);
// The loop should resume after the retry_after_ms
jest.advanceTimersByTime(BACKOFF_TIME + 100);
await Promise.resolve();
await keyImported;
});
it("On Unknown error on import skip the key and continue", async () => {
const keyImported = defer<void>();
mockRustBackupManager.importBackedUpRoomKeys
.mockImplementationOnce(async () => {
throw new Error("Didn't work");
})
.mockImplementationOnce(async (sessions) => {
const roomId = sessions[0].room_id;
const sessionId = sessions[0].session_id;
if (roomId === "!roomA" && sessionId === "sessionA1") {
keyImported.resolve();
}
return;
});
fetchMock.get(`express:/_matrix/client/v3/room_keys/keys/:roomId/:sessionId`, mockCipherKey, {
overwriteRoutes: true,
});
// @ts-ignore access to private function
const keyQuerySpy: SpyInstance = jest.spyOn(downloader, "queryKeyBackup");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA0");
downloader.onDecryptionKeyMissingError("!roomA", "sessionA1");
await jest.runAllTimersAsync();
await keyImported.promise;
expect(keyQuerySpy).toHaveBeenCalledTimes(2);
expect(mockRustBackupManager.importBackedUpRoomKeys).toHaveBeenCalledTimes(2);
});
});
});
+163 -10
View File
@@ -16,16 +16,169 @@
* /
*/
import { HistoryVisibility as RustHistoryVisibility } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
Curve25519PublicKey,
Ed25519PublicKey,
HistoryVisibility as RustHistoryVisibility,
IdentityKeys,
OlmMachine,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { Mocked } from "jest-mock";
import { HistoryVisibility } from "../../../src";
import { toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
import { HistoryVisibility, MatrixEvent, Room, RoomMember } from "../../../src";
import { RoomEncryptor, toRustHistoryVisibility } from "../../../src/rust-crypto/RoomEncryptor";
import { KeyClaimManager } from "../../../src/rust-crypto/KeyClaimManager";
import { defer } from "../../../src/utils";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
it.each([
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
describe("RoomEncryptor", () => {
describe("History Visibility", () => {
it.each([
[HistoryVisibility.Invited, RustHistoryVisibility.Invited],
[HistoryVisibility.Joined, RustHistoryVisibility.Joined],
[HistoryVisibility.Shared, RustHistoryVisibility.Shared],
[HistoryVisibility.WorldReadable, RustHistoryVisibility.WorldReadable],
])("JS HistoryVisibility to Rust HistoryVisibility: converts %s to %s", (historyVisibility, expected) => {
expect(toRustHistoryVisibility(historyVisibility)).toBe(expected);
});
});
describe("RoomEncryptor", () => {
/** The room encryptor under test */
let roomEncryptor: RoomEncryptor;
let mockOlmMachine: Mocked<OlmMachine>;
let mockKeyClaimManager: Mocked<KeyClaimManager>;
let mockOutgoingRequestManager: Mocked<OutgoingRequestsManager>;
let mockRoom: Mocked<Room>;
const mockRoomMember = {
userId: "@alice:example.org",
membership: "join",
} as unknown as Mocked<RoomMember>;
function createMockEvent(text: string): Mocked<MatrixEvent> {
return {
getTxnId: jest.fn().mockReturnValue(""),
getType: jest.fn().mockReturnValue("m.room.message"),
getContent: jest.fn().mockReturnValue({
body: text,
msgtype: "m.text",
}),
makeEncrypted: jest.fn().mockReturnValue(undefined),
} as unknown as Mocked<MatrixEvent>;
}
beforeEach(() => {
mockOlmMachine = {
identityKeys: {
curve25519: {
toBase64: jest.fn().mockReturnValue("curve25519"),
} as unknown as Curve25519PublicKey,
ed25519: {
toBase64: jest.fn().mockReturnValue("ed25519"),
} as unknown as Ed25519PublicKey,
} as unknown as Mocked<IdentityKeys>,
shareRoomKey: jest.fn(),
updateTrackedUsers: jest.fn().mockResolvedValue(undefined),
encryptRoomEvent: jest.fn().mockResolvedValue("{}"),
} as unknown as Mocked<OlmMachine>;
mockKeyClaimManager = {
ensureSessionsForUsers: jest.fn(),
} as unknown as Mocked<KeyClaimManager>;
mockOutgoingRequestManager = {
doProcessOutgoingRequests: jest.fn().mockResolvedValue(undefined),
} as unknown as Mocked<OutgoingRequestsManager>;
mockRoom = {
roomId: "!foo:example.org",
getJoinedMembers: jest.fn().mockReturnValue([mockRoomMember]),
getEncryptionTargetMembers: jest.fn().mockReturnValue([mockRoomMember]),
shouldEncryptForInvitedMembers: jest.fn().mockReturnValue(true),
getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Invited),
getBlacklistUnverifiedDevices: jest.fn().mockReturnValue(false),
} as unknown as Mocked<Room>;
roomEncryptor = new RoomEncryptor(
mockOlmMachine,
mockKeyClaimManager,
mockOutgoingRequestManager,
mockRoom,
{ algorithm: "m.megolm.v1.aes-sha2" },
);
});
it("should ensure that there is only one shareRoomKey at a time", async () => {
const deferredShare = defer<void>();
const insideOlmShareRoom = defer<void>();
mockOlmMachine.shareRoomKey.mockImplementationOnce(async () => {
insideOlmShareRoom.resolve();
await deferredShare.promise;
});
roomEncryptor.prepareForEncryption(false);
await insideOlmShareRoom.promise;
// call several times more
roomEncryptor.prepareForEncryption(false);
roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
roomEncryptor.prepareForEncryption(false);
roomEncryptor.encryptEvent(createMockEvent("World"), false);
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(1);
deferredShare.resolve();
await roomEncryptor.prepareForEncryption(false);
// should have been called again
expect(mockOlmMachine.shareRoomKey).toHaveBeenCalledTimes(6);
});
// Regression test for https://github.com/element-hq/element-web/issues/26684
it("Should maintain order of encryption requests", async () => {
const firstTargetMembers = defer<void>();
const secondTargetMembers = defer<void>();
mockOlmMachine.shareRoomKey.mockResolvedValue(undefined);
// Hook into this method to demonstrate the race condition
mockRoom.getEncryptionTargetMembers
.mockImplementationOnce(async () => {
await firstTargetMembers.promise;
return [mockRoomMember];
})
.mockImplementationOnce(async () => {
await secondTargetMembers.promise;
return [mockRoomMember];
});
let firstMessageFinished: string | null = null;
const firstRequest = roomEncryptor.encryptEvent(createMockEvent("Hello"), false);
const secondRequest = roomEncryptor.encryptEvent(createMockEvent("Edit of Hello"), false);
firstRequest.then(() => {
if (firstMessageFinished === null) {
firstMessageFinished = "hello";
}
});
secondRequest.then(() => {
if (firstMessageFinished === null) {
firstMessageFinished = "edit";
}
});
// suppose the second getEncryptionTargetMembers call returns first
secondTargetMembers.resolve();
firstTargetMembers.resolve();
await Promise.all([firstRequest, secondRequest]);
expect(firstMessageFinished).toBe("hello");
});
});
});
+144
View File
@@ -0,0 +1,144 @@
import { Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { CryptoEvent, HttpApiEvent, HttpApiEventHandlerMap, MatrixHttpApi, TypedEventEmitter } from "../../../src";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import * as testData from "../../test-utils/test-data";
import * as TestData from "../../test-utils/test-data";
import { IKeyBackup } from "../../../src/crypto/backup";
import { IKeyBackupSession } from "../../../src/crypto/keybackup";
import { RustBackupManager } from "../../../src/rust-crypto/backup";
describe("Upload keys to backup", () => {
/** The backup manager under test */
let rustBackupManager: RustBackupManager;
let mockOlmMachine: Mocked<RustSdkCryptoJs.OlmMachine>;
let outgoingRequestProcessor: Mocked<OutgoingRequestProcessor>;
const httpAPi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
let idGenerator = 0;
function mockBackupRequest(keyCount: number): RustSdkCryptoJs.KeysBackupRequest {
const requestBody: IKeyBackup = {
rooms: {
"!room1:server": {
sessions: {},
},
},
};
for (let i = 0; i < keyCount; i++) {
requestBody.rooms["!room1:server"].sessions["session" + i] = {} as IKeyBackupSession;
}
return {
id: "id" + idGenerator++,
body: JSON.stringify(requestBody),
} as unknown as Mocked<RustSdkCryptoJs.KeysBackupRequest>;
}
beforeEach(async () => {
jest.useFakeTimers();
idGenerator = 0;
mockOlmMachine = {
getBackupKeys: jest.fn().mockResolvedValue({
backupVersion: TestData.SIGNED_BACKUP_DATA.version!,
decryptionKey: RustSdkCryptoJs.BackupDecryptionKey.fromBase64(TestData.BACKUP_DECRYPTION_KEY_BASE64),
} as unknown as RustSdkCryptoJs.BackupKeys),
backupRoomKeys: jest.fn(),
isBackupEnabled: jest.fn().mockResolvedValue(true),
enableBackupV1: jest.fn(),
verifyBackup: jest.fn().mockResolvedValue({
trusted: jest.fn().mockResolvedValue(true),
} as unknown as RustSdkCryptoJs.SignatureVerification),
roomKeyCounts: jest.fn(),
} as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
rustBackupManager = new RustBackupManager(mockOlmMachine, httpAPi, outgoingRequestProcessor);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
});
afterEach(() => {
fetchMock.reset();
jest.useRealTimers();
jest.resetAllMocks();
});
it("Should call expensive roomKeyCounts only once per loop", async () => {
const remainingEmitted: number[] = [];
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
remainingEmitted.push(count);
if (count == 0) {
resolve();
}
});
});
// We want several batch of keys to check that we don't call expensive room key count several times
mockOlmMachine.backupRoomKeys
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(100))
.mockResolvedValueOnce(mockBackupRequest(2))
.mockResolvedValue(null);
mockOlmMachine.roomKeyCounts.mockResolvedValue({
total: 602,
// First iteration won't call roomKeyCounts(); it will be called on the second iteration after 200 keys have been saved.
backedUp: 200,
});
await rustBackupManager.checkKeyBackupAndEnable(false);
await jest.runAllTimersAsync();
await zeroRemainingWasEmitted;
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(7);
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(1);
// check event emission
expect(remainingEmitted[0]).toEqual(402);
expect(remainingEmitted[1]).toEqual(302);
expect(remainingEmitted[2]).toEqual(202);
expect(remainingEmitted[3]).toEqual(102);
expect(remainingEmitted[4]).toEqual(2);
expect(remainingEmitted[5]).toEqual(0);
});
it("Should not call expensive roomKeyCounts when only one iteration is needed", async () => {
const zeroRemainingWasEmitted = new Promise<void>((resolve) => {
rustBackupManager.on(CryptoEvent.KeyBackupSessionsRemaining, (count) => {
if (count == 0) {
resolve();
}
});
});
// Only returns 2 keys on the first call, then none.
mockOlmMachine.backupRoomKeys.mockResolvedValueOnce(mockBackupRequest(2)).mockResolvedValue(null);
await rustBackupManager.checkKeyBackupAndEnable(false);
await jest.runAllTimersAsync();
await zeroRemainingWasEmitted;
expect(outgoingRequestProcessor.makeOutgoingRequest).toHaveBeenCalledTimes(1);
expect(mockOlmMachine.roomKeyCounts).toHaveBeenCalledTimes(0);
});
});
+583 -64
View File
@@ -15,7 +15,15 @@ limitations under the License.
*/
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { KeysQueryRequest, OlmMachine } from "@matrix-org/matrix-sdk-crypto-wasm";
import {
BaseMigrationData,
KeysQueryRequest,
Migration,
OlmMachine,
PickledInboundGroupSession,
PickledSession,
StoreHandle,
} from "@matrix-org/matrix-sdk-crypto-wasm";
import { mocked, Mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
@@ -25,6 +33,7 @@ import {
CryptoEvent,
Device,
DeviceVerification,
encodeBase64,
HttpApiEvent,
HttpApiEventHandlerMap,
IHttpOpts,
@@ -32,13 +41,20 @@ import {
MatrixClient,
MatrixEvent,
MatrixHttpApi,
MemoryCryptoStore,
TypedEventEmitter,
} from "../../../src";
import { mkEvent } from "../../test-utils/test-utils";
import { CryptoBackend } from "../../../src/common-crypto/CryptoBackend";
import { IEventDecryptionResult } from "../../../src/@types/crypto";
import { IEventDecryptionResult, IMegolmSessionData } from "../../../src/@types/crypto";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { ServerSideSecretStorage } from "../../../src/secret-storage";
import {
AccountDataClient,
AddSecretStorageKeyOpts,
SecretStorageCallbacks,
ServerSideSecretStorage,
ServerSideSecretStorageImpl,
} from "../../../src/secret-storage";
import {
CryptoCallbacks,
EventShieldColour,
@@ -51,10 +67,20 @@ import * as testData from "../../test-utils/test-data";
import { defer } from "../../../src/utils";
import { logger } from "../../../src/logger";
import { OutgoingRequestsManager } from "../../../src/rust-crypto/OutgoingRequestsManager";
import { ClientEvent, ClientEventHandlerMap } from "../../../src/client";
import { Curve25519AuthData } from "../../../src/crypto-api/keybackup";
import { encryptAES } from "../../../src/crypto/aes";
import { CryptoStore, SecretStorePrivateKeys } from "../../../src/crypto/store/base";
const TEST_USER = "@alice:example.com";
const TEST_DEVICE_ID = "TEST_DEVICE";
beforeAll(async () => {
// Load the WASM upfront, before any of the tests. This can take some time, and doing it here means that it gets
// a separate timeout.
await RustSdkCryptoJs.initAsync();
}, 15000);
afterEach(() => {
fetchMock.reset();
jest.restoreAllMocks();
@@ -65,71 +91,287 @@ describe("initRustCrypto", () => {
return {
registerRoomKeyUpdatedCallback: jest.fn(),
registerUserIdentityUpdatedCallback: jest.fn(),
getSecretsFromInbox: jest.fn().mockResolvedValue(["dGhpc2lzYWZha2VzZWNyZXQ="]),
getSecretsFromInbox: jest.fn().mockResolvedValue([]),
deleteSecretsFromInbox: jest.fn(),
registerReceiveSecretCallback: jest.fn(),
registerDevicesUpdatedCallback: jest.fn(),
outgoingRequests: jest.fn(),
isBackupEnabled: jest.fn().mockResolvedValue(false),
verifyBackup: jest.fn().mockResolvedValue({ trusted: jest.fn().mockReturnValue(false) }),
getBackupKeys: jest.fn(),
} as unknown as Mocked<OlmMachine>;
}
it("passes through the store params", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto(
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
"storePrefix",
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
});
expect(OlmMachine.initialize).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
"storePrefix",
"storePassphrase",
);
expect(StoreHandle.open).toHaveBeenCalledWith("storePrefix", "storePassphrase");
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("suppresses the storePassphrase if storePrefix is unset", async () => {
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto(
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
null,
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: null,
storePassphrase: "storePassphrase",
});
expect(OlmMachine.initialize).toHaveBeenCalledWith(expect.anything(), expect.anything(), undefined, undefined);
expect(StoreHandle.open).toHaveBeenCalledWith(undefined, undefined);
expect(OlmMachine.initFromStore).toHaveBeenCalledWith(expect.anything(), expect.anything(), mockStore);
});
it("Should get secrets from inbox on start", async () => {
const testOlmMachine = makeTestOlmMachine() as OlmMachine;
jest.spyOn(OlmMachine, "initialize").mockResolvedValue(testOlmMachine);
const mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
await initRustCrypto(
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
await initRustCrypto({
logger,
{} as MatrixClient["http"],
TEST_USER,
TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
{} as CryptoCallbacks,
"storePrefix",
"storePassphrase",
);
http: {} as MatrixClient["http"],
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
});
expect(testOlmMachine.getSecretsFromInbox).toHaveBeenCalledWith("m.megolm_backup.v1");
});
describe("libolm migration", () => {
let mockStore: RustSdkCryptoJs.StoreHandle;
beforeEach(() => {
// Stub out a bunch of stuff in the Rust library
mockStore = { free: jest.fn() } as unknown as StoreHandle;
jest.spyOn(StoreHandle, "open").mockResolvedValue(mockStore);
jest.spyOn(Migration, "migrateBaseData").mockResolvedValue(undefined);
jest.spyOn(Migration, "migrateOlmSessions").mockResolvedValue(undefined);
jest.spyOn(Migration, "migrateMegolmSessions").mockResolvedValue(undefined);
const testOlmMachine = makeTestOlmMachine();
jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine);
});
it("migrates data from a legacy crypto store", async () => {
const PICKLE_KEY = "pickle1234";
const legacyStore = new MemoryCryptoStore();
// Populate the legacy store with some test data
const storeSecretKey = (type: string, key: string) =>
encryptAndStoreSecretKey(type, new TextEncoder().encode(key), PICKLE_KEY, legacyStore);
await legacyStore.storeAccount({}, "not a real account");
await storeSecretKey("m.megolm_backup.v1", "backup key");
await storeSecretKey("master", "master key");
await storeSecretKey("self_signing", "ssk");
await storeSecretKey("user_signing", "usk");
const nDevices = 6;
const nSessionsPerDevice = 10;
createSessions(legacyStore, nDevices, nSessionsPerDevice);
createMegolmSessions(legacyStore, nDevices, nSessionsPerDevice);
await legacyStore.markSessionsNeedingBackup([{ senderKey: pad43("device5"), sessionId: "session5" }]);
fetchMock.get("path:/_matrix/client/v3/room_keys/version", { version: "45" });
function legacyMigrationProgressListener(progress: number, total: number): void {
logger.log(`migrated ${progress} of ${total}`);
}
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
legacyCryptoStore: legacyStore,
legacyPickleKey: PICKLE_KEY,
legacyMigrationProgressListener,
});
// Check that the migration functions were correctly called
expect(Migration.migrateBaseData).toHaveBeenCalledWith(
expect.any(BaseMigrationData),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
const data = mocked(Migration.migrateBaseData).mock.calls[0][0];
expect(data.pickledAccount).toEqual("not a real account");
expect(data.userId!.toString()).toEqual(TEST_USER);
expect(data.deviceId!.toString()).toEqual(TEST_DEVICE_ID);
expect(atob(data.backupRecoveryKey!)).toEqual("backup key");
expect(data.backupVersion).toEqual("45");
expect(atob(data.privateCrossSigningMasterKey!)).toEqual("master key");
expect(atob(data.privateCrossSigningUserSigningKey!)).toEqual("usk");
expect(atob(data.privateCrossSigningSelfSigningKey!)).toEqual("ssk");
expect(Migration.migrateOlmSessions).toHaveBeenCalledTimes(2);
expect(Migration.migrateOlmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
// First call should have 50 entries; second should have 10
const sessions1: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[0][0];
expect(sessions1.length).toEqual(50);
const sessions2: PickledSession[] = mocked(Migration.migrateOlmSessions).mock.calls[1][0];
expect(sessions2.length).toEqual(10);
const sessions = [...sessions1, ...sessions2];
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const session = sessions[i * nSessionsPerDevice + j];
expect(session.senderKey).toEqual(`device${i}`);
expect(session.pickle).toEqual(`session${i}.${j}`);
expect(session.creationTime).toEqual(new Date(1000));
expect(session.lastUseTime).toEqual(new Date(1000));
}
}
expect(Migration.migrateMegolmSessions).toHaveBeenCalledTimes(2);
expect(Migration.migrateMegolmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
// First call should have 50 entries; second should have 10
const megolmSessions1: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[0][0];
expect(megolmSessions1.length).toEqual(50);
const megolmSessions2: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[1][0];
expect(megolmSessions2.length).toEqual(10);
const megolmSessions = [...megolmSessions1, ...megolmSessions2];
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const session = megolmSessions[i * nSessionsPerDevice + j];
expect(session.senderKey).toEqual(pad43(`device${i}`));
expect(session.pickle).toEqual("sessionPickle");
expect(session.roomId!.toString()).toEqual("!room:id");
expect(session.senderSigningKey).toEqual("sender_signing_key");
// only one of the sessions needs backing up
expect(session.backedUp).toEqual(i !== 5 || j !== 5);
}
}
}, 10000);
it("handles megolm sessions with no `keysClaimed`", async () => {
const legacyStore = new MemoryCryptoStore();
legacyStore.storeAccount({}, "not a real account");
legacyStore.storeEndToEndInboundGroupSession(
pad43(`device1`),
`session1`,
{
forwardingCurve25519KeyChain: [],
room_id: "!room:id",
session: "sessionPickle",
},
undefined,
);
const PICKLE_KEY = "pickle1234";
await initRustCrypto({
logger,
http: makeMatrixHttpApi(),
userId: TEST_USER,
deviceId: TEST_DEVICE_ID,
secretStorage: {} as ServerSideSecretStorage,
cryptoCallbacks: {} as CryptoCallbacks,
storePrefix: "storePrefix",
storePassphrase: "storePassphrase",
legacyCryptoStore: legacyStore,
legacyPickleKey: PICKLE_KEY,
});
expect(Migration.migrateMegolmSessions).toHaveBeenCalledTimes(1);
expect(Migration.migrateMegolmSessions).toHaveBeenCalledWith(
expect.any(Array),
new Uint8Array(Buffer.from(PICKLE_KEY)),
mockStore,
);
const megolmSessions: PickledInboundGroupSession[] = mocked(Migration.migrateMegolmSessions).mock
.calls[0][0];
expect(megolmSessions.length).toEqual(1);
const session = megolmSessions[0];
expect(session.senderKey).toEqual(pad43(`device1`));
expect(session.pickle).toEqual("sessionPickle");
expect(session.roomId!.toString()).toEqual("!room:id");
expect(session.senderSigningKey).toBe(undefined);
}, 10000);
async function encryptAndStoreSecretKey(type: string, key: Uint8Array, pickleKey: string, store: CryptoStore) {
const encryptedKey = await encryptAES(encodeBase64(key), Buffer.from(pickleKey), type);
store.storeSecretStorePrivateKey(undefined, type as keyof SecretStorePrivateKeys, encryptedKey);
}
/** Create a bunch of fake Olm sessions and stash them in the DB. */
function createSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
const sessionData = {
deviceKey: `device${i}`,
sessionId: `session${j}`,
session: `session${i}.${j}`,
lastReceivedMessageTs: 1000,
};
store.storeEndToEndSession(`device${i}`, `session${j}`, sessionData, undefined);
}
}
}
/** Create a bunch of fake Megolm sessions and stash them in the DB. */
function createMegolmSessions(store: CryptoStore, nDevices: number, nSessionsPerDevice: number) {
for (let i = 0; i < nDevices; i++) {
for (let j = 0; j < nSessionsPerDevice; j++) {
store.storeEndToEndInboundGroupSession(
pad43(`device${i}`),
`session${j}`,
{
forwardingCurve25519KeyChain: [],
keysClaimed: { ed25519: "sender_signing_key" },
room_id: "!room:id",
session: "sessionPickle",
},
undefined,
);
}
}
}
});
});
describe("RustCrypto", () => {
@@ -141,7 +383,7 @@ describe("RustCrypto", () => {
);
});
describe(".importRoomKeys and .exportRoomKeys", () => {
describe("importing and exporting room keys", () => {
let rustCrypto: RustCrypto;
beforeEach(
@@ -157,7 +399,7 @@ describe("RustCrypto", () => {
let importTotal = 0;
const opt: ImportRoomKeysOpts = {
progressCallback: (stage) => {
importTotal = stage.total;
importTotal = stage.total ?? 0;
},
};
await rustCrypto.importRoomKeys(someRoomKeys, opt);
@@ -174,6 +416,29 @@ describe("RustCrypto", () => {
expect(aSession).toStrictEqual(exportedKey);
});
it("should import and export keys as JSON", async () => {
const someRoomKeys = testData.MEGOLM_SESSION_DATA_ARRAY;
let importTotal = 0;
const opt: ImportRoomKeysOpts = {
progressCallback: (stage) => {
importTotal = stage.total ?? 0;
},
};
await rustCrypto.importRoomKeysAsJson(JSON.stringify(someRoomKeys), opt);
expect(importTotal).toBe(someRoomKeys.length);
const keys: Array<IMegolmSessionData> = JSON.parse(await rustCrypto.exportRoomKeysAsJson());
expect(Array.isArray(keys)).toBeTruthy();
expect(keys.length).toBe(someRoomKeys.length);
const aSession = someRoomKeys[0];
const exportedKey = keys.find((k) => k.session_id == aSession.session_id);
expect(aSession).toStrictEqual(exportedKey);
});
});
describe("call preprocess methods", () => {
@@ -293,6 +558,62 @@ describe("RustCrypto", () => {
expect(mockCrossSigningIdentity.bootstrapCrossSigning).toHaveBeenCalledWith({});
});
it("bootstrapSecretStorage creates new backup when requested", async () => {
const secretStorageCallbacks = {
getSecretStorageKey: async (keys: any, name: string) => {
return [[...Object.keys(keys.keys)][0], new Uint8Array(32)];
},
} as SecretStorageCallbacks;
const secretStorage = new ServerSideSecretStorageImpl(new DummyAccountDataClient(), secretStorageCallbacks);
const outgoingRequestProcessor = {
makeOutgoingRequest: jest.fn(),
} as unknown as Mocked<OutgoingRequestProcessor>;
const rustCrypto = await makeTestRustCrypto(
new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
}),
testData.TEST_USER_ID,
undefined,
secretStorage,
);
rustCrypto["checkKeyBackupAndEnable"] = async () => {
return null;
};
(rustCrypto["crossSigningIdentity"] as any)["outgoingRequestProcessor"] = outgoingRequestProcessor;
const resetKeyBackup = (rustCrypto["resetKeyBackup"] = jest.fn());
async function createSecretStorageKey() {
return {
keyInfo: {} as AddSecretStorageKeyOpts,
privateKey: new Uint8Array(32),
};
}
// create initial secret storage
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
await rustCrypto.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: true,
});
// check that rustCrypto.resetKeyBackup was called
expect(resetKeyBackup.mock.calls).toHaveLength(1);
// reset secret storage
await rustCrypto.bootstrapSecretStorage({
createSecretStorageKey,
setupNewSecretStorage: true,
setupNewKeyBackup: true,
});
// check that rustCrypto.resetKeyBackup was called again
expect(resetKeyBackup.mock.calls).toHaveLength(2);
});
it("isSecretStorageReady", async () => {
const mockSecretStorage = {
getDefaultKeyId: jest.fn().mockResolvedValue(null),
@@ -399,6 +720,58 @@ describe("RustCrypto", () => {
await awaitCallToMakeOutgoingRequest();
expect(olmMachine.outgoingRequests).toHaveBeenCalledTimes(2);
});
it("should encode outgoing requests properly", async () => {
// we need a real OlmMachine, so replace the one created by beforeEach
rustCrypto = await makeTestRustCrypto();
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
const outgoingRequestProcessor = {} as unknown as OutgoingRequestProcessor;
rustCrypto["outgoingRequestProcessor"] = outgoingRequestProcessor;
const outgoingRequestsManager = new OutgoingRequestsManager(logger, olmMachine, outgoingRequestProcessor);
rustCrypto["outgoingRequestsManager"] = outgoingRequestsManager;
// The second time we do a /keys/upload, the `device_keys` property
// should be absent from the request body
// cf. https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/issues/57
//
// On the first upload, we pretend that there are no OTKs, so it will
// try to upload more keys
let keysUploadCount = 0;
let deviceKeys: object;
let deviceKeysAbsent = false;
outgoingRequestProcessor.makeOutgoingRequest = jest.fn(async (request, uiaCallback?) => {
let resp: any = {};
if (request instanceof RustSdkCryptoJs.KeysUploadRequest) {
if (keysUploadCount == 0) {
deviceKeys = JSON.parse(request.body).device_keys;
resp = { one_time_key_counts: { signed_curve25519: 0 } };
} else {
deviceKeysAbsent = !("device_keys" in JSON.parse(request.body));
resp = { one_time_key_counts: { signed_curve25519: 50 } };
}
keysUploadCount++;
} else if (request instanceof RustSdkCryptoJs.KeysQueryRequest) {
resp = {
device_keys: {
[TEST_USER]: {
[TEST_DEVICE_ID]: deviceKeys,
},
},
};
} else if (request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) {
// SigningKeysUploadRequest does not implement OutgoingRequest and does not need to be marked as sent.
return;
}
if (request.id) {
olmMachine.markRequestAsSent(request.id, request.type, JSON.stringify(resp));
}
});
await outgoingRequestsManager.doProcessOutgoingRequests();
await outgoingRequestsManager.doProcessOutgoingRequests();
expect(deviceKeysAbsent).toBe(true);
});
});
describe(".getEventEncryptionInfo", () => {
@@ -420,7 +793,7 @@ describe("RustCrypto", () => {
decryptEvent: () =>
({
senderCurve25519Key: "1234",
} as IEventDecryptionResult),
}) as IEventDecryptionResult,
} as unknown as CryptoBackend;
await event.attemptDecryption(mockCryptoBackend);
@@ -460,7 +833,7 @@ describe("RustCrypto", () => {
decryptEvent: () =>
({
clearEvent: { content: { body: "1234" } },
} as unknown as IEventDecryptionResult),
}) as unknown as IEventDecryptionResult,
} as unknown as CryptoBackend;
await encryptedEvent.attemptDecryption(mockCryptoBackend);
return encryptedEvent;
@@ -645,6 +1018,7 @@ describe("RustCrypto", () => {
it("should call getDevice", async () => {
olmMachine.getDevice.mockResolvedValue({
free: jest.fn(),
isCrossSigningTrusted: jest.fn().mockReturnValue(false),
isLocallyTrusted: jest.fn().mockReturnValue(false),
isCrossSignedByOwner: jest.fn().mockReturnValue(false),
@@ -737,8 +1111,8 @@ describe("RustCrypto", () => {
// Expect the private key to be an Uint8Array with a length of 32
expect(recoveryKey.privateKey).toBeInstanceOf(Uint8Array);
expect(recoveryKey.privateKey.length).toBe(32);
// Expect keyInfo to be empty
expect(Object.keys(recoveryKey.keyInfo!).length).toBe(0);
// Expect passphrase info to be absent
expect(recoveryKey.keyInfo?.passphrase).toBeUndefined();
});
it("should create a recovery key with password", async () => {
@@ -756,13 +1130,9 @@ describe("RustCrypto", () => {
});
it("should wait for a keys/query before returning devices", async () => {
jest.useFakeTimers();
// We want to use fake timers, but the wasm bindings of matrix-sdk-crypto rely on a working `queueMicrotask`.
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
@@ -772,7 +1142,7 @@ describe("RustCrypto", () => {
},
});
const rustCrypto = await makeTestRustCrypto(mockHttpApi, testData.TEST_USER_ID);
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), testData.TEST_USER_ID);
// an attempt to fetch the device list should block
const devicesPromise = rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
@@ -786,6 +1156,33 @@ describe("RustCrypto", () => {
rustCrypto.stop();
});
it("should emit events on device changes", async () => {
jest.useFakeTimers({ doNotFake: ["queueMicrotask"] });
fetchMock.post("path:/_matrix/client/v3/keys/upload", { one_time_key_counts: {} });
fetchMock.post("path:/_matrix/client/v3/keys/query", {
device_keys: {
[testData.TEST_USER_ID]: {
[testData.TEST_DEVICE_ID]: testData.SIGNED_TEST_DEVICE_DATA,
},
},
});
const rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), testData.TEST_USER_ID);
const willUpdateCallback = jest.fn();
rustCrypto.on(CryptoEvent.WillUpdateDevices, willUpdateCallback);
const devicesUpdatedCallback = jest.fn();
rustCrypto.on(CryptoEvent.DevicesUpdated, devicesUpdatedCallback);
rustCrypto.onSyncCompleted({});
// wait for the devices to be updated
await rustCrypto.getUserDeviceInfo([testData.TEST_USER_ID]);
expect(willUpdateCallback).toHaveBeenCalledWith([testData.TEST_USER_ID], false);
expect(devicesUpdatedCallback).toHaveBeenCalledWith([testData.TEST_USER_ID], false);
rustCrypto.stop();
});
describe("requestDeviceVerification", () => {
it("throws an error if the device is unknown", async () => {
const rustCrypto = await makeTestRustCrypto();
@@ -871,7 +1268,7 @@ describe("RustCrypto", () => {
});
it("returns a verified UserVerificationStatus when the UserIdentity is verified", async () => {
olmMachine.getIdentity.mockResolvedValue({ isVerified: jest.fn().mockReturnValue(true) });
olmMachine.getIdentity.mockResolvedValue({ free: jest.fn(), isVerified: jest.fn().mockReturnValue(true) });
const userVerificationStatus = await rustCrypto.getUserVerificationStatus(testData.TEST_USER_ID);
expect(userVerificationStatus.isVerified()).toBeTruthy();
@@ -898,12 +1295,6 @@ describe("RustCrypto", () => {
// Return the key backup
fetchMock.get("path:/_matrix/client/v3/room_keys/version", testData.SIGNED_BACKUP_DATA);
const mockHttpApi = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
const olmMachine = {
getIdentity: jest.fn(),
// Force the backup to be trusted by the olmMachine
@@ -916,7 +1307,7 @@ describe("RustCrypto", () => {
const rustCrypto = new RustCrypto(
logger,
olmMachine,
mockHttpApi,
makeMatrixHttpApi(),
testData.TEST_USER_ID,
testData.TEST_DEVICE_ID,
{} as ServerSideSecretStorage,
@@ -930,9 +1321,88 @@ describe("RustCrypto", () => {
await rustCrypto.onUserIdentityUpdated(new RustSdkCryptoJs.UserId(testData.TEST_USER_ID));
expect(await keyBackupStatusPromise).toBe(true);
});
it("does not back up keys that came from backup", async () => {
const rustCrypto = await makeTestRustCrypto();
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
await olmMachine.enableBackupV1(
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
testData.SIGNED_BACKUP_DATA.version!,
);
// we import two keys: one "from backup", and one "from export"
const [backedUpRoomKey, exportedRoomKey] = testData.MEGOLM_SESSION_DATA_ARRAY;
await rustCrypto.importBackedUpRoomKeys([backedUpRoomKey]);
await rustCrypto.importRoomKeys([exportedRoomKey]);
// we ask for the keys that should be backed up
const roomKeysRequest = await olmMachine.backupRoomKeys();
expect(roomKeysRequest).toBeTruthy();
const roomKeys = JSON.parse(roomKeysRequest!.body);
// we expect that the key "from export" is present
expect(roomKeys).toMatchObject({
rooms: {
[exportedRoomKey.room_id]: {
sessions: {
[exportedRoomKey.session_id]: {},
},
},
},
});
// we expect that the key "from backup" is not present
expect(roomKeys).not.toMatchObject({
rooms: {
[backedUpRoomKey.room_id]: {
sessions: {
[backedUpRoomKey.session_id]: {},
},
},
},
});
});
it("ignores invalid keys when restoring from backup", async () => {
const rustCrypto = await makeTestRustCrypto();
const olmMachine: OlmMachine = rustCrypto["olmMachine"];
await olmMachine.enableBackupV1(
(testData.SIGNED_BACKUP_DATA.auth_data as Curve25519AuthData).public_key,
testData.SIGNED_BACKUP_DATA.version!,
);
const backup = Array.from(testData.MEGOLM_SESSION_DATA_ARRAY);
// in addition to correct keys, we restore an invalid key
backup.push({ room_id: "!roomid", session_id: "sessionid" } as IMegolmSessionData);
const progressCallback = jest.fn();
await rustCrypto.importBackedUpRoomKeys(backup, { progressCallback });
expect(progressCallback).toHaveBeenCalledWith({
total: 3,
successes: 0,
stage: "load_keys",
failures: 1,
});
expect(progressCallback).toHaveBeenCalledWith({
total: 3,
successes: 1,
stage: "load_keys",
failures: 1,
});
});
});
});
/** Build a MatrixHttpApi instance */
function makeMatrixHttpApi(): MatrixHttpApi<IHttpOpts & { onlyData: true }> {
return new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
baseUrl: "http://server/",
prefix: "",
onlyData: true,
});
}
/** build a basic RustCrypto instance for testing
*
* just provides default arguments for initRustCrypto()
@@ -944,5 +1414,54 @@ async function makeTestRustCrypto(
secretStorage: ServerSideSecretStorage = {} as ServerSideSecretStorage,
cryptoCallbacks: CryptoCallbacks = {} as CryptoCallbacks,
): Promise<RustCrypto> {
return await initRustCrypto(logger, http, userId, deviceId, secretStorage, cryptoCallbacks, null, undefined);
return await initRustCrypto({
logger,
http,
userId,
deviceId,
secretStorage,
cryptoCallbacks,
storePrefix: null,
storePassphrase: undefined,
});
}
/** emulate account data, storing in memory
*/
class DummyAccountDataClient
extends TypedEventEmitter<ClientEvent.AccountData, ClientEventHandlerMap>
implements AccountDataClient
{
private storage: Map<string, any> = new Map();
public constructor() {
super();
}
public async getAccountDataFromServer<T extends Record<string, any>>(eventType: string): Promise<T | null> {
const ret = this.storage.get(eventType);
if (eventType) {
return ret as T;
} else {
return null;
}
}
public async setAccountData(eventType: string, content: any): Promise<{}> {
this.storage.set(eventType, content);
this.emit(
ClientEvent.AccountData,
new MatrixEvent({
content,
type: eventType,
}),
);
return {};
}
}
/** Pad a string to 43 characters long */
function pad43(x: string): string {
return x + ".".repeat(43 - x.length);
}
+525 -4
View File
@@ -17,8 +17,19 @@ limitations under the License.
import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import { Mocked } from "jest-mock";
import { isVerificationEvent, RustVerificationRequest } from "../../../src/rust-crypto/verification";
import { OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import {
isVerificationEvent,
RustVerificationRequest,
verificationMethodIdentifierToMethod,
} from "../../../src/rust-crypto/verification";
import {
ShowSasCallbacks,
VerificationRequestEvent,
Verifier,
VerifierEvent,
} from "../../../src/crypto-api/verification";
import { OutgoingRequest, OutgoingRequestProcessor } from "../../../src/rust-crypto/OutgoingRequestProcessor";
import { IDeviceKeys } from "../../../src/@types/crypto";
import { EventType, MatrixEvent, MsgType } from "../../../src";
describe("VerificationRequest", () => {
@@ -62,9 +73,13 @@ describe("VerificationRequest", () => {
describe("startVerification", () => {
let request: RustVerificationRequest;
let machine: Mocked<RustSdkCryptoJs.OlmMachine>;
let inner: Mocked<RustSdkCryptoJs.VerificationRequest>;
beforeEach(() => {
request = makeTestRequest();
inner = makeMockedInner();
machine = { getDevice: jest.fn() } as unknown as Mocked<RustSdkCryptoJs.OlmMachine>;
request = makeTestRequest(inner, machine);
});
it("does not permit methods other than SAS", async () => {
@@ -73,12 +88,368 @@ describe("VerificationRequest", () => {
);
});
it("raises an error if the other device is unknown", async () => {
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
"startVerification(): other device is unknown",
);
});
it("raises an error if starting verification does not produce a verifier", async () => {
jest.spyOn(inner, "otherDeviceId", "get").mockReturnValue(new RustSdkCryptoJs.DeviceId("other_device"));
machine.getDevice.mockResolvedValue({} as RustSdkCryptoJs.Device);
await expect(request.startVerification("m.sas.v1")).rejects.toThrow(
"Still no verifier after startSas() call",
);
});
});
it("can verify with SAS", async () => {
const aliceUserId = "@alice:example.org";
const aliceDeviceId = "ABCDEFG";
const bobUserId = "@bob:example.org";
const bobDeviceId = "HIJKLMN";
const [aliceOlmMachine, aliceDeviceKeys, aliceCrossSigningKeys] = await initOlmMachineAndKeys(
aliceUserId,
aliceDeviceId,
);
const [bobOlmMachine, bobDeviceKeys, bobCrossSigningKeys] = await initOlmMachineAndKeys(bobUserId, bobDeviceId);
const aliceRequestLoop = makeRequestLoop(
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
);
const bobRequestLoop = makeRequestLoop(
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
);
try {
await aliceOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(bobUserId)]);
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
const innerVerificationRequest = await bobUserIdentity.requestVerification(
roomId,
new RustSdkCryptoJs.EventId("$m.key.verification.request"),
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
["m.sas.v1"],
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
sender: aliceUserId,
event_id: "$m.key.verification.request",
content: verificationRequestContent,
origin_server_ts: Date.now(),
unsigned: {
age: 0,
},
}),
roomId,
);
// Bob accepts
const bobInnerVerificationRequest = bobOlmMachine.getVerificationRequest(
new RustSdkCryptoJs.UserId(aliceUserId),
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
["m.sas.v1"],
);
await bobVerificationRequest.accept();
// Alice starts the verification
const bobVerifierPromise: Promise<Verifier> = new Promise((resolve, reject) => {
bobVerificationRequest.on(VerificationRequestEvent.Change, () => {
const verifier = bobVerificationRequest.verifier;
if (verifier) {
resolve(verifier);
}
});
});
const aliceVerifier = await aliceVerificationRequest.startVerification("m.sas.v1");
const bobVerifier = await bobVerifierPromise;
// create a function to compare the SAS, and then let the verification run
let otherCallbacks: ShowSasCallbacks | undefined;
const compareSas = (callbacks: ShowSasCallbacks): void => {
if (otherCallbacks) {
const ourDecimal = callbacks.sas.decimal!;
const theirDecimal = otherCallbacks.sas.decimal!;
if (ourDecimal.every((el, idx) => el == theirDecimal[idx])) {
otherCallbacks.confirm();
callbacks.confirm();
} else {
otherCallbacks.mismatch();
callbacks.mismatch();
}
} else {
otherCallbacks = callbacks;
}
};
aliceVerifier.on(VerifierEvent.ShowSas, compareSas);
bobVerifier.on(VerifierEvent.ShowSas, compareSas);
await Promise.all([aliceVerifier.verify(), await bobVerifier.verify()]);
} finally {
await aliceRequestLoop.stop();
await bobRequestLoop.stop();
}
});
it("can handle simultaneous starts in SAS", async () => {
const aliceUserId = "@alice:example.org";
const aliceDeviceId = "ABCDEFG";
const bobUserId = "@bob:example.org";
const bobDeviceId = "HIJKLMN";
const [aliceOlmMachine, aliceDeviceKeys, aliceCrossSigningKeys] = await initOlmMachineAndKeys(
aliceUserId,
aliceDeviceId,
);
const [bobOlmMachine, bobDeviceKeys, bobCrossSigningKeys] = await initOlmMachineAndKeys(bobUserId, bobDeviceId);
let aliceStartRequest: RustSdkCryptoJs.RoomMessageRequest | undefined;
const aliceRequestLoop = makeRequestLoop(
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
async (request): Promise<any> => {
// If the request is sending the m.key.verification.start
// event, we delay sending it until after Bob has also started
// a verification
if (
!aliceStartRequest &&
request instanceof RustSdkCryptoJs.RoomMessageRequest &&
request.event_type == "m.key.verification.start"
) {
aliceStartRequest = request;
return { event_id: "$m.key.verification.start" };
}
},
);
const bobRequestLoop = makeRequestLoop(
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
);
try {
await aliceOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(bobUserId)]);
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [verificationMethodIdentifierToMethod("m.sas.v1")];
const innerVerificationRequest = await bobUserIdentity.requestVerification(
roomId,
new RustSdkCryptoJs.EventId("$m.key.verification.request"),
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
["m.sas.v1"],
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
sender: aliceUserId,
event_id: "$m.key.verification.request",
content: verificationRequestContent,
origin_server_ts: Date.now(),
unsigned: {
age: 0,
},
}),
roomId,
);
// Bob accepts
const bobInnerVerificationRequest = bobOlmMachine.getVerificationRequest(
new RustSdkCryptoJs.UserId(aliceUserId),
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
["m.sas.v1"],
);
await bobVerificationRequest.accept();
// Alice and Bob both start the verification
const aliceVerifier = await aliceVerificationRequest.startVerification("m.sas.v1");
const bobVerifier = await bobVerificationRequest.startVerification("m.sas.v1");
// We can now send Alice's start message to Bob
await aliceRequestLoop.makeOutgoingRequest(aliceStartRequest!);
// create a function to compare the SAS, and then let the verification run
let otherCallbacks: ShowSasCallbacks | undefined;
const compareSas = (callbacks: ShowSasCallbacks) => {
if (otherCallbacks) {
const ourDecimal = callbacks.sas.decimal!;
const theirDecimal = otherCallbacks.sas.decimal!;
if (ourDecimal.every((el, idx) => el == theirDecimal[idx])) {
otherCallbacks.confirm();
callbacks.confirm();
} else {
otherCallbacks.mismatch();
callbacks.mismatch();
}
} else {
otherCallbacks = callbacks;
}
};
aliceVerifier.on(VerifierEvent.ShowSas, compareSas);
bobVerifier.on(VerifierEvent.ShowSas, compareSas);
await Promise.all([aliceVerifier.verify(), await bobVerifier.verify()]);
} finally {
await aliceRequestLoop.stop();
await bobRequestLoop.stop();
}
});
it("can verify by QR code", async () => {
const aliceUserId = "@alice:example.org";
const aliceDeviceId = "ABCDEFG";
const bobUserId = "@bob:example.org";
const bobDeviceId = "HIJKLMN";
const [aliceOlmMachine, aliceDeviceKeys, aliceCrossSigningKeys] = await initOlmMachineAndKeys(
aliceUserId,
aliceDeviceId,
);
const [bobOlmMachine, bobDeviceKeys, bobCrossSigningKeys] = await initOlmMachineAndKeys(bobUserId, bobDeviceId);
const aliceRequestLoop = makeRequestLoop(
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
);
const bobRequestLoop = makeRequestLoop(
bobOlmMachine,
bobDeviceKeys,
bobCrossSigningKeys,
aliceOlmMachine,
aliceDeviceKeys,
aliceCrossSigningKeys,
);
try {
await aliceOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(bobUserId)]);
await bobOlmMachine.updateTrackedUsers([new RustSdkCryptoJs.UserId(aliceUserId)]);
// Alice requests verification
const bobUserIdentity = await aliceOlmMachine.getIdentity(new RustSdkCryptoJs.UserId(bobUserId));
const roomId = new RustSdkCryptoJs.RoomId("!roomId:example.org");
const methods = [
verificationMethodIdentifierToMethod("m.reciprocate.v1"),
verificationMethodIdentifierToMethod("m.qr_code.show.v1"),
];
const innerVerificationRequest = await bobUserIdentity.requestVerification(
roomId,
new RustSdkCryptoJs.EventId("$m.key.verification.request"),
methods,
);
const aliceVerificationRequest = new RustVerificationRequest(
aliceOlmMachine,
innerVerificationRequest,
aliceRequestLoop as unknown as OutgoingRequestProcessor,
["m.reciprocate.v1", "m.qr_code.show.v1"],
);
const verificationRequestContent = JSON.parse(await bobUserIdentity.verificationRequestContent(methods));
await bobOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: "m.room.message",
sender: aliceUserId,
event_id: "$m.key.verification.request",
content: verificationRequestContent,
origin_server_ts: Date.now(),
unsigned: {
age: 0,
},
}),
roomId,
);
// Bob accepts
const bobInnerVerificationRequest = bobOlmMachine.getVerificationRequest(
new RustSdkCryptoJs.UserId(aliceUserId),
"$m.key.verification.request",
)!;
const bobVerificationRequest = new RustVerificationRequest(
bobOlmMachine,
bobInnerVerificationRequest,
bobRequestLoop as unknown as OutgoingRequestProcessor,
["m.reciprocate.v1", "m.qr_code.show.v1", "m.qr_code.scan.v1"],
);
await bobVerificationRequest.accept();
// Bob scans
const qrCode = await aliceVerificationRequest.generateQRCode();
const aliceVerifierPromise: Promise<Verifier> = new Promise((resolve, reject) => {
aliceVerificationRequest.on(VerificationRequestEvent.Change, () => {
const verifier = aliceVerificationRequest.verifier;
if (verifier) {
resolve(verifier);
}
});
});
const bobVerifier = await bobVerificationRequest.scanQRCode(qrCode!);
const aliceVerifier = await aliceVerifierPromise;
aliceVerifier.on(VerifierEvent.ShowReciprocateQr, (showQrCodeCallbacks) => {
showQrCodeCallbacks.confirm();
});
await Promise.all([aliceVerifier.verify(), await bobVerifier.verify()]);
} finally {
await aliceRequestLoop.stop();
await bobRequestLoop.stop();
}
});
});
describe("isVerificationEvent", () => {
@@ -118,11 +489,13 @@ describe("isVerificationEvent", () => {
/** build a RustVerificationRequest with default parameters */
function makeTestRequest(
inner?: RustSdkCryptoJs.VerificationRequest,
olmMachine?: RustSdkCryptoJs.OlmMachine,
outgoingRequestProcessor?: OutgoingRequestProcessor,
): RustVerificationRequest {
inner ??= makeMockedInner();
olmMachine ??= {} as RustSdkCryptoJs.OlmMachine;
outgoingRequestProcessor ??= {} as OutgoingRequestProcessor;
return new RustVerificationRequest(inner, outgoingRequestProcessor, []);
return new RustVerificationRequest(olmMachine, inner, outgoingRequestProcessor, []);
}
/** Mock up a rust-side VerificationRequest */
@@ -133,5 +506,153 @@ function makeMockedInner(): Mocked<RustSdkCryptoJs.VerificationRequest> {
phase: jest.fn().mockReturnValue(RustSdkCryptoJs.VerificationRequestPhase.Created),
isPassive: jest.fn().mockReturnValue(false),
timeRemainingMillis: jest.fn(),
get otherDeviceId() {
return undefined;
},
} as unknown as Mocked<RustSdkCryptoJs.VerificationRequest>;
}
interface CrossSigningKeys {
master_key: any;
self_signing_key: any;
user_signing_key: any;
}
/** create an Olm machine and device/cross-signing keys for a user */
async function initOlmMachineAndKeys(
userId: string,
deviceId: string,
): Promise<[RustSdkCryptoJs.OlmMachine, IDeviceKeys, CrossSigningKeys]> {
const olmMachine = await RustSdkCryptoJs.OlmMachine.initialize(
new RustSdkCryptoJs.UserId(userId),
new RustSdkCryptoJs.DeviceId(deviceId),
undefined,
undefined,
);
const { uploadKeysRequest, uploadSignaturesRequest, uploadSigningKeysRequest } =
await olmMachine.bootstrapCrossSigning(true);
const deviceKeys = JSON.parse(uploadKeysRequest.body).device_keys;
await olmMachine.markRequestAsSent(
uploadKeysRequest.id,
uploadKeysRequest.type,
'{"one_time_key_counts":{"signed_curve25519":100}}',
);
const crossSigningSignatures = JSON.parse(uploadSignaturesRequest.body);
for (const [keyId, signature] of Object.entries(crossSigningSignatures[userId][deviceId]["signatures"][userId])) {
deviceKeys["signatures"][userId][keyId] = signature;
}
const crossSigningKeys = JSON.parse(uploadSigningKeysRequest.body);
// note: the upload signatures request and upload signing keys requests
// don't need to be marked as sent in the Olm machine
return [olmMachine, deviceKeys, crossSigningKeys];
}
type CustomRequestHandler = (request: OutgoingRequest | RustSdkCryptoJs.UploadSigningKeysRequest) => Promise<any>;
/** Loop for handling outgoing requests from an Olm machine.
*
* Simulates a server with two users: "us" and "them". Handles key query
* requests, querying either our keys or the other user's keys. Room messages
* are sent as incoming verification events to the other user. A custom
* handler can be added to override default request processing (the handler
* should return a response body to inhibit default processing).
*
* Can also be used as an OutgoingRequestProcessor. */
function makeRequestLoop(
ourOlmMachine: RustSdkCryptoJs.OlmMachine,
ourDeviceKeys: IDeviceKeys,
ourCrossSigningKeys: CrossSigningKeys,
theirOlmMachine: RustSdkCryptoJs.OlmMachine,
theirDeviceKeys: IDeviceKeys,
theirCrossSigningKeys: CrossSigningKeys,
customHandler?: CustomRequestHandler,
) {
let stopRequestLoop = false;
const ourUserId = ourOlmMachine.userId.toString();
const ourDeviceId = ourOlmMachine.deviceId.toString();
const theirUserId = theirOlmMachine.userId.toString();
const theirDeviceId = theirOlmMachine.deviceId.toString();
function defaultHandler(request: OutgoingRequest | RustSdkCryptoJs.UploadSigningKeysRequest): any {
if (request instanceof RustSdkCryptoJs.KeysQueryRequest) {
const resp: Record<string, any> = {
device_keys: {},
};
const body = JSON.parse(request.body);
const query = body.device_keys;
const masterKeys: Record<string, any> = {};
const selfSigningKeys: Record<string, any> = {};
if (ourUserId in query) {
resp.device_keys[ourUserId] = { [ourDeviceId]: ourDeviceKeys };
masterKeys[ourUserId] = ourCrossSigningKeys.master_key;
selfSigningKeys[ourUserId] = ourCrossSigningKeys.self_signing_key;
resp.user_signing_keys = {
[ourUserId]: ourCrossSigningKeys.user_signing_key,
};
}
if (theirUserId in query) {
resp.device_keys[theirUserId] = {
[theirDeviceId]: theirDeviceKeys,
};
masterKeys[theirUserId] = theirCrossSigningKeys.master_key;
selfSigningKeys[theirUserId] = theirCrossSigningKeys.self_signing_key;
}
if (Object.keys(masterKeys).length) {
resp.master_keys = masterKeys;
}
if (Object.keys(selfSigningKeys).length) {
resp.self_signing_keys = selfSigningKeys;
}
return resp;
} else if (request instanceof RustSdkCryptoJs.RoomMessageRequest) {
theirOlmMachine.receiveVerificationEvent(
JSON.stringify({
type: request.event_type,
sender: ourUserId,
event_id: "$" + request.event_type,
content: JSON.parse(request.body),
origin_server_ts: Date.now(),
unsigned: {
age: 0,
},
}),
new RustSdkCryptoJs.RoomId(request.room_id),
);
return { event_id: "$" + request.event_type };
} else if (request instanceof RustSdkCryptoJs.SignatureUploadRequest) {
// this only gets called at the end after the verification
// succeeds, so we don't actually have to do anything.
return { failures: {} };
}
return {};
}
async function makeOutgoingRequest(
request: OutgoingRequest | RustSdkCryptoJs.UploadSigningKeysRequest,
): Promise<any> {
const resp = (await customHandler?.(request)) ?? defaultHandler(request);
if (!(request instanceof RustSdkCryptoJs.UploadSigningKeysRequest) && request.id) {
await ourOlmMachine.markRequestAsSent(request.id!, request.type, JSON.stringify(resp));
}
}
async function runLoop() {
while (!stopRequestLoop) {
const requests = await ourOlmMachine.outgoingRequests();
for (const request of requests) {
await makeOutgoingRequest(request);
}
}
}
const loopCompletedPromise = runLoop();
return {
makeOutgoingRequest,
stop: async () => {
stopRequestLoop = true;
await loopCompletedPromise;
},
};
}
+18 -4
View File
@@ -33,7 +33,9 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a default key", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2");
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
key: new Uint8Array(32),
});
// it should have made up a 32-character key id
expect(result.keyId.length).toEqual(32);
@@ -46,7 +48,13 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a key with an explicit id", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {}, "myKeyId");
const result = await secretStorage.addKey(
"m.secret_storage.v1.aes-hmac-sha2",
{
key: new Uint8Array(32),
},
"myKeyId",
);
// it should have made up a 32-character key id
expect(result.keyId).toEqual("myKeyId");
@@ -59,7 +67,10 @@ describe("ServerSideSecretStorageImpl", function () {
it("should allow storing a key with a name", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", { name: "mykey" });
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
name: "mykey",
key: new Uint8Array(32),
});
expect(result.keyInfo.name).toEqual("mykey");
@@ -80,6 +91,7 @@ describe("ServerSideSecretStorageImpl", function () {
};
const result = await secretStorage.addKey("m.secret_storage.v1.aes-hmac-sha2", {
passphrase,
key: new Uint8Array(32),
});
expect(result.keyInfo.passphrase).toEqual(passphrase);
@@ -93,7 +105,9 @@ describe("ServerSideSecretStorageImpl", function () {
it("should complain about invalid algorithm", async function () {
const accountDataAdapter = mockAccountDataClient();
const secretStorage = new ServerSideSecretStorageImpl(accountDataAdapter, {});
await expect(() => secretStorage.addKey("bad_alg")).rejects.toThrow("Unknown key algorithm");
await expect(() => secretStorage.addKey("bad_alg", { key: new Uint8Array(32) })).rejects.toThrow(
"Unknown key algorithm",
);
});
});
+51
View File
@@ -0,0 +1,51 @@
/*
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEvent } from "../../src";
import { randomString } from "../../src/randomstring";
import { getRelationsThreadFilter } from "../../src/thread-utils";
function makeEvent(relatesToEvent: string, relType: string): Partial<IEvent> {
return {
event_id: randomString(10),
type: "m.room.message",
content: {
"msgtype": "m.text",
"body": "foo",
"m.relates_to": {
rel_type: relType,
event_id: relatesToEvent,
},
},
};
}
describe("getRelationsThreadFilter", () => {
it("should filter out relations directly to the thread root event", () => {
const threadId = "thisIsMyThreadRoot";
const reactionToRoot = makeEvent(threadId, "m.annotation");
const editToRoot = makeEvent(threadId, "m.replace");
const firstThreadedReply = makeEvent(threadId, "m.thread");
const reactionToThreadedEvent = makeEvent(firstThreadedReply.event_id!, "m.annotation");
const filteredEvents = [reactionToRoot, editToRoot, firstThreadedReply, reactionToThreadedEvent].filter(
getRelationsThreadFilter(threadId),
);
expect(filteredEvents).toEqual([firstThreadedReply, reactionToThreadedEvent]);
});
});
+4 -4
View File
@@ -954,7 +954,7 @@ describe("Group Call", function () {
},
},
}),
} as MatrixEvent);
}) as MatrixEvent;
it("should mute remote feed's audio after receiving metadata with video audio", async () => {
const metadataEvent = getMetadataEvent(true, false);
@@ -965,7 +965,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(
// @ts-ignore Mock
@@ -992,7 +992,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2).get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
// @ts-ignore Mock
call.pushRemoteFeed(
// @ts-ignore Mock
@@ -1310,7 +1310,7 @@ describe("Group Call", function () {
// @ts-ignore
const call = groupCall.calls.get(FAKE_USER_ID_2)!.get(FAKE_DEVICE_ID_2)!;
call.getOpponentMember = () => ({ userId: call.invitee } as RoomMember);
call.getOpponentMember = () => ({ userId: call.invitee }) as RoomMember;
call.onNegotiateReceived({
getContent: () => ({
[SDPStreamMetadataKey]: {
+17
View File
@@ -0,0 +1,17 @@
/*
Copyright 2024 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
export type NonEmptyArray<T> = [T, ...T[]];
+3
View File
@@ -94,6 +94,9 @@ export enum EventType {
// Group call events
GroupCallPrefix = "org.matrix.msc3401.call",
GroupCallMemberPrefix = "org.matrix.msc3401.call.member",
// MatrixRTC events
CallNotify = "org.matrix.msc4075.call.notify",
}
export enum RelationType {
+2 -3
View File
@@ -23,9 +23,8 @@ import { isProvided } from "../extensible_events_v1/utilities";
/**
* Represents the stable and unstable values of a given namespace.
*/
export type TSNamespace<N> = N extends NamespacedValue<infer S, infer U>
? TSNamespaceValue<S> | TSNamespaceValue<U>
: never;
export type TSNamespace<N> =
N extends NamespacedValue<infer S, infer U> ? TSNamespaceValue<S> | TSNamespaceValue<U> : never;
/**
* Represents a namespaced value, if the value is a string. Used to extract provided types
+4 -1
View File
@@ -26,7 +26,10 @@ export class NamespacedValue<S extends string, U extends string> {
public constructor(stable: S, unstable: U);
public constructor(stable: S, unstable?: U);
public constructor(stable: null | undefined, unstable: U);
public constructor(public readonly stable?: S | null, public readonly unstable?: U) {
public constructor(
public readonly stable?: S | null,
public readonly unstable?: U,
) {
if (!this.unstable && !this.stable) {
throw new Error("One of stable or unstable values must be supplied");
}
+24 -132
View File
@@ -15,20 +15,10 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { SigningKey } from "oidc-client-ts";
import { IClientWellKnown, IWellKnownConfig, IDelegatedAuthConfig, IServerVersions, M_AUTHENTICATION } from "./client";
import { IClientWellKnown, IWellKnownConfig, IServerVersions } from "./client";
import { logger } from "./logger";
import { MatrixError, Method, timeoutSignal } from "./http-api";
import { discoverAndValidateAuthenticationConfig } from "./oidc/discovery";
import {
ValidatedIssuerConfig,
ValidatedIssuerMetadata,
validateOIDCIssuerWellKnown,
validateWellKnownAuthentication,
} from "./oidc/validate";
import { OidcError } from "./oidc/error";
import { MINIMUM_MATRIX_VERSION } from "./version-support";
import { SUPPORTED_MATRIX_VERSIONS } from "./version-support";
// Dev note: Auto discovery is part of the spec.
// See: https://matrix.org/docs/spec/client_server/r0.4.0.html#server-discovery
@@ -51,7 +41,10 @@ export enum AutoDiscoveryError {
InvalidIs = "Invalid identity server discovery response",
MissingWellknown = "No .well-known JSON file found",
InvalidJson = "Invalid JSON",
HomeserverTooOld = "The homeserver does not meet the minimum version requirements",
UnsupportedHomeserverSpecVersion = "The homeserver does not meet the version requirements",
/** @deprecated Replaced by `UnsupportedHomeserverSpecVersion` */
HomeserverTooOld = UnsupportedHomeserverSpecVersion,
// TODO: Implement when Sydent supports the `/versions` endpoint - https://github.com/matrix-org/sydent/issues/424
//IdentityServerTooOld = "The identity server does not meet the minimum version requirements",
}
@@ -62,26 +55,9 @@ interface AutoDiscoveryState {
}
interface WellKnownConfig extends Omit<IWellKnownConfig, "error">, AutoDiscoveryState {}
/**
* @deprecated in favour of OidcClientConfig
*/
interface DelegatedAuthConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig, AutoDiscoveryState {}
/**
* @experimental
*/
export interface OidcClientConfig extends IDelegatedAuthConfig, ValidatedIssuerConfig {
metadata: ValidatedIssuerMetadata;
signingKeys?: SigningKey[];
}
export interface ClientConfig extends Omit<IClientWellKnown, "m.homeserver" | "m.identity_server"> {
"m.homeserver": WellKnownConfig;
"m.identity_server": WellKnownConfig;
/**
* @experimental
*/
"m.authentication"?: (OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState;
}
/**
@@ -112,7 +88,11 @@ export class AutoDiscovery {
public static readonly ERROR_INVALID_JSON = AutoDiscoveryError.InvalidJson;
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscoveryError.HomeserverTooOld;
public static readonly ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION =
AutoDiscoveryError.UnsupportedHomeserverSpecVersion;
/** @deprecated Replaced by ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION */
public static readonly ERROR_HOMESERVER_TOO_OLD = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
public static readonly ALL_ERRORS = Object.keys(AutoDiscoveryError) as AutoDiscoveryError[];
@@ -216,10 +196,19 @@ export class AutoDiscovery {
return Promise.resolve(clientConfig);
}
// Step 3.1: Non-spec check to ensure the server will actually work for us
if (!hsVersions.raw!["versions"].includes(MINIMUM_MATRIX_VERSION)) {
logger.error("Homeserver does not meet minimum version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_HOMESERVER_TOO_OLD;
// Step 3.1: Non-spec check to ensure the server will actually work for us. We need to check if
// any of the versions in `SUPPORTED_MATRIX_VERSIONS` are listed in the /versions response.
const hsVersionSet = new Set(hsVersions.raw!["versions"]);
let supportedVersionFound = false;
for (const version of SUPPORTED_MATRIX_VERSIONS) {
if (hsVersionSet.has(version)) {
supportedVersionFound = true;
break;
}
}
if (!supportedVersionFound) {
logger.error("Homeserver does not meet version requirements");
clientConfig["m.homeserver"].error = AutoDiscovery.ERROR_UNSUPPORTED_HOMESERVER_SPEC_VERSION;
// Supply the base_url to the caller because they may be ignoring liveliness
// errors, like this one.
@@ -302,107 +291,10 @@ export class AutoDiscovery {
}
});
const authConfig = await this.discoverAndValidateAuthenticationConfig(wellknown);
clientConfig[M_AUTHENTICATION.stable!] = authConfig;
// Step 8: Give the config to the caller (finally)
return Promise.resolve(clientConfig);
}
/**
* Validate delegated auth configuration
* @deprecated use discoverAndValidateAuthenticationConfig
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, DelegatedAuthConfig will be returned with endpoints used for delegated auth
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async validateDiscoveryAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<DelegatedAuthConfig | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const homeserverAuthenticationConfig = validateWellKnownAuthentication(authentication);
const issuerOpenIdConfigUrl = `${this.sanitizeWellKnownUrl(
homeserverAuthenticationConfig.issuer,
)}/.well-known/openid-configuration`;
const issuerWellKnown = await this.fetchWellKnownObject<unknown>(issuerOpenIdConfigUrl);
if (issuerWellKnown.action !== AutoDiscoveryAction.SUCCESS) {
logger.error("Failed to fetch issuer openid configuration");
throw new Error(OidcError.General);
}
const validatedIssuerConfig = validateOIDCIssuerWellKnown(issuerWellKnown.raw);
const delegatedAuthConfig: DelegatedAuthConfig = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...homeserverAuthenticationConfig,
...validatedIssuerConfig,
};
return delegatedAuthConfig;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Validate delegated auth configuration
* - m.authentication config is present and valid
* - delegated auth issuer openid-configuration is reachable
* - delegated auth issuer openid-configuration is configured correctly for us
* When successful, validated authentication metadata and optionally signing keys will be returned
* Any errors are caught, and AutoDiscoveryState returned with error
* @param wellKnown - configuration object as returned
* by the .well-known auto-discovery endpoint
* @returns Config or failure result
*/
public static async discoverAndValidateAuthenticationConfig(
wellKnown: IClientWellKnown,
): Promise<(OidcClientConfig & AutoDiscoveryState) | AutoDiscoveryState> {
try {
const authentication = M_AUTHENTICATION.findIn<IDelegatedAuthConfig>(wellKnown) || undefined;
const result = await discoverAndValidateAuthenticationConfig(authentication);
// include this for backwards compatibility
const validatedIssuerConfig = validateOIDCIssuerWellKnown(result.metadata);
const response = {
state: AutoDiscoveryAction.SUCCESS,
error: null,
...validatedIssuerConfig,
...result,
};
return response;
} catch (error) {
const errorMessage = (error as Error).message as unknown as OidcError;
const errorType = Object.values(OidcError).includes(errorMessage) ? errorMessage : OidcError.General;
const state =
errorType === OidcError.NotSupported ? AutoDiscoveryAction.IGNORE : AutoDiscoveryAction.FAIL_ERROR;
return {
state,
error: errorType,
};
}
}
/**
* Attempts to automatically discover client configuration information
* prior to logging in. Such information includes the homeserver URL
+351 -176
View File
@@ -51,7 +51,7 @@ import { decodeBase64, encodeBase64 } from "./base64";
import { IExportedDevice as IExportedOlmDevice } from "./crypto/OlmDevice";
import { IOlmDevice } from "./crypto/algorithms/megolm";
import { TypedReEmitter } from "./ReEmitter";
import { IRoomEncryption, RoomList } from "./crypto/RoomList";
import { IRoomEncryption } from "./crypto/RoomList";
import { logger, Logger } from "./logger";
import { SERVICE_TYPES } from "./service-types";
import {
@@ -209,7 +209,7 @@ import { IgnoredInvites } from "./models/invites-ignorer";
import { UIARequest, UIAResponse } from "./@types/uia";
import { LocalNotificationSettings } from "./@types/local_notifications";
import { buildFeatureSupportMap, Feature, ServerSupport } from "./feature";
import { CryptoBackend } from "./common-crypto/CryptoBackend";
import { BackupDecryptor, CryptoBackend } from "./common-crypto/CryptoBackend";
import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants";
import { BootstrapCrossSigningOpts, CrossSigningKeyInfo, CryptoApi, ImportRoomKeysOpts } from "./crypto-api";
import { DeviceInfoMap } from "./crypto/DeviceList";
@@ -221,6 +221,7 @@ import {
} from "./secret-storage";
import { RegisterRequest, RegisterResponse } from "./@types/registration";
import { MatrixRTCSessionManager } from "./matrixrtc/MatrixRTCSessionManager";
import { getRelationsThreadFilter } from "./thread-utils";
export type Store = IStore;
@@ -624,13 +625,10 @@ export interface IServerVersions {
unstable_features: Record<string, boolean>;
}
export const M_AUTHENTICATION = new UnstableValue("m.authentication", "org.matrix.msc2965.authentication");
export interface IClientWellKnown {
[key: string]: any;
"m.homeserver"?: IWellKnownConfig;
"m.identity_server"?: IWellKnownConfig;
[M_AUTHENTICATION.name]?: IDelegatedAuthConfig; // MSC2965
}
export interface IWellKnownConfig<T = IClientWellKnown> {
@@ -644,14 +642,6 @@ export interface IWellKnownConfig<T = IClientWellKnown> {
server_name?: string;
}
export interface IDelegatedAuthConfig {
// MSC2965
/** The OIDC Provider/issuer the client should use */
issuer: string;
/** The optional URL of the web UI where the user can manage their account */
account?: string;
}
interface IKeyBackupPath {
path: string;
queryData?: {
@@ -895,7 +885,7 @@ interface IRoomHierarchy {
export interface TimestampToEventResponse {
event_id: string;
origin_server_ts: string;
origin_server_ts: number;
}
interface IWhoamiResponse {
@@ -951,6 +941,7 @@ type CryptoEvents =
| CryptoEvent.KeyBackupStatus
| CryptoEvent.KeyBackupFailed
| CryptoEvent.KeyBackupSessionsRemaining
| CryptoEvent.KeyBackupDecryptionKeyCached
| CryptoEvent.RoomKeyRequest
| CryptoEvent.RoomKeyRequestCancellation
| CryptoEvent.VerificationRequest
@@ -960,7 +951,8 @@ type CryptoEvents =
| CryptoEvent.KeysChanged
| CryptoEvent.Warning
| CryptoEvent.DevicesUpdated
| CryptoEvent.WillUpdateDevices;
| CryptoEvent.WillUpdateDevices
| CryptoEvent.LegacyCryptoStoreMigrationProgress;
type MatrixEventEvents = MatrixEventEvent.Decrypted | MatrixEventEvent.Replaced | MatrixEventEvent.VisibilityChange;
@@ -1271,7 +1263,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected cryptoStore?: CryptoStore;
protected verificationMethods?: VerificationMethod[];
protected fallbackICEServerAllowed = false;
protected roomList: RoomList;
protected syncApi?: SlidingSyncSdk | SyncApi;
public roomNameGenerator?: ICreateClientOpts["roomNameGenerator"];
public pushRules?: IPushRules;
@@ -1303,7 +1294,13 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
protected txnCtr = 0;
protected mediaHandler = new MediaHandler(this);
protected sessionId: string;
protected pendingEventEncryption = new Map<string, Promise<void>>();
/** IDs of events which are currently being encrypted.
*
* This is part of the cancellation mechanism: if the event is no longer listed here when encryption completes,
* that tells us that it has been cancelled, and we should not send it.
*/
private eventsBeingEncrypted = new Set<string>();
private useE2eForGroupCall = true;
private toDeviceMessageQueue: ToDeviceMessageQueue;
@@ -1427,10 +1424,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.livekitServiceURL = opts.livekitServiceURL;
// List of which rooms have encryption enabled: separate from crypto because
// we still want to know which rooms are encrypted even if crypto is disabled:
// we don't want to start sending unencrypted events to them.
this.roomList = new RoomList(this.cryptoStore);
this.roomNameGenerator = opts.roomNameGenerator;
this.toDeviceMessageQueue = new ToDeviceMessageQueue(this);
@@ -1447,7 +1440,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// correctly handle notification counts on encrypted rooms.
// This fixes https://github.com/vector-im/element-web/issues/9421
this.on(RoomEvent.Receipt, (event, room) => {
if (room && this.isRoomEncrypted(room.roomId)) {
if (room?.hasEncryptionStateEvent()) {
// Figure out if we've read something or if it's just informational
const content = event.getContent();
const isSelf =
@@ -1494,6 +1487,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.ignoredInvites = new IgnoredInvites(this);
this._secretStorage = new ServerSideSecretStorageImpl(this, opts.cryptoCallbacks ?? {});
// having lots of event listeners is not unusual. 0 means "unlimited".
this.setMaxListeners(0);
}
public set store(newStore: Store) {
@@ -2229,10 +2225,6 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
this.logger.debug("Crypto: Starting up crypto store...");
await this.cryptoStore.startup();
// initialise the list of encrypted rooms (whether or not crypto is enabled)
this.logger.debug("Crypto: initialising roomlist...");
await this.roomList.init();
const userId = this.getUserId();
if (userId === null) {
throw new Error(
@@ -2247,15 +2239,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
);
}
const crypto = new Crypto(
this,
userId,
this.deviceId,
this.store,
this.cryptoStore,
this.roomList,
this.verificationMethods!,
);
const crypto = new Crypto(this, userId, this.deviceId, this.store, this.cryptoStore, this.verificationMethods!);
this.reEmitter.reEmit(crypto, [
CryptoEvent.KeyBackupFailed,
@@ -2328,17 +2312,25 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// importing rust-crypto will download the webassembly, so we delay it until we know it will be
// needed.
this.logger.debug("Downloading Rust crypto library");
const RustCrypto = await import("./rust-crypto");
const rustCrypto = await RustCrypto.initRustCrypto(
this.logger,
this.http,
userId,
deviceId,
this.secretStorage,
this.cryptoCallbacks,
useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
this.pickleKey,
);
const rustCrypto = await RustCrypto.initRustCrypto({
logger: this.logger,
http: this.http,
userId: userId,
deviceId: deviceId,
secretStorage: this.secretStorage,
cryptoCallbacks: this.cryptoCallbacks,
storePrefix: useIndexedDB ? RUST_SDK_STORE_PREFIX : null,
storePassphrase: this.pickleKey,
legacyCryptoStore: this.cryptoStore,
legacyPickleKey: this.pickleKey ?? "DEFAULT_KEY",
legacyMigrationProgressListener: (progress, total) => {
this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total);
},
});
rustCrypto.setSupportedVerificationMethods(this.verificationMethods);
this.cryptoBackend = rustCrypto;
@@ -2356,6 +2348,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
CryptoEvent.KeyBackupStatus,
CryptoEvent.KeyBackupSessionsRemaining,
CryptoEvent.KeyBackupFailed,
CryptoEvent.KeyBackupDecryptionKeyCached,
CryptoEvent.KeysChanged,
CryptoEvent.DevicesUpdated,
CryptoEvent.WillUpdateDevices,
]);
}
@@ -2390,6 +2386,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns base64-encoded ed25519 key. Null if crypto is
* disabled.
*
* @deprecated Prefer {@link CryptoApi.getOwnDeviceKeys}
*/
public getDeviceEd25519Key(): string | null {
return this.crypto?.getDeviceEd25519Key() ?? null;
@@ -2400,6 +2398,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @returns base64-encoded curve25519 key. Null if crypto is
* disabled.
*
* @deprecated Use {@link CryptoApi.getOwnDeviceKeys}
*/
public getDeviceCurve25519Key(): string | null {
return this.crypto?.getDeviceCurve25519Key() ?? null;
@@ -3243,6 +3243,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param roomId - The room ID to enable encryption in.
* @param config - The encryption config for the room.
* @returns A promise that will resolve when encryption is set up.
*
* @deprecated Not supported for Rust Cryptography. To enable encryption in a room, send an `m.room.encryption`
* state event.
*/
public setRoomEncryption(roomId: string, config: IRoomEncryption): Promise<void> {
if (!this.crypto) {
@@ -3255,6 +3258,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* Whether encryption is enabled for a room.
* @param roomId - the room id to query.
* @returns whether encryption is enabled.
*
* @deprecated Not correctly supported for Rust Cryptography. Use {@link CryptoApi.isEncryptionEnabledInRoom} and/or
* {@link Room.hasEncryptionStateEvent}.
*/
public isRoomEncrypted(roomId: string): boolean {
const room = this.getRoom(roomId);
@@ -3266,15 +3272,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// if there is an 'm.room.encryption' event in this room, it should be
// encrypted (independently of whether we actually support encryption)
const ev = room.currentState.getStateEvents(EventType.RoomEncryption, "");
if (ev) {
if (room.hasEncryptionStateEvent()) {
return true;
}
// we don't have an m.room.encrypted event, but that might be because
// the server is hiding it from us. Check the store to see if it was
// previously encrypted.
return this.roomList.isRoomEncrypted(roomId);
return this.crypto?.isRoomEncrypted(roomId) ?? false;
}
/**
@@ -3637,6 +3642,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Marks all group sessions as needing to be backed up and schedules them to
* upload in the background as soon as possible.
*
* (This is done automatically as part of {@link CryptoApi.resetKeyBackup},
* so there is probably no need to call this manually.)
*/
public async scheduleAllGroupSessionsForBackup(): Promise<void> {
if (!this.crypto) {
@@ -3649,6 +3657,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
/**
* Marks all group sessions as needing to be backed up without scheduling
* them to upload in the background.
*
* (This is done automatically as part of {@link CryptoApi.resetKeyBackup},
* so there is probably no need to call this manually.)
*
* @returns Promise which resolves to the number of sessions requiring a backup.
*/
public flagAllGroupSessionsForBackup(): Promise<number> {
@@ -3896,7 +3908,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
let totalKeyCount = 0;
let keys: IMegolmSessionData[] = [];
let totalFailures = 0;
let totalImported = 0;
const path = this.makeKeyBackupPath(targetRoomId, targetSessionId, backupInfo.version);
@@ -3932,25 +3945,61 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
{ prefix: ClientPrefix.V3 },
);
if ((res as IRoomsKeysResponse).rooms) {
const rooms = (res as IRoomsKeysResponse).rooms;
for (const [roomId, roomData] of Object.entries(rooms)) {
if (!roomData.sessions) continue;
// We have finished fetching the backup, go to next step
if (progressCallback) {
progressCallback({
stage: "load_keys",
});
}
totalKeyCount += Object.keys(roomData.sessions).length;
const roomKeys = await backupDecryptor.decryptSessions(roomData.sessions);
for (const k of roomKeys) {
k.room_id = roomId;
keys.push(k);
}
}
if ((res as IRoomsKeysResponse).rooms) {
// We have a full backup here, it can get quite big, so we need to decrypt and import it in chunks.
// Get the total count as a first pass
totalKeyCount = this.getTotalKeyCount(res as IRoomsKeysResponse);
// Now decrypt and import the keys in chunks
await this.handleDecryptionOfAFullBackup(
res as IRoomsKeysResponse,
backupDecryptor,
200,
async (chunk) => {
// We have a chunk of decrypted keys: import them
try {
await this.cryptoBackend!.importBackedUpRoomKeys(chunk, {
untrusted,
});
totalImported += chunk.length;
} catch (e) {
totalFailures += chunk.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);
}
if (progressCallback) {
progressCallback({
total: totalKeyCount,
successes: totalImported,
stage: "load_keys",
failures: totalFailures,
});
}
},
);
} else if ((res as IRoomKeysResponse).sessions) {
// For now we don't chunk for a single room backup, but we could in the future.
// Currently it is not used by the application.
const sessions = (res as IRoomKeysResponse).sessions;
totalKeyCount = Object.keys(sessions).length;
keys = await backupDecryptor.decryptSessions(sessions);
const keys = await backupDecryptor.decryptSessions(sessions);
for (const k of keys) {
k.room_id = targetRoomId!;
}
await this.cryptoBackend.importBackedUpRoomKeys(keys, {
progressCallback,
untrusted,
});
totalImported = keys.length;
} else {
totalKeyCount = 1;
try {
@@ -3959,7 +4008,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
});
key.room_id = targetRoomId!;
key.session_id = targetSessionId!;
keys.push(key);
await this.cryptoBackend.importBackedUpRoomKeys([key], {
progressCallback,
untrusted,
});
totalImported = 1;
} catch (e) {
this.logger.debug("Failed to decrypt megolm session from backup", e);
}
@@ -3968,16 +4022,88 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
backupDecryptor.free();
}
await this.cryptoBackend.importRoomKeys(keys, {
progressCallback,
untrusted,
source: "backup",
});
/// in case entering the passphrase would add a new signature?
await this.cryptoBackend.checkKeyBackupAndEnable();
return { total: totalKeyCount, imported: keys.length };
return { total: totalKeyCount, imported: totalImported };
}
/**
* This method calculates the total number of keys present in the response of a `/room_keys/keys` call.
*
* @param res - The response from the server containing the keys to be counted.
*
* @returns The total number of keys in the backup.
*/
private getTotalKeyCount(res: IRoomsKeysResponse): number {
const rooms = res.rooms;
let totalKeyCount = 0;
for (const roomData of Object.values(rooms)) {
if (!roomData.sessions) continue;
totalKeyCount += Object.keys(roomData.sessions).length;
}
return totalKeyCount;
}
/**
* This method handles the decryption of a full backup, i.e a call to `/room_keys/keys`.
* It will decrypt the keys in chunks and call the `block` callback for each chunk.
*
* @param res - The response from the server containing the keys to be decrypted.
* @param backupDecryptor - An instance of the BackupDecryptor class used to decrypt the keys.
* @param chunkSize - The size of the chunks to be processed at a time.
* @param block - A callback function that is called for each chunk of keys.
*
* @returns A promise that resolves when the decryption is complete.
*/
private async handleDecryptionOfAFullBackup(
res: IRoomsKeysResponse,
backupDecryptor: BackupDecryptor,
chunkSize: number,
block: (chunk: IMegolmSessionData[]) => Promise<void>,
): Promise<void> {
const rooms = (res as IRoomsKeysResponse).rooms;
let groupChunkCount = 0;
let chunkGroupByRoom: Map<string, IKeyBackupRoomSessions> = new Map();
const handleChunkCallback = async (roomChunks: Map<string, IKeyBackupRoomSessions>): Promise<void> => {
const currentChunk: IMegolmSessionData[] = [];
for (const roomId of roomChunks.keys()) {
const decryptedSessions = await backupDecryptor.decryptSessions(roomChunks.get(roomId)!);
for (const sessionId in decryptedSessions) {
const k = decryptedSessions[sessionId];
k.room_id = roomId;
currentChunk.push(k);
}
}
await block(currentChunk);
};
for (const [roomId, roomData] of Object.entries(rooms)) {
if (!roomData.sessions) continue;
chunkGroupByRoom.set(roomId, {});
for (const [sessionId, session] of Object.entries(roomData.sessions)) {
const sessionsForRoom = chunkGroupByRoom.get(roomId)!;
sessionsForRoom[sessionId] = session;
groupChunkCount += 1;
if (groupChunkCount >= chunkSize) {
// We have enough chunks to decrypt
await handleChunkCallback(chunkGroupByRoom);
chunkGroupByRoom = new Map();
// There might be remaining keys for that room, so add back an entry for the current room.
chunkGroupByRoom.set(roomId, {});
groupChunkCount = 0;
}
}
}
// Handle remaining chunk if needed
if (groupChunkCount > 0) {
await handleChunkCallback(chunkGroupByRoom);
}
}
public deleteKeysFromBackup(roomId: undefined, sessionId: undefined, version?: string): Promise<void>;
@@ -4004,7 +4130,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("End-to-end encryption disabled");
}
const roomEncryption = this.roomList.getRoomEncryption(roomId);
const roomEncryption = this.crypto?.getRoomEncryption(roomId);
if (!roomEncryption) {
// unknown room, or unencrypted room
this.logger.error("Unknown room. Not sharing decryption keys");
@@ -4326,9 +4452,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw new Error("cannot cancel an event with status " + event.status);
}
// if the event is currently being encrypted then
// If the event is currently being encrypted then remove it from the pending list, to indicate that it should
// not be sent.
if (event.status === EventStatus.ENCRYPTING) {
this.pendingEventEncryption.delete(event.getId()!);
this.eventsBeingEncrypted.delete(event.getId()!);
} else if (this.scheduler && event.status === EventStatus.QUEUED) {
// tell the scheduler to forget about it, if it's queued
this.scheduler.removeEventFromQueue(event);
@@ -4627,96 +4754,102 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* encrypts the event if necessary; adds the event to the queue, or sends it; marks the event as sent/unsent
* @returns returns a promise which resolves with the result of the send request
*/
protected encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
let cancelled = false;
// Add an extra Promise.resolve() to turn synchronous exceptions into promise rejections,
// so that we can handle synchronous and asynchronous exceptions with the
// same code path.
return Promise.resolve()
.then(() => {
const encryptionPromise = this.encryptEventIfNeeded(event, room ?? undefined);
if (!encryptionPromise) return null; // doesn't need encryption
protected async encryptAndSendEvent(room: Room | null, event: MatrixEvent): Promise<ISendEventResponse> {
try {
let cancelled: boolean;
this.eventsBeingEncrypted.add(event.getId()!);
try {
await this.encryptEventIfNeeded(event, room ?? undefined);
} finally {
cancelled = !this.eventsBeingEncrypted.delete(event.getId()!);
}
this.pendingEventEncryption.set(event.getId()!, encryptionPromise);
this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
return encryptionPromise.then(() => {
if (!this.pendingEventEncryption.has(event.getId()!)) {
// cancelled via MatrixClient::cancelPendingEvent
cancelled = true;
return;
}
this.updatePendingEventStatus(room, event, EventStatus.SENDING);
});
})
.then(() => {
if (cancelled) return {} as ISendEventResponse;
let promise: Promise<ISendEventResponse> | null = null;
if (this.scheduler) {
// if this returns a promise then the scheduler has control now and will
// resolve/reject when it is done. Internally, the scheduler will invoke
// processFn which is set to this._sendEventHttpRequest so the same code
// path is executed regardless.
promise = this.scheduler.queueEvent(event);
if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
// event is processed FIFO so if the length is 2 or more we know
// this event is stuck behind an earlier event.
this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
}
}
if (cancelled) {
// cancelled via MatrixClient::cancelPendingEvent
return {} as ISendEventResponse;
}
if (!promise) {
promise = this.sendEventHttpRequest(event);
if (room) {
promise = promise.then((res) => {
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
return res;
});
}
}
// encryptEventIfNeeded may have updated the status from SENDING to ENCRYPTING. If so, we need
// to put it back.
if (event.status === EventStatus.ENCRYPTING) {
this.updatePendingEventStatus(room, event, EventStatus.SENDING);
}
return promise;
})
.catch((err) => {
this.logger.error("Error sending event", err.stack || err);
try {
// set the error on the event before we update the status:
// updating the status emits the event, so the state should be
// consistent at that point.
event.error = err;
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
} catch (e) {
this.logger.error("Exception in error handler!", (<Error>e).stack || err);
let promise: Promise<ISendEventResponse> | null = null;
if (this.scheduler) {
// if this returns a promise then the scheduler has control now and will
// resolve/reject when it is done. Internally, the scheduler will invoke
// processFn which is set to this._sendEventHttpRequest so the same code
// path is executed regardless.
promise = this.scheduler.queueEvent(event);
if (promise && this.scheduler.getQueueForEvent(event)!.length > 1) {
// event is processed FIFO so if the length is 2 or more we know
// this event is stuck behind an earlier event.
this.updatePendingEventStatus(room, event, EventStatus.QUEUED);
}
if (err instanceof MatrixError) {
err.event = event;
}
if (!promise) {
promise = this.sendEventHttpRequest(event);
if (room) {
promise = promise.then((res) => {
room.updatePendingEvent(event, EventStatus.SENT, res["event_id"]);
return res;
});
}
throw err;
});
}
return await promise;
} catch (err) {
this.logger.error("Error sending event", err);
try {
// set the error on the event before we update the status:
// updating the status emits the event, so the state should be
// consistent at that point.
event.error = <MatrixError>err;
this.updatePendingEventStatus(room, event, EventStatus.NOT_SENT);
} catch (e) {
this.logger.error("Exception in error handler!", e);
}
if (err instanceof MatrixError) {
err.event = event;
}
throw err;
}
}
private encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> | null {
private async encryptEventIfNeeded(event: MatrixEvent, room?: Room): Promise<void> {
// If the room is unknown, we cannot encrypt for it
if (!room) return;
if (!(await this.shouldEncryptEventForRoom(event, room))) return;
if (!this.cryptoBackend && this.usingExternalCrypto) {
// The client has opted to allow sending messages to encrypted
// rooms even if the room is encrypted, and we haven't set up
// crypto. This is useful for users of matrix-org/pantalaimon
return;
}
if (!this.cryptoBackend) {
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
}
this.updatePendingEventStatus(room, event, EventStatus.ENCRYPTING);
await this.cryptoBackend.encryptEvent(event, room);
}
/**
* Determine whether a given event should be encrypted when we send it to the given room.
*
* This takes into account event type and room configuration.
*/
private async shouldEncryptEventForRoom(event: MatrixEvent, room: Room): Promise<boolean> {
if (event.isEncrypted()) {
// this event has already been encrypted; this happens if the
// encryption step succeeded, but the send step failed on the first
// attempt.
return null;
}
if (event.isRedaction()) {
// Redactions do not support encryption in the spec at this time,
// whilst it mostly worked in some clients, it wasn't compliant.
return null;
}
if (!room || !this.isRoomEncrypted(event.getRoomId()!)) {
return null;
}
if (!this.cryptoBackend && this.usingExternalCrypto) {
// The client has opted to allow sending messages to encrypted
// rooms even if the room is encrypted, and we haven't setup
// crypto. This is useful for users of matrix-org/pantalaimon
return null;
return false;
}
if (event.getType() === EventType.Reaction) {
@@ -4730,14 +4863,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// The reaction key / content / emoji value does warrant encrypting, but
// this will be handled separately by encrypting just this value.
// See https://github.com/matrix-org/matrix-doc/pull/1849#pullrequestreview-248763642
return null;
return false;
}
if (!this.cryptoBackend) {
throw new Error("This room is configured to use encryption, but your client does not support encryption.");
if (event.isRedaction()) {
// Redactions do not support encryption in the spec at this time.
// Whilst it mostly worked in some clients, it wasn't compliant.
return false;
}
return this.cryptoBackend.encryptEvent(event, room);
// If the room has an m.room.encryption event, we should encrypt.
if (room.hasEncryptionStateEvent()) return true;
// If we have a crypto impl, and *it* thinks we should encrypt, then we should.
if (await this.cryptoBackend?.isEncryptionEnabledInRoom(room.roomId)) return true;
// Otherwise, no need to encrypt.
return false;
}
/**
@@ -4752,7 +4894,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
eventType?: EventType | string | null,
): EventType | string | null | undefined {
if (eventType === EventType.Reaction) return eventType;
return this.isRoomEncrypted(roomId) ? EventType.RoomMessageEncrypted : eventType;
return this.getRoom(roomId)?.hasEncryptionStateEvent() ? EventType.RoomMessageEncrypted : eventType;
}
protected updatePendingEventStatus(room: Room | null, event: MatrixEvent, newStatus: EventStatus): void {
@@ -5165,7 +5307,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const room = this.getRoom(event.getRoomId());
if (room && this.credentials.userId) {
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType);
room.addLocalEchoReceipt(this.credentials.userId, event, receiptType, unthreaded);
}
return promise;
}
@@ -5682,6 +5824,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
* @param allowDirectLinks - If true, return any non-mxc URLs
* directly. Fetching such URLs will leak information about the user to
* anyone they share a room with. If false, will return null for such URLs.
* @param allowRedirects - If true, the caller supports the URL being 307 or
* 308 redirected to another resource upon request. If false, redirects
* are not expected.
* @returns the avatar URL or null.
*/
public mxcUrlToHttp(
@@ -5690,8 +5835,9 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
height?: number,
resizeMethod?: string,
allowDirectLinks?: boolean,
allowRedirects?: boolean,
): string | null {
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks);
return getHttpUriForMxc(this.baseUrl, mxcUrl, width, height, resizeMethod, allowDirectLinks, allowRedirects);
}
/**
@@ -5970,14 +6116,14 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const resOlder: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
null,
{ dir: Direction.Backward, from: res.start, recurse: recurse || undefined },
);
const resNewer: IRelationsResponse = await this.fetchRelations(
timelineSet.room.roomId,
thread.id,
THREAD_RELATION_TYPE.name,
null,
null,
{ dir: Direction.Forward, from: res.end, recurse: recurse || undefined },
);
@@ -5985,10 +6131,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
// Order events from most recent to oldest (reverse-chronological).
// We start with the last event, since that's the point at which we have known state.
// events_after is already backwards; events_before is forwards.
...resNewer.chunk.reverse().map(mapper),
...resNewer.chunk.reverse().filter(getRelationsThreadFilter(thread.id)).map(mapper),
event,
...resOlder.chunk.map(mapper),
...resOlder.chunk.filter(getRelationsThreadFilter(thread.id)).map(mapper),
];
for (const event of events) {
await timelineSet.thread?.processEvent(event);
}
@@ -6363,6 +6510,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
const stateEvents = res.state.filter(noUnsafeEventProps).map(this.getEventMapper());
roomState.setUnknownStateEvents(stateEvents);
}
const token = res.end;
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(this.getEventMapper());
@@ -6390,7 +6538,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
}
const recurse = this.canSupport.get(Feature.RelationsRecursion) !== ServerSupport.Unsupported;
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, THREAD_RELATION_TYPE.name, null, {
promise = this.fetchRelations(eventTimeline.getRoomId() ?? "", thread.id, null, null, {
dir,
limit: opts.limit,
from: token ?? undefined,
@@ -6398,7 +6546,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
})
.then(async (res) => {
const mapper = this.getEventMapper();
const matrixEvents = res.chunk.filter(noUnsafeEventProps).map(mapper);
const matrixEvents = res.chunk
.filter(noUnsafeEventProps)
.filter(getRelationsThreadFilter(thread.id))
.map(mapper);
// Process latest events first
for (const event of matrixEvents.slice().reverse()) {
@@ -7446,16 +7597,11 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
return this.serverVersionsPromise;
}
// We send an authenticated request as of MSC4026
this.serverVersionsPromise = this.http
.request<IServerVersions>(
Method.Get,
"/_matrix/client/versions",
undefined, // queryParams
undefined, // data
{
prefix: "",
},
)
.authedRequest<IServerVersions>(Method.Get, "/_matrix/client/versions", undefined, undefined, {
prefix: "",
})
.catch((e) => {
// Need to unset this if it fails, otherwise we'll never retry
this.serverVersionsPromise = undefined;
@@ -7593,7 +7739,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public async relations(
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
relationType: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<{
@@ -7728,6 +7874,8 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public setAccessToken(token: string): void {
this.http.opts.accessToken = token;
// The /versions response can vary for different users so clear the cache
this.serverVersionsPromise = undefined;
}
/**
@@ -8116,7 +8264,7 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
public fetchRelations(
roomId: string,
eventId: string,
relationType?: RelationType | string | null,
relationType: RelationType | string | null,
eventType?: EventType | string | null,
opts: IRelationsRequestOpts = { dir: Direction.Backward },
): Promise<IRelationsResponse> {
@@ -9815,6 +9963,20 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
throw err;
}
}
/**
* Get the OIDC issuer responsible for authentication on this server, if any
* @returns Resolves: A promise of an object containing the OIDC issuer if configured
* @returns Rejects: when the request fails (module:http-api.MatrixError)
* @experimental - part of MSC2965
*/
public async getAuthIssuer(): Promise<{
issuer: string;
}> {
return this.http.request(Method.Get, "/auth_issuer", undefined, undefined, {
prefix: ClientPrefix.Unstable + "/org.matrix.msc2965",
});
}
}
/**
@@ -9829,6 +9991,19 @@ export function fixNotificationCountOnDecryption(cli: MatrixClient, event: Matri
const room = cli.getRoom(event.getRoomId());
if (!room || !ourUserId || !eventId) return;
// Due to threads, we can get relation events (eg. edits & reactions) that never get
// added to a timeline and so cannot be found in their own room (their edit / reaction
// still applies to the event it needs to, so it doesn't matter too much). However, if
// we try to process notification about this event, we'll get very confused because we
// won't be able to find the event in the room, so will assume it must be unread, even
// if it's actually read. We therefore skip anything that isn't in the room. This isn't
// *great*, so if we can fix the homeless events (eg. with MSC4023) then we should probably
// remove this workaround.
if (!room.findEventById(eventId)) {
logger.info(`Decrypted event ${event.getId()} is not in room ${room.roomId}: ignoring`);
return;
}
const isThreadEvent = !!event.threadRootId && !event.isThreadRoot;
let hasReadEvent;
@@ -9913,7 +10088,7 @@ export function threadIdForReceipt(event: MatrixEvent): string {
* @returns true if this event is considered to be in the main timeline as far
* as receipts are concerned.
*/
function inMainTimelineForReceipt(event: MatrixEvent): boolean {
export function inMainTimelineForReceipt(event: MatrixEvent): boolean {
if (!event.threadRootId) {
// Not in a thread: then it is in the main timeline
return true;
+10 -1
View File
@@ -17,7 +17,7 @@ limitations under the License.
import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator";
import { IClearEvent, MatrixEvent } from "../models/event";
import { Room } from "../models/room";
import { CryptoApi } from "../crypto-api";
import { CryptoApi, ImportRoomKeysOpts } from "../crypto-api";
import { CrossSigningInfo, UserTrustLevel } from "../crypto/CrossSigning";
import { IEncryptedEventInfo } from "../crypto/api";
import { KeyBackupInfo, KeyBackupSession } from "../crypto-api/keybackup";
@@ -108,6 +108,15 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @param privKey - The private decryption key.
*/
getBackupDecryptor(backupInfo: KeyBackupInfo, privKey: ArrayLike<number>): Promise<BackupDecryptor>;
/**
* Import a list of room keys restored from backup
*
* @param keys - a list of session export objects
* @param opts - options object
* @returns a promise which resolves once the keys have been imported
*/
importBackedUpRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
}
/** The methods which crypto implementations should expose to the Sync api
+9
View File
@@ -28,6 +28,9 @@ import { encodeParams } from "./utils";
* directly. Fetching such URLs will leak information about the user to
* anyone they share a room with. If false, will return the emptry string
* for such URLs.
* @param allowRedirects - If true, the caller supports the URL being 307 or
* 308 redirected to another resource upon request. If false, redirects
* are not expected.
* @returns The complete URL to the content.
*/
export function getHttpUriForMxc(
@@ -37,6 +40,7 @@ export function getHttpUriForMxc(
height?: number,
resizeMethod?: string,
allowDirectLinks = false,
allowRedirects?: boolean,
): string {
if (typeof mxc !== "string" || !mxc) {
return "";
@@ -67,6 +71,11 @@ export function getHttpUriForMxc(
prefix = "/_matrix/media/v3/thumbnail/";
}
if (typeof allowRedirects === "boolean") {
// We add this after, so we don't convert everything to a thumbnail request.
params["allow_redirect"] = JSON.stringify(allowRedirects);
}
const fragmentOffset = serverAndMediaId.indexOf("#");
let fragment = "";
if (fragmentOffset >= 0) {
+83 -11
View File
@@ -18,7 +18,7 @@ import type { IMegolmSessionData } from "./@types/crypto";
import { Room } from "./models/room";
import { DeviceMap } from "./models/device";
import { UIAuthCallback } from "./interactive-auth";
import { AddSecretStorageKeyOpts, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { PassphraseInfo, SecretStorageCallbacks, SecretStorageKeyDescription } from "./secret-storage";
import { VerificationRequest } from "./crypto-api/verification";
import { BackupTrustInfo, KeyBackupCheck, KeyBackupInfo } from "./crypto-api/keybackup";
import { ISignatures } from "./@types/signed";
@@ -46,6 +46,25 @@ export interface CryptoApi {
*/
getVersion(): string;
/**
* Get the public part of the device keys for the current device.
*
* @returns The public device keys.
*/
getOwnDeviceKeys(): Promise<OwnDeviceKeys>;
/**
* Check if we believe the given room to be encrypted.
*
* This method returns true if the room has been configured with encryption. The setting is persistent, so that
* even if the encryption event is removed from the room state, it still returns true. This helps to guard against
* a downgrade attack wherein a server admin attempts to remove encryption.
*
* @returns `true` if the room with the supplied ID is encrypted. `false` if the room is not encrypted, or is unknown to
* us.
*/
isEncryptionEnabledInRoom(roomId: string): Promise<boolean>;
/**
* Perform any background tasks that can be done before a message is ready to
* send, in order to speed up sending of the message.
@@ -77,6 +96,17 @@ export interface CryptoApi {
*/
exportRoomKeys(): Promise<IMegolmSessionData[]>;
/**
* Get a JSON list containing all of the room keys
*
* This should be encrypted before returning it to the user.
*
* @returns a promise which resolves to a JSON string
* encoding a list of session export objects,
* each of which is an IMegolmSessionData
*/
exportRoomKeysAsJson(): Promise<string>;
/**
* Import a list of room keys previously exported by exportRoomKeys
*
@@ -86,6 +116,17 @@ export interface CryptoApi {
*/
importRoomKeys(keys: IMegolmSessionData[], opts?: ImportRoomKeysOpts): Promise<void>;
/**
* Import a JSON string encoding a list of room keys previously
* exported by exportRoomKeysAsJson
*
* @param keys - a JSON string encoding a list of session export
* objects, each of which is an IMegolmSessionData
* @param opts - options object
* @returns a promise which resolves once the keys have been imported
*/
importRoomKeysAsJson(keys: string, opts?: ImportRoomKeysOpts): Promise<void>;
/**
* Check if the given user has published cross-signing keys.
*
@@ -162,7 +203,7 @@ export interface CryptoApi {
/**
* Mark the given device as locally verified.
*
* Marking a devices as locally verified has much the same effect as completing the verification dance, or receiving
* Marking a device as locally verified has much the same effect as completing the verification dance, or receiving
* a cross-signing signature for it.
*
* @param userId - owner of the device
@@ -175,6 +216,21 @@ export interface CryptoApi {
*/
setDeviceVerified(userId: string, deviceId: string, verified?: boolean): Promise<void>;
/**
* Cross-sign one of our own devices.
*
* This will create a signature for the device using our self-signing key, and publish that signature.
* Cross-signing a device indicates, to our other devices and to other users, that we have verified that it really
* belongs to us.
*
* Requires that cross-signing has been set up on this device (normally by calling {@link bootstrapCrossSigning}).
*
* *Note*: Do not call this unless you have verified, somehow, that the device is genuine!
*
* @param deviceId - ID of the device to be signed.
*/
crossSignDevice(deviceId: string): Promise<void>;
/**
* Checks whether cross signing:
* - is enabled on this account and trusted by this device
@@ -559,25 +615,28 @@ export class DeviceVerificationStatus {
/**
* Room key import progress report.
* Used when calling {@link CryptoApi#importRoomKeys} as the parameter of
* Used when calling {@link CryptoApi#importRoomKeys} or
* {@link CryptoApi#importRoomKeysAsJson} as the parameter of
* the progressCallback. Used to display feedback.
*/
export interface ImportRoomKeyProgressData {
stage: string; // TODO: Enum
successes: number;
failures: number;
total: number;
successes?: number;
failures?: number;
total?: number;
}
/**
* Options object for {@link CryptoApi#importRoomKeys}.
* Options object for {@link CryptoApi#importRoomKeys} and
* {@link CryptoApi#importRoomKeysAsJson}.
*/
export interface ImportRoomKeysOpts {
/** Reports ongoing progress of the import process. Can be used for feedback. */
progressCallback?: (stage: ImportRoomKeyProgressData) => void;
// TODO, the rust SDK will always such imported keys as untrusted
/** @deprecated the rust SDK will always such imported keys as untrusted */
untrusted?: boolean;
source?: String; // TODO: Enum (backup, file, ??)
/** @deprecated not useful externally */
source?: string;
}
/**
@@ -687,10 +746,15 @@ export interface CrossSigningKeyInfo {
}
/**
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase}
* Recovery key created by {@link CryptoApi#createRecoveryKeyFromPassphrase} or {@link CreateSecretStorageOpts#createSecretStorageKey}.
*/
export interface GeneratedSecretStorageKey {
keyInfo?: AddSecretStorageKeyOpts;
keyInfo?: {
/** If the key was derived from a passphrase, information (algorithm, salt, etc) on that derivation. */
passphrase?: PassphraseInfo;
/** Optional human-readable name for the key, to be stored in account_data. */
name?: string;
};
/** The raw generated private key. */
privateKey: Uint8Array;
/** The generated key, encoded for display to the user per https://spec.matrix.org/v1.7/client-server-api/#key-representation. */
@@ -749,5 +813,13 @@ export enum EventShieldReason {
MISMATCHED_SENDER_KEY,
}
/** The result of a call to {@link CryptoApi.getOwnDeviceKeys} */
export interface OwnDeviceKeys {
/** Public part of the Ed25519 fingerprint key for the current device, base64 encoded. */
ed25519: string;
/** Public part of the Curve25519 identity key for the current device, base64 encoded. */
curve25519: string;
}
export * from "./crypto-api/verification";
export * from "./crypto-api/keybackup";
+1 -1
View File
@@ -64,7 +64,7 @@ export interface InboundGroupSessionData {
room_id: string; // eslint-disable-line camelcase
/** pickled Olm.InboundGroupSession */
session: string;
keysClaimed: Record<string, string>;
keysClaimed?: Record<string, string>;
/** Devices involved in forwarding this session to us (normally empty). */
forwardingCurve25519KeyChain: string[];
/** whether this session is untrusted. */
+8 -1
View File
@@ -29,6 +29,13 @@ export interface IRoomEncryption {
}
/* eslint-enable camelcase */
/**
* Information about the encryption settings of rooms. Loads this information
* from the supplied crypto store when `init()` is called, and saves it to the
* crypto store whenever it is updated via `setRoomEncryption()`. Can supply
* full information about a room's encryption via `getRoomEncryption()`, or just
* answer whether or not a room has encryption via `isRoomEncrypted`.
*/
export class RoomList {
// Object of roomId -> room e2e info object (body of the m.room.encryption event)
private roomEncryption: Record<string, IRoomEncryption> = {};
@@ -43,7 +50,7 @@ export class RoomList {
});
}
public getRoomEncryption(roomId: string): IRoomEncryption {
public getRoomEncryption(roomId: string): IRoomEncryption | null {
return this.roomEncryption[roomId] || null;
}
+4 -1
View File
@@ -38,7 +38,10 @@ interface ISecretRequestInternal {
export class SecretSharing {
private requests = new Map<string, ISecretRequestInternal>();
public constructor(private readonly baseApis: MatrixClient, private readonly cryptoCallbacks: ICryptoCallbacks) {}
public constructor(
private readonly baseApis: MatrixClient,
private readonly cryptoCallbacks: ICryptoCallbacks,
) {}
/**
* Request a secret from another device
+1 -5
View File
@@ -73,11 +73,7 @@ export class SecretStorage<B extends MatrixClient | undefined = MatrixClient> im
/**
* Add a key for encrypting secrets.
*/
public addKey(
algorithm: string,
opts: AddSecretStorageKeyOpts = {},
keyId?: string,
): Promise<SecretStorageKeyObject> {
public addKey(algorithm: string, opts: AddSecretStorageKeyOpts, keyId?: string): Promise<SecretStorageKeyObject> {
return this.storageImpl.addKey(algorithm, opts, keyId);
}
+10 -2
View File
@@ -210,7 +210,11 @@ export abstract class DecryptionAlgorithm {
export class DecryptionError extends Error {
public readonly detailedString: string;
public constructor(public readonly code: string, msg: string, details?: Record<string, string | Error>) {
public constructor(
public readonly code: string,
msg: string,
details?: Record<string, string | Error>,
) {
super(msg);
this.code = code;
this.name = "DecryptionError";
@@ -242,7 +246,11 @@ export class UnknownDeviceError extends Error {
* @param msg - message describing the problem
* @param devices - set of unknown devices per user we're warning about
*/
public constructor(msg: string, public readonly devices: DeviceInfoMap, public event?: MatrixEvent) {
public constructor(
msg: string,
public readonly devices: DeviceInfoMap,
public event?: MatrixEvent,
) {
super(msg);
this.name = "UnknownDeviceError";
this.devices = devices;
+4 -1
View File
@@ -164,7 +164,10 @@ class OutboundSessionInfo {
* @param sharedHistory - whether the session can be freely shared with
* other group members, according to the room history visibility settings
*/
public constructor(public readonly sessionId: string, public readonly sharedHistory = false) {
public constructor(
public readonly sessionId: string,
public readonly sharedHistory = false,
) {
this.creationTime = new Date().getTime();
}
+8 -2
View File
@@ -124,7 +124,10 @@ export class BackupManager {
// The backup manager will schedule backup of keys when active (`scheduleKeyBackupSend`), this allows cancel when client is stopped
private clientRunning = true;
public constructor(private readonly baseApis: MatrixClient, public readonly getKey: GetKey) {
public constructor(
private readonly baseApis: MatrixClient,
public readonly getKey: GetKey,
) {
this.checkedForBackup = false;
this.sendingBackups = false;
}
@@ -773,7 +776,10 @@ const UNSTABLE_MSC3270_NAME = new UnstableValue(
export class Aes256 implements BackupAlgorithm {
public static algorithmName = UNSTABLE_MSC3270_NAME.name;
public constructor(public readonly authData: IAes256AuthData, private readonly key: Uint8Array) {}
public constructor(
public readonly authData: IAes256AuthData,
private readonly key: Uint8Array,
) {}
public static async init(authData: IAes256AuthData, getKey: () => Promise<Uint8Array>): Promise<Aes256> {
if (!authData) {

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